Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f91583efa0 | |||
| cc745700e8 | |||
| e6b6ec519c | |||
| 3de9e05076 | |||
| b1da145611 | |||
| 3b1e705e8b | |||
| bde54b48a9 | |||
| a448c1304a | |||
| 251d36d47d | |||
| b197e5c383 | |||
| cd7f51dbe6 | |||
| 761563f04e | |||
| 5c5ec2c5a5 | |||
| dbdced6aa9 | |||
| 644734bb7c | |||
| 0541076f90 | |||
| c7dbd6c3e4 | |||
| 2e0507380b | |||
| 993379f184 | |||
| f0a52caae6 | |||
| 71f485b76c | |||
| 18a0be64a9 | |||
| 579e044e54 | |||
| 643dd5c1f5 | |||
| ab43d5a9dc | |||
| a336acd23d | |||
| b103d02f17 | |||
| f14ad38cb4 | |||
| e40607dfee | |||
| ddf9006edf | |||
| d3249101f8 | |||
| cf23d2aead | |||
| bc59544b07 | |||
| 2567b2f6ef | |||
| 1028777a9f | |||
| 72df19b513 | |||
| dc25031eed | |||
| ba6e8f668e | |||
| 76cb9ddedb | |||
| f6e836a98d | |||
| ed3662de5e | |||
| 844664c642 | |||
| 6aa7c52be6 | |||
| 346245d860 | |||
| e6aad44c0f | |||
| d049e8fe1c | |||
| be7db9e9df | |||
| a70c291737 | |||
| 4ab16ca805 | |||
| ca50e9affb | |||
| 266131205d | |||
| be07f24270 | |||
| 247848d009 | |||
| 5fbc33d78a | |||
| 53e0fa884a | |||
| 550b75c1f4 | |||
| 6e7918212f | |||
| 8a29dac385 | |||
| 097a5a9613 | |||
| 9c86bd8de1 | |||
| 4b0b56aa6a | |||
| d1215a84c4 | |||
| 3d0439503c | |||
| 04fe77ac41 | |||
| 6a87864176 | |||
| 3a6f447874 | |||
| b92dc7895c | |||
| 5c2cbd265a | |||
| 455bf4a0b3 | |||
| f22f715756 | |||
| c4713bafa7 | |||
| bac1dc0701 | |||
| 0e0fc210b5 | |||
| bc9c930d7c | |||
| d5910dc3b2 |
@@ -766,7 +766,7 @@ def list_queued_issues() -> list[dict]:
|
||||
query={
|
||||
"state": "open",
|
||||
"type": "pulls",
|
||||
"labels": QUEUE_LABEL,
|
||||
"label": QUEUE_LABEL,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1170,7 +1170,9 @@ def enumerate_readiness(*, dry_run: bool = False) -> list[ReadinessEntry]:
|
||||
post-batch summary can be printed.
|
||||
"""
|
||||
bp = get_branch_protection(WATCH_BRANCH)
|
||||
contexts = bp.required_contexts
|
||||
# Uniform gate: governance checks are ALWAYS required, even if branch
|
||||
# protection does not enumerate them. Deduplicate against BP list.
|
||||
contexts = list(dict.fromkeys(bp.required_contexts + GOVERNANCE_REQUIRED_CONTEXTS))
|
||||
required_approvals = bp.required_approvals
|
||||
|
||||
main_sha = get_branch_head(WATCH_BRANCH)
|
||||
|
||||
@@ -165,7 +165,7 @@ def api(
|
||||
# Format: "<workflow_name> / <job_name_or_key> (<event>)"
|
||||
# Examples observed on molecule-core/main:
|
||||
# "Secret scan / Scan diff for credential-shaped strings (pull_request)"
|
||||
# " / tier-check (pull_request)"
|
||||
# "sop-checklist / all-items-acked (pull_request)"
|
||||
#
|
||||
# Split strategy: peel off the trailing ` (<event>)` first, then split
|
||||
# the leading `<workflow> / <rest>` on the FIRST ` / ` (workflow names
|
||||
|
||||
@@ -50,15 +50,15 @@ class TestQaReviewDirectTrigger:
|
||||
"pull_request_review must include 'submitted' type"
|
||||
)
|
||||
|
||||
def test_job_guard_requires_approved_state(self):
|
||||
def test_job_guard_has_no_review_state_check(self):
|
||||
wf = load_workflow("qa-review.yml")
|
||||
guard = _job_guard_string(wf)
|
||||
assert "github.event.review.state == 'APPROVED'" in guard, (
|
||||
"job guard must check review.state for 'APPROVED'"
|
||||
)
|
||||
assert "github.event.review.state == 'approved'" in guard, (
|
||||
"job guard must check review.state for 'approved' (case fallback per #2135)"
|
||||
assert "github.event.review.state" not in guard, (
|
||||
"job guard must NOT check review.state (#2159: Gitea 1.22.6 payload unreliable); "
|
||||
"evaluator (review-check.sh) verifies actual APPROVE via API"
|
||||
)
|
||||
assert "github.event_name == 'pull_request_target'" in guard
|
||||
assert "github.event_name == 'pull_request_review'" in guard
|
||||
|
||||
def test_post_step_uses_status_post_token(self):
|
||||
wf = load_workflow("qa-review.yml")
|
||||
@@ -91,15 +91,15 @@ class TestSecurityReviewDirectTrigger:
|
||||
"pull_request_review must include 'submitted' type"
|
||||
)
|
||||
|
||||
def test_job_guard_requires_approved_state(self):
|
||||
def test_job_guard_has_no_review_state_check(self):
|
||||
wf = load_workflow("security-review.yml")
|
||||
guard = _job_guard_string(wf)
|
||||
assert "github.event.review.state == 'APPROVED'" in guard, (
|
||||
"job guard must check review.state for 'APPROVED'"
|
||||
)
|
||||
assert "github.event.review.state == 'approved'" in guard, (
|
||||
"job guard must check review.state for 'approved' (case fallback per #2135)"
|
||||
assert "github.event.review.state" not in guard, (
|
||||
"job guard must NOT check review.state (#2159: Gitea 1.22.6 payload unreliable); "
|
||||
"evaluator (review-check.sh) verifies actual APPROVE via API"
|
||||
)
|
||||
assert "github.event_name == 'pull_request_target'" in guard
|
||||
assert "github.event_name == 'pull_request_review'" in guard
|
||||
|
||||
def test_post_step_uses_status_post_token(self):
|
||||
wf = load_workflow("security-review.yml")
|
||||
@@ -153,7 +153,7 @@ class TestRefireTokenSeparation:
|
||||
"qa refire must receive STATUS_POST_TOKEN env var"
|
||||
)
|
||||
# Evaluator stays on read token
|
||||
assert "SOP_TIER_CHECK_TOKEN" in env.get("GITEA_TOKEN", "") or "GITHUB_TOKEN" in env.get("GITEA_TOKEN", ""), (
|
||||
assert "SOP_CHECKLIST_GATE_TOKEN" in env.get("GITEA_TOKEN", "") or "GITHUB_TOKEN" in env.get("GITEA_TOKEN", ""), (
|
||||
"qa refire evaluator must stay on read-scoped token"
|
||||
)
|
||||
|
||||
@@ -163,6 +163,6 @@ class TestRefireTokenSeparation:
|
||||
assert env.get("STATUS_POST_TOKEN") == "${{ secrets.STATUS_POST_TOKEN }}", (
|
||||
"security refire must receive STATUS_POST_TOKEN env var"
|
||||
)
|
||||
assert "SOP_TIER_CHECK_TOKEN" in env.get("GITEA_TOKEN", "") or "GITHUB_TOKEN" in env.get("GITEA_TOKEN", ""), (
|
||||
assert "SOP_CHECKLIST_GATE_TOKEN" in env.get("GITEA_TOKEN", "") or "GITHUB_TOKEN" in env.get("GITEA_TOKEN", ""), (
|
||||
"security refire evaluator must stay on read-scoped token"
|
||||
)
|
||||
|
||||
@@ -333,6 +333,27 @@ def test_governance_red_blocks_merge():
|
||||
assert "required contexts not green" in decision.reason
|
||||
|
||||
|
||||
def test_non_required_red_does_not_block_merge():
|
||||
# Uniform gate flip (CTO #2407): qa-review, security-review, sop-checklist
|
||||
# are REQUIRED for ALL PRs. A PR with these failing/pending must NOT be
|
||||
# force-mergeable, even if BP-required CI is green and approvals are genuine.
|
||||
pr_status = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
{"context": "qa-review / approved (pull_request)", "status": "failure"},
|
||||
{"context": "security-review / approved (pull_request)", "status": "pending"},
|
||||
{"context": "sop-checklist / all-items-acked (pull_request)", "status": "failure"},
|
||||
{"context": "Staging SaaS / e2e (pull_request)", "status": "failure"},
|
||||
],
|
||||
}
|
||||
decision = mq.evaluate_merge_readiness(**_ready_kwargs(pr_status=pr_status))
|
||||
assert decision.ready is False
|
||||
assert decision.action == "wait"
|
||||
assert "required contexts not green" in decision.reason
|
||||
assert decision.force is False
|
||||
|
||||
|
||||
def test_non_required_advisory_red_does_not_block_merge():
|
||||
# Governance checks are green; only advisory non-required reds (Staging SaaS)
|
||||
# are present → PR is still mergeable with force_merge bypassing the advisory.
|
||||
@@ -1182,7 +1203,7 @@ def test_list_candidate_issues_omits_label_filter_when_auto_discover(monkeypatch
|
||||
assert captured["query"].get("type") == "pulls"
|
||||
|
||||
mq.list_candidate_issues(auto_discover=False)
|
||||
assert captured["query"].get("labels") == "merge-queue"
|
||||
assert captured["query"].get("label") == "merge-queue"
|
||||
|
||||
|
||||
def _wire_ready_process_once(monkeypatch, *, issues, pr_payload, calls):
|
||||
|
||||
@@ -35,11 +35,33 @@ if grep -q '_is_tier_low_pending_ok' .gitea/scripts/gitea-merge-queue.py; then
|
||||
fi
|
||||
|
||||
# 5. No sop-tier-check context references in workflow YAML
|
||||
if grep -r 'sop-tier-check' .gitea/workflows/; then
|
||||
if grep -rI --exclude-dir='__pycache__' 'sop-tier-check' .gitea/workflows/; then
|
||||
echo "FAIL: sop-tier-check context reappeared in workflows" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# 6. No SOP_TIER_CHECK_TOKEN references in workflow YAML or scripts
|
||||
if grep -rI --exclude-dir='__pycache__' --exclude='test_no_tier_regression.sh' 'SOP_TIER_CHECK_TOKEN' .gitea/workflows/ .gitea/scripts/; then
|
||||
echo "FAIL: SOP_TIER_CHECK_TOKEN reference reappeared (use SOP_CHECKLIST_GATE_TOKEN)" >&2
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# 7. qa-review and security-review must have labeled/unlabeled triggers (#2139)
|
||||
for f in .gitea/workflows/qa-review.yml .gitea/workflows/security-review.yml; do
|
||||
if ! grep -q 'labeled, unlabeled' "$f"; then
|
||||
echo "FAIL: $f missing labeled/unlabeled triggers (#2139)" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
# 8. qa-review and security-review must NOT have review.state guard (#2159)
|
||||
for f in .gitea/workflows/qa-review.yml .gitea/workflows/security-review.yml; do
|
||||
if grep -q 'github.event.review.state' "$f"; then
|
||||
echo "FAIL: $f has review.state guard reappeared (#2159)" >&2
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" -eq 1 ]; then
|
||||
echo "TIER_REGRESSION_DETECTED" >&2
|
||||
exit 1
|
||||
|
||||
@@ -149,7 +149,11 @@ items:
|
||||
|
||||
- slug: memory-consulted
|
||||
numeric_alias: 7
|
||||
pr_section_marker: "Memory/saved-feedback consulted"
|
||||
# #1973: normalize marker so it matches the slug. Previously the
|
||||
# slash produced a checklist status that never resolved because
|
||||
# normalize_slug() collapses / to - and the Gitea PR body parser
|
||||
# would not find the expected heading.
|
||||
pr_section_marker: "Memory consulted"
|
||||
required_teams: [engineers]
|
||||
ai_ack_eligible: true
|
||||
description: >-
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Detect force-merge + emit audit event
|
||||
env:
|
||||
# Same org-level secret the sop-checklist workflow uses.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
# Gitea persona whose ONLY job is reading branch_protections
|
||||
# and posting the [ci-drift] tracking issue. The endpoint
|
||||
# `GET /repos/.../branch_protections/{branch}` requires
|
||||
# repo-ADMIN role (Gitea 1.22.6) — SOP_TIER_CHECK_TOKEN and the
|
||||
# repo-ADMIN role (Gitea 1.22.6) — the default GITHUB_TOKEN and the
|
||||
# auto-injected GITHUB_TOKEN do NOT have it (read-only / write
|
||||
# without admin), so the previous fallback chain 403'd.
|
||||
# Mirrors the controlplane fix landed in CP PR#134.
|
||||
|
||||
@@ -309,6 +309,11 @@ jobs:
|
||||
# #1815 — wires coverage into CI so we get a baseline visible on
|
||||
# every PR. No threshold gate yet; thresholds dial in (Step 3, also
|
||||
# tracked in #1815) after the team sees what current coverage is.
|
||||
# Memory: the full vitest+v8-coverage process tree peaks at ~1.33 GB
|
||||
# (measured 2026-06-08), comfortably within the runner — so this single
|
||||
# run is BOTH the pass/fail gate and the coverage artifact (one SSOT, no
|
||||
# split). The earlier intermittent red here was a DisplayTab paste-race
|
||||
# (fixed in this PR), NOT a coverage OOM.
|
||||
run: npx vitest run --coverage
|
||||
- name: Upload coverage summary as artifact
|
||||
if: ${{ needs.changes.outputs.canvas == 'true' }}
|
||||
|
||||
@@ -429,6 +429,9 @@ jobs:
|
||||
# round-trip is covered by the priority-runtimes `mock` arm, not here.
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: bash tests/e2e/test_keyless_feature_contracts_e2e.sh
|
||||
- name: Run user_tasks E2E (REST + MCP — agent→user action requests)
|
||||
if: needs.detect-changes.outputs.api == 'true'
|
||||
run: bash tests/e2e/test_user_tasks_e2e.sh
|
||||
- name: Run secrets-dispatch contract test (keyless SECRETS_JSON branch order)
|
||||
# Previously orphaned (no workflow referenced it). Hermetic unit-style
|
||||
# contract over test_staging_full_saas.sh's LLM-key branch precedence —
|
||||
|
||||
@@ -54,6 +54,13 @@ on:
|
||||
- 'tests/e2e/lib/model_slug.sh'
|
||||
- 'tests/e2e/lib/aws_leak_check.sh'
|
||||
- 'tests/e2e/test_aws_leak_check.sh'
|
||||
- 'tests/e2e/test_staging_concierge_e2e.sh'
|
||||
- 'tests/e2e/test_staging_concierge_creates_workspace_e2e.sh'
|
||||
- 'workspace-server/internal/staginge2e/**'
|
||||
- 'workspace-server/internal/handlers/platform_agent.go'
|
||||
- 'workspace-server/internal/handlers/user_tasks.go'
|
||||
- 'workspace-server/internal/handlers/llm_billing_mode_handler.go'
|
||||
- 'workspace-server/internal/handlers/discovery.go'
|
||||
- '.gitea/workflows/e2e-staging-saas.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -69,6 +76,13 @@ on:
|
||||
- 'tests/e2e/lib/model_slug.sh'
|
||||
- 'tests/e2e/lib/aws_leak_check.sh'
|
||||
- 'tests/e2e/test_aws_leak_check.sh'
|
||||
- 'tests/e2e/test_staging_concierge_e2e.sh'
|
||||
- 'tests/e2e/test_staging_concierge_creates_workspace_e2e.sh'
|
||||
- 'workspace-server/internal/staginge2e/**'
|
||||
- 'workspace-server/internal/handlers/platform_agent.go'
|
||||
- 'workspace-server/internal/handlers/user_tasks.go'
|
||||
- 'workspace-server/internal/handlers/llm_billing_mode_handler.go'
|
||||
- 'workspace-server/internal/handlers/discovery.go'
|
||||
- '.gitea/workflows/e2e-staging-saas.yml'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
@@ -496,3 +510,341 @@ jobs:
|
||||
echo "::warning::platform-boot teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
# ── CONCIERGE user_tasks PRIMITIVE (Feature 3) — real-staging REST+MCP+authz ──
|
||||
#
|
||||
# Drives tests/e2e/test_staging_concierge_e2e.sh against a fresh throwaway
|
||||
# tenant: the full agent→user "ask" contract over BOTH surfaces (REST +
|
||||
# the MCP tools/call envelope a canvas concierge agent uses) PLUS the
|
||||
# cross-workspace authz scoping (ws-B can't touch ws-A's task). Reuses the
|
||||
# same CP-admin org-provision/teardown scaffolding + _lib.sh + AWS-leak-check
|
||||
# lib as the full-SaaS harness (the script SOURCEs them — no duplication).
|
||||
#
|
||||
# GATING (no continue-on-error): user_tasks is a pure DB/handler primitive
|
||||
# with NO LLM container dependency (workspaces are created 'external' — row
|
||||
# only, no EC2), so this is fast (~provision + TLS, no 10-min cold boot) and
|
||||
# NOT subject to the cp#245 boot-timeout flake the full-SaaS job carries. It
|
||||
# therefore has no honest reason to be masked. Runs on push-to-main /
|
||||
# workflow_dispatch / cron only (needs live staging infra — never on PR, where
|
||||
# the pr-validate job above already posts the workflow's PR status).
|
||||
# bp-required: pending #2430
|
||||
e2e-staging-concierge-user-tasks:
|
||||
name: E2E Staging Concierge user_tasks
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
E2E_AWS_LEAK_CHECK: required
|
||||
E2E_AWS_TERMINATE_LEAKS: '1'
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify admin token + AWS creds present
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
echo "::error::$var not set — EC2 leak verification cannot run"
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
echo "Admin token + AWS creds present ✓"
|
||||
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::Staging CP unhealthy (got HTTP $code). Skipping — not a workspace bug."
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging CP healthy ✓"
|
||||
|
||||
- name: Run concierge user_tasks E2E
|
||||
run: bash tests/e2e/test_staging_concierge_e2e.sh
|
||||
|
||||
- name: Teardown safety net (runs on cancel/failure)
|
||||
if: always()
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
run: |
|
||||
# Sweep any e2e-cncrg-YYYYMMDD-<run_id>-* org this run created if the
|
||||
# script died before its EXIT trap fired. Run-id scoped so it never
|
||||
# stomps a concurrent run's fresh tenant (see the saas job's note).
|
||||
set +e
|
||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import json, sys, os, datetime
|
||||
run_id = os.environ.get('GITHUB_RUN_ID', '')
|
||||
d = json.load(sys.stdin)
|
||||
today = datetime.date.today()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
dates = (today.strftime('%Y%m%d'), yesterday.strftime('%Y%m%d'))
|
||||
if run_id:
|
||||
prefixes = tuple(f'e2e-cncrg-{d}-{run_id}-' for d in dates)
|
||||
else:
|
||||
prefixes = tuple(f'e2e-cncrg-{d}-' for d in dates)
|
||||
candidates = [o['slug'] for o in d.get('orgs', [])
|
||||
if any(o.get('slug','').startswith(p) for p in prefixes)
|
||||
and o.get('instance_status') not in ('purged',)]
|
||||
print('\n'.join(candidates))
|
||||
" 2>/dev/null)
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
echo "Safety-net teardown: $slug"
|
||||
set +e
|
||||
curl -sS -o /tmp/cncrg-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/tmp/cncrg-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/cncrg-cleanup.code 2>/dev/null || echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::concierge teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/cncrg-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::concierge teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
# ── CONCIERGE FUNCTIONAL: it ACTUALLY CREATES A WORKSPACE (real-LLM) ─────────
|
||||
#
|
||||
# Drives tests/e2e/test_staging_concierge_creates_workspace_e2e.sh — the
|
||||
# RFC docs/design/rfc-platform-agent.md §11.4 "Reach" check turned into a gate:
|
||||
# send the org concierge a natural-language A2A message ("create a workspace
|
||||
# named e2e-cncrg-worker-<runid> with role engineer") and assert the
|
||||
# DETERMINISTIC SIDE EFFECT — that named workspace now EXISTS in GET /workspaces
|
||||
# — which can only happen if the concierge's LLM really invoked the
|
||||
# create_workspace platform-MCP tool (a real org mutation), NOT just that a REST
|
||||
# API returned 200.
|
||||
#
|
||||
# GATING (no continue-on-error), but FALSE-GREEN-PROOF via E2E_REQUIRE_LIVE=1:
|
||||
# this is a REAL-LLM, REAL-tool test, so it depends on the concierge being
|
||||
# provisioned on the DEDICATED platform-agent image (Dockerfile.platform-agent,
|
||||
# ships /opt/molecule-mcp-server — the ONLY image where create_workspace lights
|
||||
# up; see platform_agent.go's SELF-HOST CAVEAT). A parallel agent is wiring that
|
||||
# image into the staging provision path. The script SKIPs LOUD when the
|
||||
# concierge is absent / not online / not on the platform-agent image — but with
|
||||
# E2E_REQUIRE_LIVE=1 the harness converts that skip into a HARD FAIL (exit 5) so
|
||||
# a silently-missing platform-agent image can NEVER false-green this gate. Runs
|
||||
# on push-to-main / workflow_dispatch / cron only (needs live staging infra +
|
||||
# a model — never on PR, where pr-validate posts the workflow's PR status).
|
||||
# bp-required: pending #2430
|
||||
e2e-staging-concierge-creates-workspace:
|
||||
name: E2E Staging Concierge Creates Workspace
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
MOLECULE_CP_URL: https://staging-api.moleculesai.app
|
||||
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: us-east-2
|
||||
E2E_AWS_LEAK_CHECK: required
|
||||
E2E_AWS_TERMINATE_LEAKS: '1'
|
||||
# The concierge is platform_managed on SaaS (the CP-exported LLM proxy
|
||||
# supplies its model — no BYOK key needed for the concierge itself). The
|
||||
# MiniMax key is wired anyway so a staging image that boots the concierge
|
||||
# BYOK-MiniMax (parallel-agent image work) still has a model; harmless when
|
||||
# the concierge is platform-managed.
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
# False-green guard: a concierge that is absent / not on the platform-agent
|
||||
# image / never online must FAIL this gate (exit 5), not silently skip.
|
||||
E2E_REQUIRE_LIVE: '1'
|
||||
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Verify admin token + AWS creds present
|
||||
run: |
|
||||
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
for var in AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
|
||||
if [ -z "${!var:-}" ]; then
|
||||
echo "::error::$var not set — EC2 leak verification cannot run"
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
echo "Admin token + AWS creds present ✓"
|
||||
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::Staging CP unhealthy (got HTTP $code). Skipping — not a workspace bug."
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging CP healthy ✓"
|
||||
|
||||
- name: Run concierge-creates-workspace functional E2E
|
||||
run: bash tests/e2e/test_staging_concierge_creates_workspace_e2e.sh
|
||||
|
||||
- name: Teardown safety net (runs on cancel/failure)
|
||||
if: always()
|
||||
env:
|
||||
ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
run: |
|
||||
# Sweep any e2e-cncrg-mk-YYYYMMDD-<run_id>-* org this run created if the
|
||||
# script died before its EXIT trap fired. Run-id scoped so it never
|
||||
# stomps a concurrent run's fresh tenant.
|
||||
set +e
|
||||
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "
|
||||
import json, sys, os, datetime
|
||||
run_id = os.environ.get('GITHUB_RUN_ID', '')
|
||||
d = json.load(sys.stdin)
|
||||
today = datetime.date.today()
|
||||
yesterday = today - datetime.timedelta(days=1)
|
||||
dates = (today.strftime('%Y%m%d'), yesterday.strftime('%Y%m%d'))
|
||||
if run_id:
|
||||
prefixes = tuple(f'e2e-cncrg-mk-{d}-{run_id}-' for d in dates)
|
||||
else:
|
||||
prefixes = tuple(f'e2e-cncrg-mk-{d}-' for d in dates)
|
||||
candidates = [o['slug'] for o in d.get('orgs', [])
|
||||
if any(o.get('slug','').startswith(p) for p in prefixes)
|
||||
and o.get('instance_status') not in ('purged',)]
|
||||
print('\n'.join(candidates))
|
||||
" 2>/dev/null)
|
||||
leaks=()
|
||||
for slug in $orgs; do
|
||||
echo "Safety-net teardown: $slug"
|
||||
set +e
|
||||
curl -sS -o /tmp/cncrg-mk-cleanup.out -w "%{http_code}" \
|
||||
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$slug\"}" >/tmp/cncrg-mk-cleanup.code
|
||||
set -e
|
||||
code=$(cat /tmp/cncrg-mk-cleanup.code 2>/dev/null || echo "000")
|
||||
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
|
||||
echo "[teardown] deleted $slug (HTTP $code)"
|
||||
else
|
||||
echo "::warning::concierge-mk teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within ~45 min. Body: $(head -c 300 /tmp/cncrg-mk-cleanup.out 2>/dev/null)"
|
||||
leaks+=("$slug")
|
||||
fi
|
||||
done
|
||||
if [ ${#leaks[@]} -gt 0 ]; then
|
||||
echo "::warning::concierge-mk teardown left ${#leaks[@]} leak(s): ${leaks[*]}"
|
||||
fi
|
||||
exit 0
|
||||
|
||||
# ── CONCIERGE / PLATFORM-AGENT Go staginge2e (Features 1,2,4,5,6) ────────────
|
||||
#
|
||||
# Drives TestConciergePlatformAgent_Staging (workspace-server/internal/
|
||||
# staginge2e/concierge_platform_test.go), which REUSES the lifecycle suite's
|
||||
# harness (requireStagingEnv / adminCreateOrg / tenantAdminToken /
|
||||
# tenantCreateWorkspace / doTenantJSON / jsonField) to assert, against a real
|
||||
# tenant: platform-agent install + /org/identity (1), kind on the workspace
|
||||
# API (2), discovery peers admin-auth regression guard (4), BYOK billing-mode
|
||||
# round-trip (5), and the concierge config-tab auth sweep (6). It asserts
|
||||
# OBSERVABLE state (sole root re-parenting, kind discriminator, resolved_mode,
|
||||
# non-401 tabs) — not just HTTP 200.
|
||||
#
|
||||
# Two jobs, mirroring e2e-workspace-lifecycle.yml's honest pattern:
|
||||
# • concierge-compile-skip (every push/PR/dispatch): proves the staginge2e
|
||||
# suite still COMPILES under -tags=staging_e2e and SKIPs LOUD without
|
||||
# creds. GATING (no mask) — a broken test file fails at PR time.
|
||||
# • concierge-staging (push-to-main/dispatch/cron): the real live run with
|
||||
# staging creds + t.Cleanup teardown.
|
||||
# bp-exempt: PR-time compile-only check (build the concierge e2e test, then
|
||||
# skip execution — no staging creds on PR). pr-validate posts the workflow's
|
||||
# PR status; this job is not itself a branch-protection gate.
|
||||
e2e-staging-concierge-compile-skip:
|
||||
name: E2E Staging Concierge (compile+skip)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
- name: go vet (staging_e2e tag)
|
||||
working-directory: workspace-server
|
||||
run: go vet -tags staging_e2e ./internal/staginge2e/...
|
||||
- name: Compile + skip-run (must SKIP LOUD without STAGING_E2E)
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
# No STAGING_E2E / creds → the suite MUST skip (not pass-with-zero-
|
||||
# assertions). go test exit 0 with a SKIP line is the contract.
|
||||
out=$(go test -tags staging_e2e ./internal/staginge2e/ -run TestConciergePlatformAgent -count=1 -v 2>&1)
|
||||
echo "$out"
|
||||
echo "$out" | grep -q "SKIP: TestConciergePlatformAgent_Staging" \
|
||||
|| { echo "::error::expected a LOUD skip of TestConciergePlatformAgent_Staging without creds"; exit 1; }
|
||||
|
||||
# bp-required: pending #2430
|
||||
e2e-staging-concierge-platform:
|
||||
name: E2E Staging Concierge Platform Agent
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event_name == 'schedule'
|
||||
timeout-minutes: 40
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
CP_BASE_URL: https://staging-api.moleculesai.app
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
STAGING_E2E: '1'
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
- name: Verify admin token present
|
||||
run: |
|
||||
if [ -z "$CP_ADMIN_API_TOKEN" ]; then
|
||||
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
|
||||
exit 2
|
||||
fi
|
||||
echo "Admin token present"
|
||||
- name: CP staging health preflight
|
||||
run: |
|
||||
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$CP_BASE_URL/health")
|
||||
if [ "$code" != "200" ]; then
|
||||
echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a concierge bug."
|
||||
exit 1
|
||||
fi
|
||||
echo "Staging CP healthy"
|
||||
- name: Run concierge/platform-agent staginge2e
|
||||
working-directory: workspace-server
|
||||
run: go test -tags staging_e2e ./internal/staginge2e/ -run TestConciergePlatformAgent_Staging -count=1 -v -timeout 35m
|
||||
# Teardown: the test installs a t.Cleanup admin-DELETE of its own tenant
|
||||
# (e2e-cncrg-* slug), running even on a t.Fatal. The age-guarded
|
||||
# sweep-stale-e2e-orgs workflow (30-min floor, e2e- prefix) is the final
|
||||
# net for a tenant orphaned by a hard runner cancel.
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Run gate-check-v3 (single PR mode)
|
||||
if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
|
||||
POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }}
|
||||
@@ -97,7 +97,7 @@ jobs:
|
||||
- name: Run gate-check-v3 (all open PRs — cron mode)
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
# Forward-compat scope:
|
||||
# Today (2026-05-11) molecule-core/main protects 3 contexts:
|
||||
# - "Secret scan / Scan diff for credential-shaped strings (pull_request)"
|
||||
# - "sop-checklist / tier-check (pull_request)"
|
||||
# - "sop-checklist / all-items-acked (pull_request)"
|
||||
# - "CI / all-required (pull_request)"
|
||||
# Per RFC#324 Step 2 the required-list expands to ~5 contexts
|
||||
# (qa-review, security-review added). Each new required context's
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
name: Local Provision Lifecycle E2E
|
||||
|
||||
# MANDATORY coverage for the LOCAL Docker provisioner (MOLECULE_ENV=development,
|
||||
# docker.sock) — the path self-hosters + dev runs use. Every OTHER e2e exercises
|
||||
# the SaaS/EC2 (control-plane) provisioner; nothing mandatory drove the local
|
||||
# Docker path, which is why a config-volume restart-survival bug went undetected.
|
||||
# This workflow provisions a REAL workspace via the local Docker provisioner and
|
||||
# asserts the full lifecycle, INCLUDING the restart-survival assertion.
|
||||
#
|
||||
# Two jobs:
|
||||
# * lifecycle-stub (REQUIRED gate) — builds the tiny stub runtime image, tags
|
||||
# it to the provisioner's RegistryModeLocal cache tag, and runs the full
|
||||
# lifecycle e2e (provision -> online -> restart-survive -> proxy-reach). Fast
|
||||
# (seconds of agent boot, no LLM, no 2.5GB image).
|
||||
# * lifecycle-real (ADVISORY, continue-on-error) — runs the SAME script against
|
||||
# the real claude-code template image with a REAL MiniMax BYOK credential
|
||||
# (LIFECYCLE_LLM=minimax). The proxy-reach step asserts an ACTUAL model reply
|
||||
# (real round-trip through the ws-<id>:8000 proxy), not just reachability.
|
||||
# MiniMax is the cheapest LLM the platform offers, and its `minimax` provider
|
||||
# dials api.minimax.io directly (no CP proxy needed on this local stack).
|
||||
# Heavy + network-dependent (pulls/builds the template + a real LLM call), so
|
||||
# it is non-blocking. Needs the MOLECULE_STAGING_MINIMAX_API_KEY CI secret:
|
||||
# when ABSENT the script SKIPS loud (exit 0) — it never reds on a missing
|
||||
# secret (serving-e2e skip-if-absent pattern).
|
||||
#
|
||||
# SUBSTRATE REQUIREMENT (read before wiring into branch protection)
|
||||
# -----------------------------------------------------------------
|
||||
# This workflow provisions SIBLING docker containers from a HOST Go binary via
|
||||
# the runner's docker.sock — exactly like e2e-api.yml, which already provisions
|
||||
# the `mock` + `priority-runtimes` arms on `docker-host`. So the docker-in-runner
|
||||
# capability IS available on the molecule-runner-* (docker-host) lane. If the
|
||||
# operator ever moves these to a runner WITHOUT docker.sock access for the
|
||||
# platform binary, this lane will red — keep it on `docker-host`.
|
||||
#
|
||||
# Both jobs pin `runs-on: docker-host` (Linux operator-host runners with the
|
||||
# molecule-core-net bridge + a working docker.sock). The bare `ubuntu-latest`
|
||||
# label is also advertised by the Windows act_runner, where docker.sock-bound
|
||||
# steps fail non-deterministically — see lint-required-workflows-docker-host-
|
||||
# pinned.yml + internal#512.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
|
||||
concurrency:
|
||||
# Per-SHA grouping (mirrors e2e-api.yml). cancel-in-progress:false so a queued
|
||||
# run for an older SHA isn't cancelled by a newer push (auto-promote brittleness).
|
||||
group: local-provision-e2e-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# ===========================================================================
|
||||
# REQUIRED gate — stub runtime, fast. This IS meant to be a required merge gate
|
||||
# (the only mandatory coverage for the LOCAL Docker provisioner), but the new
|
||||
# context is not yet in branch_protections/main — wire it in once the operator
|
||||
# confirms the docker-host runners reliably provision sibling containers from
|
||||
# the host platform binary for this lane (see SUBSTRATE REQUIREMENT above), then
|
||||
# flip the directive below to `# bp-required: yes`. Until then it runs gating
|
||||
# locally (continue-on-error: false) but un-wired in BP, an acknowledged
|
||||
# asymmetry tracked for follow-up. (Earlier this block read `# bp-exempt`, which
|
||||
# contradicted "REQUIRED gate" and tripped lint-required-context-exists-in-bp.)
|
||||
# bp-required: pending #2409
|
||||
# ===========================================================================
|
||||
lifecycle-stub:
|
||||
name: Local Provision Lifecycle E2E (stub)
|
||||
runs-on: docker-host
|
||||
continue-on-error: false
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
PG_CONTAINER: pg-lpe2e-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
REDIS_CONTAINER: redis-lpe2e-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
|
||||
- name: Ensure provisioner network + pre-pull alpine
|
||||
run: |
|
||||
# The local provisioner attaches workspace containers to
|
||||
# molecule-core-net and seeds /configs via an alpine helper; the
|
||||
# lifecycle script also uses alpine to seed config.yaml into the
|
||||
# named config volume. Pre-pull + ensure the bridge (idempotent).
|
||||
docker pull alpine:3 >/dev/null
|
||||
docker network create molecule-core-net >/dev/null 2>&1 || true
|
||||
echo "alpine:3 pre-pulled; molecule-core-net ensured."
|
||||
|
||||
- name: Start Postgres (docker, ephemeral host port)
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
||||
-p 0:5432 postgres:16 >/dev/null
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
[ -z "$PG_PORT" ] && PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
||||
if [ -z "$PG_PORT" ]; then echo "::error::no host port for $PG_CONTAINER"; docker logs "$PG_CONTAINER" || true; exit 1; fi
|
||||
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 30); do
|
||||
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && { echo "pg ready ${i}s"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Postgres not ready in 30s"; docker logs "$PG_CONTAINER" || true; exit 1
|
||||
|
||||
- name: Start Redis (docker, ephemeral host port)
|
||||
run: |
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
[ -z "$REDIS_PORT" ] && REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
||||
if [ -z "$REDIS_PORT" ]; then echo "::error::no host port for $REDIS_CONTAINER"; docker logs "$REDIS_CONTAINER" || true; exit 1; fi
|
||||
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 15); do
|
||||
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && { echo "redis ready ${i}s"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Redis not ready in 15s"; docker logs "$REDIS_CONTAINER" || true; exit 1
|
||||
|
||||
- name: Configure platform env (admin token + local Docker provisioner)
|
||||
run: |
|
||||
# Deterministic admin token: the script sends MOLECULE_ADMIN_TOKEN as the
|
||||
# bearer; the platform checks ADMIN_TOKEN. Set both to the same value.
|
||||
T="lpe2e-admin-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
echo "ADMIN_TOKEN=${T}" >> "$GITHUB_ENV"
|
||||
echo "MOLECULE_ADMIN_TOKEN=${T}" >> "$GITHUB_ENV"
|
||||
echo "BASE=http://localhost:8080" >> "$GITHUB_ENV"
|
||||
# MOLECULE_ENV=development: dev posture. MOLECULE_ORG_ID is left UNSET so
|
||||
# main.go wires the LOCAL Docker provisioner (not the CP provisioner), and
|
||||
# MOLECULE_IMAGE_REGISTRY is left UNSET so image resolution uses
|
||||
# RegistryModeLocal (the dockerHasTag cache-check the stub pre-tags into).
|
||||
echo "MOLECULE_ENV=development" >> "$GITHUB_ENV"
|
||||
echo "SECRETS_ENCRYPTION_KEY=lpe2e-test-encryption-key-32bytes!!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build platform
|
||||
working-directory: workspace-server
|
||||
run: go build -o platform-server ./cmd/server
|
||||
|
||||
- name: Kill stale platform-server before start (issue #1046)
|
||||
run: |
|
||||
# ROOT CAUSE of the stub-gate red on docker-host: both this gating job
|
||||
# and the advisory lifecycle-real job bind the SAME fixed host port
|
||||
# :8080 (PORT=8080 ./platform-server). On the small docker-host runner
|
||||
# pool a prior cancelled/timeout run can leave a zombie platform-server
|
||||
# on :8080 (a cancelled run never reaches "Stop platform"), and — until
|
||||
# lifecycle-real was serialised behind this job via needs: — the two
|
||||
# jobs could also co-schedule on one runner and contend for :8080. A
|
||||
# second bind on :8080 is FATAL (the server exits), so "Wait for
|
||||
# /health" times out at 300s and this REQUIRED gate reds. Free the port
|
||||
# before binding — mirrors the e2e-api.yml #1046 fix for the identical
|
||||
# fixed-port-on-shared-runner class.
|
||||
#
|
||||
# /proc scan — works on any Linux without pkill/lsof/ss. comm is
|
||||
# truncated to 15 chars: "platform-serve" matches "platform-server".
|
||||
# Verify via cmdline to avoid false positives.
|
||||
killed=0
|
||||
for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do
|
||||
kpid="${pid%/comm}"; kpid="${kpid##*/}"
|
||||
cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ')
|
||||
if echo "$cmdline" | grep -q "platform-server"; then
|
||||
echo "Killing stale platform-server pid ${kpid}: ${cmdline}"
|
||||
kill "$kpid" 2>/dev/null || true
|
||||
killed=$((killed + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$killed" -gt 0 ]; then echo "Killed $killed stale platform-server process(es)."; else echo "No platform-server-named process found."; fi
|
||||
# Belt-and-braces: also free :8080 from ANY holder regardless of process
|
||||
# name. A differently-named squatter (e.g. a leftover Fastify dev server
|
||||
# from another job) survives the comm-name scan above, makes our bind
|
||||
# FATAL, and can false-positive the /health probe below (no-flakes RCA;
|
||||
# tracked alongside #2430). fuser/lsof are present on the ubuntu runner;
|
||||
# if neither exists the name-scan above is the floor.
|
||||
if command -v fuser >/dev/null 2>&1; then fuser -k 8080/tcp 2>/dev/null || true; fi
|
||||
if command -v lsof >/dev/null 2>&1; then lsof -ti tcp:8080 2>/dev/null | xargs -r kill -9 2>/dev/null || true; fi
|
||||
sleep 2
|
||||
echo ":8080 freed (comm-scan + port-scan swept any squatter)."
|
||||
|
||||
- name: Start platform (background)
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
# Bind to :8080 (the script's BASE). DATABASE_URL/REDIS_URL/ADMIN_TOKEN/
|
||||
# MOLECULE_ENV are inherited from $GITHUB_ENV.
|
||||
PORT=8080 ./platform-server > platform.log 2>&1 &
|
||||
echo $! > platform.pid
|
||||
|
||||
- name: Wait for /health (+ migrations applied)
|
||||
run: |
|
||||
DEADLINE=300; PID="$(cat workspace-server/platform.pid 2>/dev/null || true)"; start=$(date +%s)
|
||||
while :; do
|
||||
# Verify OUR server owns :8080 BEFORE trusting /health. Our server binds
|
||||
# :8080 or exits FATAL, so "our PID alive" <=> "we own :8080"; checking it
|
||||
# first stops a squatter that answers /health on :8080 (our bind having
|
||||
# failed) from false-positiving the gate (no-flakes RCA).
|
||||
if [ -n "$PID" ] && ! kill -0 "$PID" 2>/dev/null; then
|
||||
echo "::error::platform-server exited early (failed to bind :8080 or crashed)"; cat workspace-server/platform.log || true; exit 1
|
||||
fi
|
||||
if curl -sf "$BASE/health" >/dev/null; then
|
||||
tables=$(docker exec "$PG_CONTAINER" psql -U dev -d molecule -tAc \
|
||||
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'" 2>/dev/null || echo 0)
|
||||
[ "$tables" = "1" ] && { echo "healthy + migrated after $(( $(date +%s) - start ))s"; exit 0; }
|
||||
fi
|
||||
[ "$(( $(date +%s) - start ))" -ge "$DEADLINE" ] && { echo "::error::platform not healthy in ${DEADLINE}s"; cat workspace-server/platform.log || true; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run local-provision lifecycle E2E (stub — REQUIRED)
|
||||
run: bash tests/e2e/test_local_provision_lifecycle_e2e.sh
|
||||
|
||||
- name: Dump platform log on failure
|
||||
if: failure()
|
||||
run: cat workspace-server/platform.log || true
|
||||
|
||||
- name: Stop platform
|
||||
if: always()
|
||||
run: |
|
||||
[ -f workspace-server/platform.pid ] && kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
||||
|
||||
- name: Stop service containers
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
|
||||
# ===========================================================================
|
||||
# ADVISORY — real claude-code image, lifecycle-only. Non-blocking. It pulls/
|
||||
# builds the 2.5GB template image, makes a real (cheap) MiniMax LLM call, and is
|
||||
# network-dependent, so a miss must not block. It proves the REAL runtime
|
||||
# survives a restart AND serves a genuine LLM round-trip on the local
|
||||
# provisioner (proxy-reach asserts a real MiniMax reply, not just reachability).
|
||||
# ===========================================================================
|
||||
# bp-exempt: advisory lane (continue-on-error: true) — informational, never a merge gate.
|
||||
lifecycle-real:
|
||||
name: Local Provision Lifecycle E2E (real image + MiniMax LLM, advisory)
|
||||
runs-on: docker-host
|
||||
# Serialise behind the gating stub job: both jobs bind the SAME fixed host
|
||||
# port :8080, so co-scheduling them on one docker-host runner makes the
|
||||
# second platform-server fail to bind (fatal) and reds whichever lost the
|
||||
# race. `needs:` forces this advisory job to start only AFTER lifecycle-stub
|
||||
# finishes, so they never contend for :8080. continue-on-error keeps a real-
|
||||
# job miss non-blocking; `needs:` does NOT gate on the stub's success (a
|
||||
# failed required gate still lets this advisory dependent run).
|
||||
needs: lifecycle-stub
|
||||
if: ${{ always() }}
|
||||
# Tracker for lint-continue-on-error-tracking (Tier 2e / internal#350): this
|
||||
# mask has a forced 14-day renewal cycle. mc#2408 tracks promoting this
|
||||
# advisory MiniMax round-trip to a gating job (then flip to false).
|
||||
continue-on-error: true # mc#2408 — promote advisory MiniMax e2e to gating
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
PG_CONTAINER: pg-lpe2e-real-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
REDIS_CONTAINER: redis-lpe2e-real-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
|
||||
- name: Ensure provisioner network + pre-pull alpine
|
||||
run: |
|
||||
docker pull alpine:3 >/dev/null
|
||||
docker network create molecule-core-net >/dev/null 2>&1 || true
|
||||
|
||||
- name: Start Postgres (docker, ephemeral host port)
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
||||
-p 0:5432 postgres:16 >/dev/null
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
[ -z "$PG_PORT" ] && PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
||||
if [ -z "$PG_PORT" ]; then echo "::error::no host port"; docker logs "$PG_CONTAINER" || true; exit 1; fi
|
||||
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 30); do
|
||||
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && { echo "pg ready ${i}s"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Postgres not ready"; docker logs "$PG_CONTAINER" || true; exit 1
|
||||
|
||||
- name: Start Redis (docker, ephemeral host port)
|
||||
run: |
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
[ -z "$REDIS_PORT" ] && REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
||||
if [ -z "$REDIS_PORT" ]; then echo "::error::no host port"; docker logs "$REDIS_CONTAINER" || true; exit 1; fi
|
||||
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 15); do
|
||||
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && { echo "redis ready ${i}s"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Redis not ready"; docker logs "$REDIS_CONTAINER" || true; exit 1
|
||||
|
||||
- name: Configure platform env
|
||||
run: |
|
||||
T="lpe2e-real-admin-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
echo "ADMIN_TOKEN=${T}" >> "$GITHUB_ENV"
|
||||
echo "MOLECULE_ADMIN_TOKEN=${T}" >> "$GITHUB_ENV"
|
||||
echo "BASE=http://localhost:8080" >> "$GITHUB_ENV"
|
||||
echo "MOLECULE_ENV=development" >> "$GITHUB_ENV"
|
||||
echo "SECRETS_ENCRYPTION_KEY=lpe2e-test-encryption-key-32bytes!!" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build platform
|
||||
working-directory: workspace-server
|
||||
run: go build -o platform-server ./cmd/server
|
||||
|
||||
- name: Kill stale platform-server before start (issue #1046)
|
||||
run: |
|
||||
# Same fixed-:8080 hygiene as the stub job — free the port from any
|
||||
# zombie left by a cancelled run before this job binds it.
|
||||
killed=0
|
||||
for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do
|
||||
kpid="${pid%/comm}"; kpid="${kpid##*/}"
|
||||
cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ')
|
||||
if echo "$cmdline" | grep -q "platform-server"; then
|
||||
echo "Killing stale platform-server pid ${kpid}: ${cmdline}"
|
||||
kill "$kpid" 2>/dev/null || true
|
||||
killed=$((killed + 1))
|
||||
fi
|
||||
done
|
||||
if [ "$killed" -gt 0 ]; then echo "Killed $killed stale platform-server process(es)."; else echo "No platform-server-named process found."; fi
|
||||
# Belt-and-braces: free :8080 from ANY holder regardless of process name
|
||||
# (a differently-named squatter survives the comm-name scan above, makes
|
||||
# our bind FATAL, and can false-positive the /health probe). Mirrors the
|
||||
# stub job's no-flakes fix (tracked alongside #2430).
|
||||
if command -v fuser >/dev/null 2>&1; then fuser -k 8080/tcp 2>/dev/null || true; fi
|
||||
if command -v lsof >/dev/null 2>&1; then lsof -ti tcp:8080 2>/dev/null | xargs -r kill -9 2>/dev/null || true; fi
|
||||
sleep 2
|
||||
echo ":8080 freed (comm-scan + port-scan swept any squatter)."
|
||||
|
||||
- name: Start platform (background)
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
PORT=8080 ./platform-server > platform.log 2>&1 &
|
||||
echo $! > platform.pid
|
||||
|
||||
- name: Wait for /health (+ migrations applied)
|
||||
run: |
|
||||
DEADLINE=300; PID="$(cat workspace-server/platform.pid 2>/dev/null || true)"; start=$(date +%s)
|
||||
while :; do
|
||||
# Verify OUR server owns :8080 before trusting /health (no-flakes RCA):
|
||||
# our server binds :8080 or exits FATAL, so checking our PID first stops
|
||||
# a squatter answering /health on :8080 from false-positiving the gate.
|
||||
if [ -n "$PID" ] && ! kill -0 "$PID" 2>/dev/null; then
|
||||
echo "::error::platform-server exited early (failed to bind :8080 or crashed)"; cat workspace-server/platform.log || true; exit 1
|
||||
fi
|
||||
if curl -sf "$BASE/health" >/dev/null; then
|
||||
tables=$(docker exec "$PG_CONTAINER" psql -U dev -d molecule -tAc \
|
||||
"SELECT count(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='workspaces'" 2>/dev/null || echo 0)
|
||||
[ "$tables" = "1" ] && { echo "healthy after $(( $(date +%s) - start ))s"; exit 0; }
|
||||
fi
|
||||
[ "$(( $(date +%s) - start ))" -ge "$DEADLINE" ] && { echo "::error::platform not healthy in ${DEADLINE}s"; cat workspace-server/platform.log || true; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run local-provision lifecycle E2E (real image + MiniMax LLM — ADVISORY)
|
||||
env:
|
||||
# LIFECYCLE_LLM=minimax: provision the REAL claude-code template image
|
||||
# (the mode forces LIFECYCLE_PROVISIONER_BUILDS=1 — the provisioner
|
||||
# clones + docker-builds the template from Gitea via RegistryModeLocal)
|
||||
# with a real MiniMax BYOK credential, and assert an ACTUAL model reply
|
||||
# at the proxy-reach step (a genuine round-trip through ws-<id>:8000).
|
||||
# MiniMax is the cheapest LLM the platform offers; its `minimax`
|
||||
# provider dials api.minimax.io directly, so no CP proxy env is needed.
|
||||
#
|
||||
# Key wiring (DO NOT hardcode): the script reads MINIMAX_API_KEY from
|
||||
# the env; we feed it from the MOLECULE_STAGING_MINIMAX_API_KEY CI
|
||||
# secret (the same secret the staging-smoke + e2e-api MiniMax arms use).
|
||||
# When that secret is ABSENT, MINIMAX_API_KEY is empty and the script
|
||||
# SKIPS loud (exit 0) — it never reds on a missing secret (serving-e2e
|
||||
# skip-if-absent pattern). The advisory job stays green either way.
|
||||
LIFECYCLE_LLM: minimax
|
||||
MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
run: bash tests/e2e/test_local_provision_lifecycle_e2e.sh
|
||||
|
||||
- name: Dump platform log on failure
|
||||
if: failure()
|
||||
run: cat workspace-server/platform.log || true
|
||||
|
||||
- name: Stop platform
|
||||
if: always()
|
||||
run: |
|
||||
[ -f workspace-server/platform.pid ] && kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
||||
|
||||
- name: Stop service containers
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
@@ -7,18 +7,25 @@
|
||||
#
|
||||
# A1-α (refire mechanism):
|
||||
# Triggers on:
|
||||
# - `pull_request_target`: opened, synchronize, reopened
|
||||
# → initial status posts when PR opens / re-pushes
|
||||
# - `pull_request_target`: opened, synchronize, reopened, labeled, unlabeled
|
||||
# → initial status posts when PR opens / re-pushes, and re-evaluates
|
||||
# when labels change (e.g. risk-indicator labels).
|
||||
# - `pull_request_review` types: [submitted]
|
||||
# → re-evaluate when a team member submits an APPROVE review so
|
||||
# the gate flips immediately (no wait for the next push or
|
||||
# slash-command). Verified live: sop-checklist.yml uses this
|
||||
# same event and provably fires (produces
|
||||
# `sop-checklist / all-items-acked (pull_request_review)` contexts).
|
||||
# The job-level `if:` guard checks
|
||||
# `github.event.review.state == 'APPROVED' || 'approved'` so
|
||||
# only APPROVE reviews run the evaluator; COMMENT and
|
||||
# REQUEST_CHANGES are skipped at the job level.
|
||||
# The job-level `if:` does NOT guard on review.state (issue
|
||||
# #2159): Gitea 1.22.6's payload shape for this event does not
|
||||
# reliably expose the state field that the GitHub-style guard
|
||||
# expects. The evaluator (review-check.sh) reads actual reviews
|
||||
# from the API and checks for a real APPROVE, so running on
|
||||
# COMMENT or REQUEST_CHANGES is harmless (read-only,
|
||||
# idempotent). Branch-protection requires the
|
||||
# `(pull_request_target)` context variant, so the review-event
|
||||
# path EXPLICITLY POSTS the required context via the API. Trust
|
||||
# boundary preserved (BASE ref, no PR-head).
|
||||
# Branch-protection requires the `(pull_request_target)`
|
||||
# context variant, so the review-event path EXPLICITLY POSTS
|
||||
# the required context via the API. Trust boundary preserved
|
||||
@@ -96,7 +103,7 @@ name: qa-review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
@@ -110,13 +117,19 @@ jobs:
|
||||
approved:
|
||||
# Gate the job:
|
||||
# - On pull_request_target events: always run.
|
||||
# - On pull_request_review_approved events: run so the gate flips
|
||||
# immediately when a team member submits an APPROVE review.
|
||||
# - On pull_request_review events: always run. We do NOT guard on
|
||||
# review.state here because Gitea 1.22.6's payload shape for this
|
||||
# event does not reliably expose the state field (issue #2159).
|
||||
# The evaluator (review-check.sh) reads actual reviews from the
|
||||
# API and checks for a real APPROVE, so running on COMMENT or
|
||||
# REQUEST_CHANGES is harmless (read-only, idempotent).
|
||||
# - On labeled/unlabeled events: re-evaluate when labels change.
|
||||
# This ensures qa-review flips when risk-indicator labels are
|
||||
# added or removed.
|
||||
# Comment-triggered refires live in sop-checklist.yml review-refire job.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
(github.event.review.state == 'APPROVED' || github.event.review.state == 'approved'))
|
||||
github.event_name == 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
|
||||
@@ -130,7 +143,7 @@ jobs:
|
||||
# no comment.user.login so the step is a no-op skip there.
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
login="${{ github.event.comment.user.login }}"
|
||||
@@ -162,7 +175,7 @@ jobs:
|
||||
- name: Evaluate qa-review
|
||||
id: eval
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
# PR number lives in different places per event:
|
||||
@@ -185,7 +198,7 @@ jobs:
|
||||
# TOKEN FIX (RC 8326): uses STATUS_POST_TOKEN (CTO-granted,
|
||||
# msg d52cc72a). Dedicated narrow-scoped write:repository token
|
||||
# for the explicit status POST. Evaluator step stays on
|
||||
# SOP_TIER_CHECK_TOKEN (read-only) per deliberate security
|
||||
# SOP_CHECKLIST_GATE_TOKEN (read-only) per deliberate security
|
||||
# separation: eval computes, POST writes, never the same cred.
|
||||
if: github.event_name == 'pull_request_review' && always()
|
||||
env:
|
||||
|
||||
@@ -12,18 +12,21 @@
|
||||
# Uses `pull_request_review` types: [submitted] — verified live via
|
||||
# sop-checklist.yml which provably fires this event (produces
|
||||
# `sop-checklist / all-items-acked (pull_request_review)` contexts).
|
||||
# The job-level `if:` guard checks
|
||||
# `github.event.review.state == 'APPROVED' || 'approved'` so only APPROVE
|
||||
# reviews run the evaluator; COMMENT and REQUEST_CHANGES are skipped at
|
||||
# the job level. Branch-protection requires the `(pull_request_target)`
|
||||
# context variant, so the review-event path EXPLICITLY POSTS the required
|
||||
# context via the API. Trust boundary preserved (BASE ref, no PR-head).
|
||||
# The job-level `if:` does NOT guard on review.state (issue #2159):
|
||||
# Gitea 1.22.6's payload shape for this event does not reliably expose
|
||||
# the state field that the GitHub-style guard expects. The evaluator
|
||||
# (review-check.sh) reads actual reviews from the API and checks for a
|
||||
# real APPROVE, so running on COMMENT or REQUEST_CHANGES is harmless
|
||||
# (read-only, idempotent). Branch-protection requires the
|
||||
# `(pull_request_target)` context variant, so the review-event path
|
||||
# EXPLICITLY POSTS the required context via the API. Trust boundary
|
||||
# preserved (BASE ref, no PR-head).
|
||||
|
||||
name: security-review
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
@@ -37,13 +40,19 @@ jobs:
|
||||
approved:
|
||||
# Gate the job:
|
||||
# - On pull_request_target events: always run.
|
||||
# - On pull_request_review_approved events: run so the gate flips
|
||||
# immediately when a team member submits an APPROVE review.
|
||||
# - On pull_request_review events: always run. We do NOT guard on
|
||||
# review.state here because Gitea 1.22.6's payload shape for this
|
||||
# event does not reliably expose the state field (issue #2159).
|
||||
# The evaluator (review-check.sh) reads actual reviews from the
|
||||
# API and checks for a real APPROVE, so running on COMMENT or
|
||||
# REQUEST_CHANGES is harmless (read-only, idempotent).
|
||||
# - On labeled/unlabeled events: re-evaluate when labels change.
|
||||
# This ensures security-review flips when risk-indicator labels
|
||||
# are added or removed.
|
||||
# Comment-triggered refires live in sop-checklist.yml review-refire job.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
(github.event.review.state == 'APPROVED' || github.event.review.state == 'approved'))
|
||||
github.event_name == 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
|
||||
@@ -52,7 +61,7 @@ jobs:
|
||||
# so re-running on a non-collaborator comment is harmless.
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
login="${{ github.event.comment.user.login }}"
|
||||
@@ -78,7 +87,7 @@ jobs:
|
||||
- name: Evaluate security-review
|
||||
id: eval
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
@@ -98,7 +107,7 @@ jobs:
|
||||
# TOKEN FIX (RC 8326): uses STATUS_POST_TOKEN (CTO-granted,
|
||||
# msg d52cc72a). Dedicated narrow-scoped write:repository token
|
||||
# for the explicit status POST. Evaluator step stays on
|
||||
# SOP_TIER_CHECK_TOKEN (read-only) per deliberate security
|
||||
# SOP_CHECKLIST_GATE_TOKEN (read-only) per deliberate security
|
||||
# separation: eval computes, POST writes, never the same cred.
|
||||
if: github.event_name == 'pull_request_review' && always()
|
||||
env:
|
||||
|
||||
@@ -167,7 +167,7 @@ jobs:
|
||||
if: steps.classify.outputs.run_qa == 'true'
|
||||
env:
|
||||
# Evaluator (review-check.sh + GET /pulls) stays on read-scoped token.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
# Explicit POST /statuses uses narrow-scoped write:repository token.
|
||||
STATUS_POST_TOKEN: ${{ secrets.STATUS_POST_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
if: steps.classify.outputs.run_security == 'true'
|
||||
env:
|
||||
# Evaluator (review-check.sh + GET /pulls) stays on read-scoped token.
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
# Explicit POST /statuses uses narrow-scoped write:repository token.
|
||||
STATUS_POST_TOKEN: ${{ secrets.STATUS_POST_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# use this Makefile; CI calls docker compose / go test directly so the
|
||||
# Makefile can evolve without breaking the build.
|
||||
|
||||
.PHONY: help dev up down logs build test e2e-peer-visibility openapi-spec openapi-spec-check gen gen-docker gen-check gen-check-docker
|
||||
.PHONY: help dev up down logs build test e2e-peer-visibility e2e-concierge-creates-workspace openapi-spec openapi-spec-check gen gen-docker gen-check gen-check-docker
|
||||
|
||||
# ─── Provider-registry SSOT codegen (internal#718) ─────────────────────
|
||||
# The Go module lives in workspace-server/. The checked-in artifact
|
||||
@@ -57,6 +57,16 @@ test: ## Run Go unit tests in workspace-server/.
|
||||
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
|
||||
bash tests/e2e/test_peer_visibility_mcp_local.sh
|
||||
|
||||
# FUNCTIONAL local proof that the org concierge actually DOES org-management:
|
||||
# send it a natural-language A2A request and assert it really CREATES a workspace
|
||||
# via its platform MCP (create_workspace) — the deterministic side effect, not a
|
||||
# REST 200. SKIPs LOUD (exit 0) unless the local concierge is seeded, online, and
|
||||
# running on the platform-agent image (so create_workspace exists). To run it
|
||||
# green locally: seed the concierge (MOLECULE_SEED_PLATFORM_AGENT=1) on the
|
||||
# platform-agent image WITH a model key. See the script header for the contract.
|
||||
e2e-concierge-creates-workspace: ## Prove the concierge actually creates a workspace via its platform MCP (skips loud if not runnable).
|
||||
bash tests/e2e/test_concierge_creates_workspace_local.sh
|
||||
|
||||
# ─── OpenAPI spec generation (RFC #1706, Phase 1) ─────────────────────
|
||||
# Regenerate workspace-server/docs/openapi/swagger.{yaml,json} from
|
||||
# swaggo annotations on the gin handlers. Commit the output. CI runs
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
/**
|
||||
* Staging concierge canvas E2E — exercises the platform-agent CONCIERGE shell
|
||||
* (canvas/src/components/concierge/ConciergeShell.tsx and the Settings split)
|
||||
* against a fresh staging org provisioned by the shared global setup
|
||||
* (e2e/staging-setup.ts). Each `test.describe` covers ONE concierge function
|
||||
* and asserts the behaviour works — not merely that an element exists.
|
||||
*
|
||||
* Why this is a SEPARATE spec from staging-tabs.spec.ts (which drives the
|
||||
* Org-map SidePanel tab UI): the two assert different surfaces of the same
|
||||
* tenant. Both reuse the EXACT shared harness — same global setup (one
|
||||
* provisioned org/workspace), same Playwright staging config (matched by the
|
||||
* `staging-*.spec.ts` testMatch), same gated `Canvas tabs E2E` workflow check.
|
||||
* No new harness, no new seeding mechanism.
|
||||
*
|
||||
* One extra precondition this spec needs that staging-tabs does NOT: a
|
||||
* kind='platform' concierge ROW. The CI/SaaS tenant does not self-seed one
|
||||
* (MOLECULE_SEED_PLATFORM_AGENT is unset on CI — workspace-server
|
||||
* cmd/server/main.go), so without it the concierge shell falls back to
|
||||
* roots[0] as a *pseudo*-platform surface and the platform-specific
|
||||
* behaviours (root tag, hidden-from-map) can't be asserted. So this spec
|
||||
* installs one via the SAME admin endpoint the control plane uses at
|
||||
* org-provision time — POST /admin/org/platform-agent (AdminAuth, accepts the
|
||||
* per-tenant admin bearer that global setup already exports). Installing it
|
||||
* re-parents the provisioned hermes workspace UNDER the platform agent
|
||||
* (handlers/platform_agent.go installPlatformAgent), giving us a real
|
||||
* platform ROOT + a real child workspace — exactly the topology the concierge
|
||||
* Home tree and Org-map filter are built to handle.
|
||||
*
|
||||
* This install mutates the shared tenant (re-parents the workspace). It is the
|
||||
* LAST staging spec alphabetically among the topology-touching ones, and
|
||||
* staging-tabs / staging-display read the workspace by id (not by root-ness),
|
||||
* so the re-parent does not break them; Playwright runs workers=1 in file
|
||||
* order, and the install is idempotent.
|
||||
*
|
||||
* Auth model is identical to staging-tabs.spec.ts: feed the per-tenant admin
|
||||
* token as an Authorization: Bearer header on every browser request, mock
|
||||
* /cp/auth/me so AuthGate resolves, and fall any non-auth 401 back to an
|
||||
* empty 200 so a workspace-scoped 401 can't yank us to AuthKit.
|
||||
*/
|
||||
|
||||
import { test, expect, type Page, type BrowserContext } from "@playwright/test";
|
||||
|
||||
const STAGING = process.env.CANVAS_E2E_STAGING === "1";
|
||||
|
||||
// Fail-closed, not skip-green (mirrors staging-tabs.spec.ts): a staging run
|
||||
// that was REQUESTED (CANVAS_E2E_STAGING=1) but has no tenant state is a
|
||||
// provisioning failure, asserted loudly inside the test body — not a skip.
|
||||
// CANVAS_E2E_STAGING unset = operator did not request staging = clean skip.
|
||||
test.skip(!STAGING, "CANVAS_E2E_STAGING not set — staging-only suite, not requested");
|
||||
|
||||
/** Resolve + validate the tenant handoff that global setup exported. */
|
||||
function tenantEnv() {
|
||||
const tenantURL = process.env.STAGING_TENANT_URL;
|
||||
const tenantToken = process.env.STAGING_TENANT_TOKEN;
|
||||
const workspaceId = process.env.STAGING_WORKSPACE_ID;
|
||||
const orgID = process.env.STAGING_ORG_ID;
|
||||
if (!tenantURL || !tenantToken || !workspaceId) {
|
||||
throw new Error(
|
||||
"staging-setup.ts did not export STAGING_TENANT_URL / " +
|
||||
"STAGING_TENANT_TOKEN / STAGING_WORKSPACE_ID. CANVAS_E2E_STAGING=1 was " +
|
||||
"set (staging WAS requested) but global setup produced no tenant — a " +
|
||||
"provisioning failure, NOT a reason to skip. See the [staging-setup] " +
|
||||
"log above.",
|
||||
);
|
||||
}
|
||||
return { tenantURL, tenantToken, workspaceId, orgID };
|
||||
}
|
||||
|
||||
// A fixed, valid uuid for the installed platform agent. Any valid uuid works
|
||||
// (the install upserts on this id); reusing one constant keeps re-runs
|
||||
// idempotent on the same row. Chosen out of the e2e namespace so it can't
|
||||
// collide with a CP-derived org id.
|
||||
const PLATFORM_AGENT_ID = "e2e0c1e2-0000-4000-a000-000000c0ce0e";
|
||||
const PLATFORM_AGENT_NAME = "E2E Concierge";
|
||||
|
||||
/**
|
||||
* Idempotently install the platform-agent (concierge) row on the shared
|
||||
* tenant so the concierge shell resolves a REAL kind='platform' root. Uses
|
||||
* the per-tenant admin bearer + org-id headers, same as staging-display.spec.
|
||||
* Tolerant of a pre-existing install (the endpoint is idempotent) and of a
|
||||
* backend that predates the endpoint (404/405) — in that degraded case the
|
||||
* spec proceeds against the roots[0] fallback and the two platform-specific
|
||||
* assertions self-document why they're loosened.
|
||||
*/
|
||||
async function installPlatformAgent(
|
||||
page: Page,
|
||||
tenantURL: string,
|
||||
tenantToken: string,
|
||||
orgID: string | undefined,
|
||||
): Promise<{ installed: boolean }> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${tenantToken}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (orgID) headers["X-Molecule-Org-Id"] = orgID;
|
||||
const resp = await page.request.post(`${tenantURL}/admin/org/platform-agent`, {
|
||||
headers,
|
||||
data: { id: PLATFORM_AGENT_ID, name: PLATFORM_AGENT_NAME },
|
||||
});
|
||||
const status = resp.status();
|
||||
if (status >= 200 && status < 300) {
|
||||
console.log(`[staging-concierge] platform agent installed (HTTP ${status})`);
|
||||
return { installed: true };
|
||||
}
|
||||
// Endpoint absent on an older backend — proceed against the fallback root.
|
||||
if (status === 404 || status === 405) {
|
||||
console.warn(
|
||||
`[staging-concierge] POST /admin/org/platform-agent returned ${status} — ` +
|
||||
`backend predates the platform-agent endpoint. Proceeding against the ` +
|
||||
`roots[0] concierge fallback; the platform-root / map-hidden assertions ` +
|
||||
`are loosened accordingly.`,
|
||||
);
|
||||
return { installed: false };
|
||||
}
|
||||
throw new Error(
|
||||
`POST /admin/org/platform-agent ${status}: ${await resp.text().catch(() => "")}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire the per-tenant bearer + the /cp/auth/me mock + the 401→empty-200
|
||||
* fallback. Verbatim contract from staging-tabs.spec.ts so the concierge spec
|
||||
* authenticates identically (no WorkOS session available to Playwright).
|
||||
*/
|
||||
async function authenticate(
|
||||
context: BrowserContext,
|
||||
tenantToken: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await context.setExtraHTTPHeaders({ Authorization: `Bearer ${tenantToken}` });
|
||||
|
||||
await context.route("**/cp/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id: `e2e-test-user-${workspaceId}`,
|
||||
org_id: "e2e-test-org",
|
||||
email: "e2e@test.local",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await context.route("**", async (route, request) => {
|
||||
if (request.resourceType() !== "fetch") return route.fallback();
|
||||
if (request.url().includes("/cp/auth/me")) return route.fallback();
|
||||
let resp;
|
||||
try {
|
||||
resp = await route.fetch();
|
||||
} catch {
|
||||
return route.fallback();
|
||||
}
|
||||
if (resp.status() !== 401) return route.fulfill({ response: resp });
|
||||
const lastSeg =
|
||||
new URL(request.url()).pathname.split("/").filter(Boolean).pop() || "";
|
||||
const looksLikeList = !/^[0-9a-f-]{8,}$/.test(lastSeg);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: looksLikeList ? "[]" : "{}",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the concierge shell and wait for hydration. Returns once the icon rail
|
||||
* (the concierge's left nav) is visible — the rail is the shell's outermost
|
||||
* stable landmark and only renders after the canvas store has hydrated.
|
||||
*/
|
||||
async function loadConcierge(page: Page, tenantURL: string): Promise<void> {
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") console.log(`[e2e/console-error] ${msg.text()}`);
|
||||
});
|
||||
await page.goto(tenantURL, { waitUntil: "domcontentloaded" });
|
||||
|
||||
// The canvas store hydrates /workspaces before the desktop shell paints.
|
||||
// Wait for the concierge nav rail OR the hydration-error banner — whichever
|
||||
// wins. Don't wait on networkidle: the shell keeps a WS + polling open.
|
||||
await page.waitForSelector(
|
||||
'[data-testid="nav-home"], [data-testid="hydration-error"]',
|
||||
{ timeout: 45_000 },
|
||||
);
|
||||
const hydrationErr = await page
|
||||
.locator('[data-testid="hydration-error"]')
|
||||
.count();
|
||||
expect(
|
||||
hydrationErr,
|
||||
"canvas hydration failed — check staging CP + tenant reachability",
|
||||
).toBe(0);
|
||||
await expect(
|
||||
page.getByText("Something went wrong", { exact: false }),
|
||||
"app-level ErrorBoundary tripped during concierge hydration",
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Switch the concierge top-level view via the left rail. */
|
||||
async function navTo(page: Page, view: "home" | "map" | "settings"): Promise<void> {
|
||||
const btn = page.getByTestId(`nav-${view}`);
|
||||
await expect(btn, `rail button nav-${view} missing`).toBeVisible({ timeout: 10_000 });
|
||||
await btn.click();
|
||||
}
|
||||
|
||||
// ── shared per-spec setup ──────────────────────────────────────────────────
|
||||
// Each test gets a freshly-authenticated context + an installed platform
|
||||
// agent. Install lives in beforeEach (idempotent) so any single test can run
|
||||
// in isolation (`--grep`), not only in whole-file order.
|
||||
let platformInstalled = false;
|
||||
|
||||
test.beforeEach(async ({ page, context }) => {
|
||||
const { tenantURL, tenantToken, workspaceId, orgID } = tenantEnv();
|
||||
await authenticate(context, tenantToken, workspaceId);
|
||||
const { installed } = await installPlatformAgent(page, tenantURL, tenantToken, orgID);
|
||||
platformInstalled = installed;
|
||||
});
|
||||
|
||||
/* ───────────────────────── 1. Concierge shell / nav ──────────────────────── */
|
||||
test.describe("concierge shell + nav", () => {
|
||||
test("left rail switches Home / Org map / Settings; topbar shows the org name", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tenantURL } = tenantEnv();
|
||||
await loadConcierge(page, tenantURL);
|
||||
|
||||
// All three rail destinations are present.
|
||||
for (const v of ["home", "map", "settings"] as const) {
|
||||
await expect(page.getByTestId(`nav-${v}`)).toBeVisible();
|
||||
}
|
||||
|
||||
// Topbar org name is dynamic from GET /org/identity. The endpoint returns
|
||||
// MOLECULE_ORG_NAME (may be "" on a staging tenant), in which case the
|
||||
// shell falls back to "Molecule AI". Either way it must render a
|
||||
// non-empty name — assert the element resolves to real text.
|
||||
const orgName = page.getByTestId("topbar-org-name");
|
||||
await expect(orgName).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => ((await orgName.innerText()) || "").trim().length, {
|
||||
message: "topbar org name never resolved to non-empty text",
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// Nav actually switches the active view. Home → Settings → Map → Home,
|
||||
// asserting the destination rail button reflects active state each hop
|
||||
// (the shell toggles the active class; we assert the view content too).
|
||||
await navTo(page, "settings");
|
||||
await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await navTo(page, "map");
|
||||
await expect(page.locator('[aria-label="Agent canvas"]')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await navTo(page, "home");
|
||||
// Home shows the agents/tasks/approvals sub-tab bar.
|
||||
await expect(page.getByTestId("home-subtab-agents")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ─────────────────────────────── 2. Home ─────────────────────────────────── */
|
||||
test.describe("concierge Home", () => {
|
||||
test("renders the canonical ChatTab, Agents/Tasks/Approvals sub-tabs, and the platform agent as ROOT", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tenantURL } = tenantEnv();
|
||||
await loadConcierge(page, tenantURL);
|
||||
await navTo(page, "home");
|
||||
|
||||
// (a) The Home chat panel reuses the EXACT canonical ChatTab — so it must
|
||||
// expose the My Chat / Agent Comms sub-tabs, a message input, and the
|
||||
// attachment affordance, exactly like the map SidePanel chat. The
|
||||
// [data-testid="chat-panel"] root is ChatTab's own marker (canvas/src/
|
||||
// components/tabs/ChatTab.tsx) — asserting it proves the canonical
|
||||
// component is mounted, not a bespoke concierge re-implementation.
|
||||
const chatPanel = page.getByTestId("chat-panel");
|
||||
await expect(chatPanel, "Home did not mount the canonical ChatTab").toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(chatPanel.locator("#chat-tab-my-chat")).toHaveText(/My Chat/);
|
||||
await expect(chatPanel.locator("#chat-tab-agent-comms")).toHaveText(/Agent Comms/);
|
||||
// Switching the chat sub-tab works (My Chat active by default → Agent Comms).
|
||||
await chatPanel.locator("#chat-tab-agent-comms").click();
|
||||
await expect(chatPanel.locator("#chat-tab-agent-comms")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await chatPanel.locator("#chat-tab-my-chat").click();
|
||||
await expect(chatPanel.locator("#chat-tab-my-chat")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
// Message input + attachment affordance (My Chat panel). The attach
|
||||
// control is the labelled button (the underlying <input type=file> is
|
||||
// aria-hidden); both are always present (disabled when the agent is
|
||||
// unreachable), so assert presence, not enabled-state.
|
||||
await expect(
|
||||
chatPanel.locator('textarea[aria-label="Message to agent"]'),
|
||||
"ChatTab message input missing",
|
||||
).toHaveCount(1);
|
||||
await expect(
|
||||
chatPanel.locator('button[aria-label="Attach file"]'),
|
||||
"ChatTab attachment affordance missing",
|
||||
).toHaveCount(1);
|
||||
|
||||
// (b) Agents / Tasks / Approvals sub-tabs switch the Home sidebar pane.
|
||||
await page.getByTestId("home-subtab-tasks").click();
|
||||
await expect(page.getByTestId("home-subtab-tasks")).toHaveClass(/active/);
|
||||
await page.getByTestId("home-subtab-approvals").click();
|
||||
await expect(page.getByTestId("home-subtab-approvals")).toHaveClass(/active/);
|
||||
await page.getByTestId("home-subtab-agents").click();
|
||||
await expect(page.getByTestId("home-subtab-agents")).toHaveClass(/active/);
|
||||
|
||||
// (c) The agent tree shows the platform agent as ROOT. After install the
|
||||
// platform agent is a kind='platform' root carrying the "root" tag, with
|
||||
// the provisioned workspace re-parented under it (depth>0). When the
|
||||
// backend predates the install endpoint, roots[0] is the pseudo-root and
|
||||
// the "root" tag is absent (it only renders for a real kind='platform'
|
||||
// root) — so we gate the strong assertion on a successful install.
|
||||
const tree = page.getByTestId("agent-tree-node");
|
||||
await expect(tree.first(), "agent tree rendered no nodes").toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
if (platformInstalled) {
|
||||
// The depth-0 node is the platform agent and it carries the root tag.
|
||||
const rootNode = page
|
||||
.locator('[data-testid="agent-tree-node"][data-depth="0"]')
|
||||
.first();
|
||||
await expect(rootNode).toHaveAttribute("data-platform", "true");
|
||||
await expect(
|
||||
rootNode.locator('[data-testid="agent-tree-root-tag"]'),
|
||||
"platform root is missing the ROOT tag",
|
||||
).toBeVisible();
|
||||
// And the provisioned workspace is nested beneath it (a child node exists).
|
||||
await expect(
|
||||
page.locator('[data-testid="agent-tree-node"][data-depth="1"]'),
|
||||
"the provisioned workspace did not re-parent under the platform root",
|
||||
).toHaveCount(1, { timeout: 10_000 });
|
||||
} else {
|
||||
// Degraded backend: at least the tree renders a root-level node.
|
||||
await expect(
|
||||
page.locator('[data-testid="agent-tree-node"][data-depth="0"]'),
|
||||
).not.toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ─────────────────────────────── 3. Org map ──────────────────────────────── */
|
||||
test.describe("concierge Org map", () => {
|
||||
test("hides the platform agent from the node graph; normal workspaces render", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tenantURL } = tenantEnv();
|
||||
await loadConcierge(page, tenantURL);
|
||||
await navTo(page, "map");
|
||||
|
||||
// The React Flow canvas renders.
|
||||
await expect(page.locator('[aria-label="Molecule AI workspace canvas"]')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Normal workspaces render as map node cards (WorkspaceNode →
|
||||
// data-testid="workspace-node"). The provisioned hermes workspace must
|
||||
// appear. expect.poll lets React Flow finish its layout pass.
|
||||
await expect
|
||||
.poll(async () => page.locator('[data-testid="workspace-node"]').count(), {
|
||||
message: "no workspace nodes rendered on the org map",
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
// The concierge (platform agent) is HIDDEN from the graph: no map node
|
||||
// carries its name. WorkspaceNode's aria-label is "<name> workspace —
|
||||
// <status>" — assert none matches the platform agent name. This is the
|
||||
// real behaviour stripPlatformRootForMap implements (Canvas.tsx /
|
||||
// canvas-topology.ts). Only meaningful when we actually installed one.
|
||||
if (platformInstalled) {
|
||||
const platformNode = page.locator(
|
||||
`[data-testid="workspace-node"][aria-label^="${PLATFORM_AGENT_NAME} workspace"]`,
|
||||
);
|
||||
await expect(
|
||||
platformNode,
|
||||
"the platform agent (concierge) leaked into the org-map node graph — " +
|
||||
"stripPlatformRootForMap should exclude it",
|
||||
).toHaveCount(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ─────────────────────── 4. Settings — two tabs ──────────────────────────── */
|
||||
test.describe("concierge Settings — two tabs", () => {
|
||||
test("Platform-agent config and Org & canvas settings are separate panes; platform tab shows the full WorkspacePanelTabs defaulting to Config", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tenantURL } = tenantEnv();
|
||||
await loadConcierge(page, tenantURL);
|
||||
await navTo(page, "settings");
|
||||
|
||||
const platformTab = page.getByTestId("settings-tab-platform");
|
||||
const orgTab = page.getByTestId("settings-tab-org");
|
||||
await expect(platformTab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(orgTab).toBeVisible();
|
||||
|
||||
// Platform tab is the default; its pane is shown and the org pane is not.
|
||||
await expect(platformTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("settings-pane-platform")).toBeVisible();
|
||||
await expect(page.getByTestId("settings-pane-org")).toHaveCount(0);
|
||||
|
||||
// The platform pane embeds the FULL WorkspacePanelTabs (the SAME tablist
|
||||
// the map SidePanel renders) and defaults to the Config tab. Assert the
|
||||
// canonical workspace tablist is present, that Config is the active tab,
|
||||
// and that the other signature tabs exist (Plugins, Container, Display,
|
||||
// Details, Activity, Terminal, Channels, Schedule).
|
||||
const wsTablist = page.getByRole("tablist", { name: "Workspace panel tabs" });
|
||||
await expect(
|
||||
wsTablist,
|
||||
"platform-agent Settings tab did not embed WorkspacePanelTabs",
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.locator("#tab-config")).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
for (const id of [
|
||||
"config",
|
||||
"skills",
|
||||
"container-config",
|
||||
"display",
|
||||
"details",
|
||||
"activity",
|
||||
"terminal",
|
||||
"channels",
|
||||
"schedule",
|
||||
]) {
|
||||
await expect(
|
||||
page.locator(`#tab-${id}`),
|
||||
`WorkspacePanelTabs is missing #tab-${id}`,
|
||||
).toHaveCount(1);
|
||||
}
|
||||
|
||||
// Clicking the OTHER settings tab switches panes (not just toggles a
|
||||
// class): the org pane mounts and the platform pane unmounts.
|
||||
await orgTab.click();
|
||||
await expect(orgTab).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByTestId("settings-pane-org")).toBeVisible();
|
||||
await expect(page.getByTestId("settings-pane-platform")).toHaveCount(0);
|
||||
|
||||
// And back.
|
||||
await platformTab.click();
|
||||
await expect(page.getByTestId("settings-pane-platform")).toBeVisible();
|
||||
await expect(page.getByTestId("settings-pane-org")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* ─────────────────────── 5. Settings — Config tab ────────────────────────── */
|
||||
test.describe("concierge Settings — Config tab dropdowns", () => {
|
||||
test("runtime dropdown is SSOT-driven; provider hides Platform on self-host but lists BYOK; model follows provider", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tenantURL } = tenantEnv();
|
||||
await loadConcierge(page, tenantURL);
|
||||
await navTo(page, "settings");
|
||||
|
||||
// Platform tab defaults to the Config tab — the runtime select is in the
|
||||
// ConfigTab "Runtime" section (label "Runtime"). Wait for it to settle.
|
||||
await expect(
|
||||
page.getByRole("tablist", { name: "Workspace panel tabs" }),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
// The runtime <select> sits under the "Runtime" label inside the Config
|
||||
// panel. Use the label association for a stable hook.
|
||||
const runtimeByLabel = page.locator('#panel-config').getByLabel("Runtime", {
|
||||
exact: true,
|
||||
});
|
||||
await expect(
|
||||
runtimeByLabel,
|
||||
"ConfigTab runtime dropdown never rendered",
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// (a) Runtime dropdown is SSOT-driven: the options come from GET
|
||||
// /templates (loadRuntimesFromManifest), so the live tenant must serve a
|
||||
// non-trivial set. Assert >= 1 runtime option AND that the provisioned
|
||||
// workspace's runtime (hermes) is among them — proving the list reflects
|
||||
// what /templates actually serves, not a stale hard-coded allowlist.
|
||||
const runtimeOptionValues = await runtimeByLabel
|
||||
.locator("option")
|
||||
.evaluateAll((els) => els.map((e) => (e as HTMLOptionElement).value));
|
||||
expect(
|
||||
runtimeOptionValues.length,
|
||||
"runtime dropdown rendered no options — SSOT /templates feed is empty",
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
runtimeOptionValues,
|
||||
"runtime dropdown does not list the provisioned 'hermes' runtime — the " +
|
||||
"SSOT /templates list has drifted",
|
||||
).toContain("hermes");
|
||||
|
||||
// (b) Provider dropdown: on self-host (no platform proxy) it must NOT
|
||||
// offer the "Platform" billing option but MUST list BYOK providers. The
|
||||
// ProviderModelSelector exposes data-testid="provider-select". Read its
|
||||
// option labels: none should be the "Platform" proxy entry, and the list
|
||||
// must be non-empty (BYOK providers present). /org/identity's
|
||||
// platform_managed_available=false on a staging tenant drives this.
|
||||
const providerSelect = page.getByTestId("provider-select");
|
||||
await expect(
|
||||
providerSelect,
|
||||
"ConfigTab provider dropdown (ProviderModelSelector) never rendered",
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
const providerLabels = await providerSelect
|
||||
.locator("option")
|
||||
.evaluateAll((els) =>
|
||||
els
|
||||
.map((e) => (e.textContent || "").trim())
|
||||
.filter((t) => t && !t.startsWith("—")),
|
||||
);
|
||||
expect(
|
||||
providerLabels.length,
|
||||
"provider dropdown lists no BYOK providers",
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
providerLabels.map((l) => l.toLowerCase()),
|
||||
'provider dropdown offered the "Platform" proxy option on a self-host / ' +
|
||||
"no-proxy tenant (platform_managed_available should hide it)",
|
||||
).not.toContain("platform");
|
||||
|
||||
// (c) Model dropdown follows the provider. The model control is
|
||||
// data-testid="model-select" (dropdown) or model-input (free-text
|
||||
// wildcard). Whichever renders, it must be present — proving the model
|
||||
// control is wired to the provider selection.
|
||||
const modelControl = page
|
||||
.locator('[data-testid="model-select"], [data-testid="model-input"]')
|
||||
.first();
|
||||
await expect(
|
||||
modelControl,
|
||||
"model control did not follow the provider selection",
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
/* ────────────────── 6. Settings — Org & canvas settings ──────────────────── */
|
||||
test.describe("concierge Settings — Org & canvas", () => {
|
||||
test("Secrets / Workspace Tokens / Org API Keys / Organization sub-tabs render; Organization shows the org (no 404)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tenantURL } = tenantEnv();
|
||||
await loadConcierge(page, tenantURL);
|
||||
await navTo(page, "settings");
|
||||
|
||||
await page.getByTestId("settings-tab-org").click();
|
||||
const orgPane = page.getByTestId("settings-pane-org");
|
||||
await expect(orgPane).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The four SettingsTabs (canvas/src/components/settings/SettingsTabs.tsx)
|
||||
// render as a radix tablist labelled "Settings sections". Assert all four
|
||||
// triggers are present.
|
||||
const settingsTablist = orgPane.getByRole("tablist", {
|
||||
name: "Settings sections",
|
||||
});
|
||||
await expect(settingsTablist).toBeVisible({ timeout: 10_000 });
|
||||
for (const label of [
|
||||
"Secrets",
|
||||
"Workspace Tokens",
|
||||
"Org API Keys",
|
||||
"Organization",
|
||||
]) {
|
||||
await expect(
|
||||
settingsTablist.getByRole("tab", { name: label }),
|
||||
`Org & canvas settings is missing the "${label}" sub-tab`,
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
// Click the Organization sub-tab — on self-host the canvas reads
|
||||
// /org/identity (NOT the CP /cp/orgs endpoint), so it must render the org
|
||||
// identity card and NOT a 404 / error state. Assert the pane settles to
|
||||
// real, non-error content.
|
||||
await settingsTablist.getByRole("tab", { name: "Organization" }).click();
|
||||
const orgInfoPanel = orgPane.locator(
|
||||
'[role="tabpanel"]:not([hidden])',
|
||||
);
|
||||
await expect(orgInfoPanel).toBeVisible({ timeout: 10_000 });
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const text = ((await orgInfoPanel.innerText()) || "").trim();
|
||||
return text.length > 0 && !/404|not found/i.test(text);
|
||||
},
|
||||
{
|
||||
message:
|
||||
"Organization sub-tab rendered empty or a 404/not-found — the " +
|
||||
"self-host /org/identity path is broken",
|
||||
timeout: 15_000,
|
||||
},
|
||||
)
|
||||
.toBe(true);
|
||||
// And no visible error alert inside the org settings pane.
|
||||
await expect(orgPane.locator('[role="alert"]:visible')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
/* ───────────────────────────── 7. Map toolbar ────────────────────────────── */
|
||||
test.describe("concierge Org map toolbar", () => {
|
||||
test("settings gear, theme toggle and legend are NOT on the map toolbar (moved to Settings/topbar)", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { tenantURL } = tenantEnv();
|
||||
await loadConcierge(page, tenantURL);
|
||||
await navTo(page, "map");
|
||||
await expect(page.locator('[aria-label="Molecule AI workspace canvas"]')).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// The map toolbar no longer carries a settings gear, a theme toggle, or a
|
||||
// legend — those moved to the concierge Settings (left rail) + topbar
|
||||
// (Toolbar.tsx: "Theme picker + settings gear removed from the map
|
||||
// toolbar"). Assert the map view contains none of them.
|
||||
//
|
||||
// Scope to the map mount (<main aria-label="Agent canvas">, ConciergeShell)
|
||||
// so the legitimate left-rail Settings button + the topbar theme toggle
|
||||
// (which live OUTSIDE the map) are not counted.
|
||||
const mapRegion = page.locator('[aria-label="Agent canvas"]');
|
||||
await expect(mapRegion).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// No settings-gear control inside the map. The old gear used
|
||||
// title="Settings" / aria-label "Settings".
|
||||
await expect(
|
||||
mapRegion.locator('button[title="Settings"], button[aria-label="Settings"]'),
|
||||
"a settings gear is still on the map toolbar (should be moved to Settings)",
|
||||
).toHaveCount(0);
|
||||
|
||||
// No theme toggle inside the map. The toggle's accessible name is
|
||||
// "Toggle theme" — it now lives only in the topbar.
|
||||
await expect(
|
||||
mapRegion.locator('button[title="Toggle theme"], button[aria-label*="theme" i]'),
|
||||
"a theme toggle is still on the map toolbar (should be in the topbar)",
|
||||
).toHaveCount(0);
|
||||
|
||||
// No legend inside the map. The Legend component's controls have accessible
|
||||
// names "Show legend" / "Hide legend" and the panel carries
|
||||
// data-testid="legend-panel" (canvas/src/components/Legend.tsx). It is no
|
||||
// longer mounted in Canvas/Toolbar at all — assert none of its surfaces.
|
||||
await expect(
|
||||
mapRegion.locator(
|
||||
'[data-testid="legend-panel"], button[aria-label="Show legend"], button[aria-label="Hide legend"]',
|
||||
),
|
||||
"a legend is still on the map toolbar (should be removed)",
|
||||
).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -341,11 +341,15 @@ export default async function globalSetup(_config: FullConfig): Promise<void> {
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// Real boot regression — hard-throw immediately with full detail.
|
||||
// #2032: tolerate transient 'failed' during boot — some runtimes
|
||||
// briefly report failed before recovering to online (e.g. agent
|
||||
// restart during init). Retry instead of hard-throwing; genuine
|
||||
// terminal failures will still surface via waitFor timeout.
|
||||
const detail = sampleErr
|
||||
? sampleErr
|
||||
: `(no last_sample_error) full body: ${JSON.stringify(r.body)}`;
|
||||
throw new Error(`Workspace failed: ${detail}`);
|
||||
console.warn(`[staging-setup] transient failed (retrying): ${detail}`);
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -52,8 +52,10 @@ describe("prefers-reduced-motion compliance", () => {
|
||||
expect(src).toContain("motion-safe:animate-pulse");
|
||||
});
|
||||
|
||||
it("SidePanel.tsx uses motion-safe:animate-pulse", () => {
|
||||
const src = readSrc("components/SidePanel.tsx");
|
||||
it("WorkspacePanelTabs.tsx uses motion-safe:animate-pulse", () => {
|
||||
// The connection-status dot moved out of SidePanel.tsx into the extracted
|
||||
// WorkspacePanelTabs.tsx; verify the reduced-motion guard followed it.
|
||||
const src = readSrc("components/WorkspacePanelTabs.tsx");
|
||||
expect(src.includes("animate-pulse") && !src.includes("motion-safe:animate-pulse")).toBe(false);
|
||||
expect(src).toContain("motion-safe:animate-pulse");
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||
// transform). We import layout.tsx only for its exported `metadata`
|
||||
// constant — mock the font module to a constructor-returning stub.
|
||||
vi.mock("next/font/google", () => ({
|
||||
Inter: () => ({ variable: "--font-inter" }),
|
||||
Hanken_Grotesk: () => ({ variable: "--font-hanken" }),
|
||||
JetBrains_Mono: () => ({ variable: "--font-jetbrains" }),
|
||||
}));
|
||||
|
||||
|
||||
+50
-38
@@ -42,48 +42,52 @@
|
||||
* before paint to eliminate flash.
|
||||
*/
|
||||
@theme {
|
||||
/* Org Concierge palette (RFC platform-agent / canvas redesign). Warm-paper
|
||||
light theme + purple accent replacing the old blue brand. */
|
||||
/* Surface — page / elevated card / sunken input / deep card */
|
||||
--color-surface: #fafaf7;
|
||||
--color-surface: #f1efe8;
|
||||
--color-surface-elevated: #ffffff;
|
||||
--color-surface-sunken: #f3f1ec;
|
||||
--color-surface-card: #efece4;
|
||||
--color-surface-sunken: #f6f4ee;
|
||||
--color-surface-card: #faf9f4;
|
||||
|
||||
/* Borders */
|
||||
--color-line: #e6e2d8;
|
||||
--color-line-soft: #efece4;
|
||||
--color-line: #ddd9cf;
|
||||
--color-line-soft: #ebe8df;
|
||||
|
||||
/* Text */
|
||||
--color-ink: #15181c;
|
||||
--color-ink-mid: #5a5e66;
|
||||
--color-ink-soft: #8b8e95;
|
||||
--color-ink: #21201b;
|
||||
--color-ink-mid: #5c5a52;
|
||||
--color-ink-soft: #6f6c62;
|
||||
|
||||
/* Brand + state */
|
||||
--color-accent: #3b5bdb;
|
||||
--color-accent-strong: #1a2f99;
|
||||
--color-warm: #c0532b;
|
||||
--color-good: #2f7a4d;
|
||||
--color-bad: #b94e4a;
|
||||
/* Brand + state — purple accent (concept #7c3aed); light good/bad kept
|
||||
slightly darker than the raw concept hues for WCAG AA on the paper tints. */
|
||||
--color-accent: #7c3aed;
|
||||
--color-accent-strong: #6d28d9;
|
||||
--color-warm: #c47e12;
|
||||
--color-good: #0c8a52;
|
||||
--color-bad: #c2403c;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--color-surface: #0e1014;
|
||||
--color-surface-elevated: #15181c;
|
||||
--color-surface-sunken: #0a0b0e;
|
||||
--color-surface-card: #1a1d23;
|
||||
/* Org Concierge dark palette — near-black panels, bright purple accent. */
|
||||
--color-surface: #08080a;
|
||||
--color-surface-elevated: #16161d;
|
||||
--color-surface-sunken: #0d0d11;
|
||||
--color-surface-card: #1b1b23;
|
||||
|
||||
--color-line: #2a2f3a;
|
||||
--color-line-soft: #1f2329;
|
||||
--color-line: #26262e;
|
||||
--color-line-soft: #1b1b22;
|
||||
|
||||
--color-ink: #f4f1e9;
|
||||
--color-ink-mid: #c8c2b4;
|
||||
--color-ink-soft: #8d92a0;
|
||||
--color-ink: #ececf1;
|
||||
--color-ink-mid: #9b9baa;
|
||||
--color-ink-soft: #65656f;
|
||||
|
||||
/* Accents brighten slightly for AA contrast on dark backgrounds. */
|
||||
--color-accent: #6883e8;
|
||||
--color-accent-strong: #8aa1ee;
|
||||
--color-warm: #d96f48;
|
||||
--color-good: #4ca06e;
|
||||
--color-bad: #d27773;
|
||||
/* Purple accent brightened for AA on the near-black surfaces. */
|
||||
--color-accent: #a78bfa;
|
||||
--color-accent-strong: #c4b5fd;
|
||||
--color-warm: #fbbf24;
|
||||
--color-good: #34d399;
|
||||
--color-bad: #f87171;
|
||||
}
|
||||
|
||||
:root {
|
||||
@@ -107,15 +111,22 @@
|
||||
* component, not per theme.
|
||||
*/
|
||||
@theme {
|
||||
--color-bg: rgb(9 9 11); /* zinc-950 */
|
||||
--color-bg-elev: rgb(24 24 27); /* zinc-900 */
|
||||
--color-bg-card: rgb(39 39 42); /* zinc-800 */
|
||||
--color-line-strong: rgb(63 63 70); /* zinc-700 */
|
||||
--color-ink-mute: rgb(161 161 170); /* zinc-400 */
|
||||
--color-ink-dim: rgb(113 113 122); /* zinc-500 */
|
||||
--color-accent-dim: rgb(96 165 250);/* blue-400 */
|
||||
--color-plasma: rgb(59 130 246); /* blue-500 */
|
||||
/* Org Concierge canvas palette (near-black + purple). */
|
||||
--color-bg: rgb(8 8 10); /* concept --bg #08080a */
|
||||
--color-bg-elev: rgb(22 22 29); /* concept --card #16161d */
|
||||
--color-bg-card: rgb(27 27 35); /* concept --card-2 #1b1b23 */
|
||||
--color-line-strong: rgb(54 54 64);
|
||||
--color-ink-mute: rgb(155 155 170); /* concept --tx-2 */
|
||||
--color-ink-dim: rgb(101 101 111); /* concept --tx-3 */
|
||||
--color-accent-dim: rgb(167 139 250);/* concept --accent-2 #a78bfa */
|
||||
--color-plasma: rgb(139 92 246); /* concept --accent #8b5cf6 */
|
||||
--color-warn: rgb(251 191 36); /* amber-400 */
|
||||
|
||||
/* Typography — Org Concierge (Hanken Grotesk UI, JetBrains Mono code).
|
||||
next/font variables are set on <html> in the canvas layout. */
|
||||
--font-sans: var(--font-hanken), ui-sans-serif, system-ui, -apple-system,
|
||||
"Segoe UI", Roboto, sans-serif;
|
||||
--font-mono: var(--font-jetbrains), ui-monospace, "SF Mono", Menlo, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -124,7 +135,8 @@ body {
|
||||
overflow: hidden;
|
||||
background-color: var(--color-surface);
|
||||
color: var(--color-ink);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
||||
font-family: var(--font-hanken), -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
Roboto, "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, JetBrains_Mono } from "next/font/google";
|
||||
import { Hanken_Grotesk, JetBrains_Mono } from "next/font/google";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
@@ -7,10 +7,13 @@ import "./globals.css";
|
||||
// because Next.js serves the .woff2 from /_next/static). Exposed as
|
||||
// CSS variables so the mobile palette can reference them without
|
||||
// importing this module.
|
||||
const interFont = Inter({
|
||||
// Org Concierge UI typeface (canvas redesign): Hanken Grotesk, exposed as
|
||||
// --font-hanken and consumed by the --font-sans theme token in globals.css.
|
||||
const interFont = Hanken_Grotesk({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700"],
|
||||
display: "swap",
|
||||
variable: "--font-inter",
|
||||
variable: "--font-hanken",
|
||||
});
|
||||
const monoFont = JetBrains_Mono({
|
||||
subsets: ["latin"],
|
||||
@@ -161,6 +164,12 @@ export default async function RootLayout({
|
||||
*/}
|
||||
<script
|
||||
nonce={nonce}
|
||||
// The browser strips the nonce attribute off <script> after applying
|
||||
// CSP, so the hydrated DOM shows nonce="" while React's tree carries
|
||||
// the real value — a benign, expected server/client diff. Suppress
|
||||
// the hydration warning for this element (same rationale as the
|
||||
// <html> suppressHydrationWarning above).
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{ __html: themeBootScript }}
|
||||
/>
|
||||
{/*
|
||||
@@ -186,6 +195,7 @@ export default async function RootLayout({
|
||||
<script
|
||||
type="application/ld+json"
|
||||
nonce={nonce}
|
||||
suppressHydrationWarning
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Canvas } from "@/components/Canvas";
|
||||
import { Legend } from "@/components/Legend";
|
||||
import { CommunicationOverlay } from "@/components/CommunicationOverlay";
|
||||
import { ConciergeShell } from "@/components/concierge/ConciergeShell";
|
||||
import { MobileApp } from "@/components/mobile/MobileApp";
|
||||
import { Spinner } from "@/components/Spinner";
|
||||
import { connectSocket, disconnectSocket } from "@/store/socket";
|
||||
@@ -115,11 +113,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<main aria-label="Agent canvas">
|
||||
<Canvas />
|
||||
</main>
|
||||
<Legend />
|
||||
<CommunicationOverlay />
|
||||
<ConciergeShell />
|
||||
{hydrationError && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { WORKSPACE_KIND } from "@/lib/workspace-kind";
|
||||
import { stripPlatformRootForMap } from "@/store/canvas-topology";
|
||||
import { useTheme } from "@/lib/theme-provider";
|
||||
import { A2ATopologyOverlay } from "./A2ATopologyOverlay";
|
||||
import { WorkspaceNode } from "./WorkspaceNode";
|
||||
@@ -78,15 +80,38 @@ function CanvasInner() {
|
||||
// half-themed page. Pull resolvedTheme so the canvas matches the user's
|
||||
// selected mode (and the system preference when they pick "system").
|
||||
const { resolvedTheme } = useTheme();
|
||||
const rawNodes = useCanvasStore((s) => s.nodes);
|
||||
const edges = useCanvasStore((s) => s.edges);
|
||||
const storeNodes = useCanvasStore((s) => s.nodes);
|
||||
const storeEdges = useCanvasStore((s) => s.edges);
|
||||
const a2aEdges = useCanvasStore((s) => s.a2aEdges);
|
||||
const showA2AEdges = useCanvasStore((s) => s.showA2AEdges);
|
||||
const deletingIds = useCanvasStore((s) => s.deletingIds);
|
||||
const allEdges = useMemo(
|
||||
() => (showA2AEdges ? [...edges, ...a2aEdges] : edges),
|
||||
[edges, a2aEdges, showA2AEdges],
|
||||
// Hide the org-level platform agent (the concierge) from the map graph: it is
|
||||
// the undeletable org ROOT surfaced in the shell (topbar + Home tree), not a
|
||||
// draggable/deletable map node. Its direct children are reparented to
|
||||
// top-level and tree edges touching it are dropped. The store keeps the full
|
||||
// node set, so the shell's Home agent tree still renders it as ROOT.
|
||||
const { nodes: rawNodes, edges } = useMemo(
|
||||
() => stripPlatformRootForMap(storeNodes, storeEdges),
|
||||
[storeNodes, storeEdges],
|
||||
);
|
||||
const platformIds = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
storeNodes
|
||||
.filter((n) => n.data.kind === WORKSPACE_KIND.Platform)
|
||||
.map((n) => n.id),
|
||||
),
|
||||
[storeNodes],
|
||||
);
|
||||
const allEdges = useMemo(() => {
|
||||
if (!showA2AEdges) return edges;
|
||||
// Drop A2A edges that touch the hidden platform root so React Flow doesn't
|
||||
// warn about an edge to a missing node.
|
||||
const a2a = a2aEdges.filter(
|
||||
(e) => !platformIds.has(e.source) && !platformIds.has(e.target),
|
||||
);
|
||||
return [...edges, ...a2a];
|
||||
}, [edges, a2aEdges, showA2AEdges, platformIds]);
|
||||
// Drag-lock during a system-owned operation (deploy OR delete).
|
||||
// React Flow respects Node.draggable, which stops the gesture
|
||||
// before it starts — preventDefault() on the drag-start callback
|
||||
@@ -277,7 +302,7 @@ function CanvasInner() {
|
||||
>
|
||||
Skip to canvas
|
||||
</a>
|
||||
<main id="canvas-main" className="w-screen h-screen bg-surface">
|
||||
<main id="canvas-main" className="w-full h-full bg-surface">
|
||||
<ReactFlow
|
||||
colorMode={resolvedTheme}
|
||||
nodes={nodes}
|
||||
|
||||
@@ -1,25 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { useCanvasStore, type PanelTab } from "@/store/canvas";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { StatusDot } from "./StatusDot";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { DetailsTab } from "./tabs/DetailsTab";
|
||||
import { SkillsTab } from "./tabs/SkillsTab";
|
||||
import { ChatTab } from "./tabs/ChatTab";
|
||||
import { ConfigTab } from "./tabs/ConfigTab";
|
||||
import { ContainerConfigTab } from "./tabs/ContainerConfigTab";
|
||||
import { DisplayTab } from "./tabs/DisplayTab";
|
||||
import { TerminalTab } from "./tabs/TerminalTab";
|
||||
import { FilesTab } from "./tabs/FilesTab";
|
||||
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
|
||||
import { AuditTrailPanel } from "./AuditTrailPanel";
|
||||
import { TracesTab } from "./tabs/TracesTab";
|
||||
import { EventsTab } from "./tabs/EventsTab";
|
||||
import { ActivityTab } from "./tabs/ActivityTab";
|
||||
import { ScheduleTab } from "./tabs/ScheduleTab";
|
||||
import { ChannelsTab } from "./tabs/ChannelsTab";
|
||||
import { WorkspacePanelTabs } from "./WorkspacePanelTabs";
|
||||
import { summarizeWorkspaceCapabilities } from "@/store/canvas";
|
||||
|
||||
const SIDEPANEL_WIDTH_KEY = "molecule:sidepanel-width";
|
||||
@@ -27,24 +11,6 @@ const SIDEPANEL_DEFAULT_WIDTH = 480;
|
||||
const SIDEPANEL_MIN_WIDTH = 320;
|
||||
const SIDEPANEL_MAX_WIDTH = 800;
|
||||
|
||||
const TABS: { id: PanelTab; label: string; icon: string }[] = [
|
||||
{ id: "chat", label: "Chat", icon: "◈" },
|
||||
{ id: "activity", label: "Activity", icon: "⊙" },
|
||||
{ id: "details", label: "Details", icon: "◉" },
|
||||
{ id: "skills", label: "Plugins", icon: "✦" },
|
||||
{ id: "terminal", label: "Terminal", icon: "▸" },
|
||||
{ id: "display", label: "Display", icon: "▣" },
|
||||
{ id: "container-config", label: "Container", icon: "▤" },
|
||||
{ id: "config", label: "Config", icon: "⚙" },
|
||||
{ id: "schedule", label: "Schedule", icon: "⏲" },
|
||||
{ id: "channels", label: "Channels", icon: "⇌" },
|
||||
{ id: "files", label: "Files", icon: "⊞" },
|
||||
{ id: "memory", label: "Memory", icon: "◇" },
|
||||
{ id: "traces", label: "Traces", icon: "◎" },
|
||||
{ id: "events", label: "Events", icon: "◊" },
|
||||
{ id: "audit", label: "Audit", icon: "⊟" },
|
||||
];
|
||||
|
||||
export function SidePanel() {
|
||||
const selectedNodeId = useCanvasStore((s) => s.selectedNodeId);
|
||||
const panelTab = useCanvasStore((s) => s.panelTab);
|
||||
@@ -219,104 +185,12 @@ export function SidePanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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-surface to-transparent z-10" aria-hidden="true" />
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Workspace panel tabs"
|
||||
className="flex overflow-x-auto bg-surface-sunken/20 px-1"
|
||||
onKeyDown={(e) => {
|
||||
const idx = TABS.findIndex((t) => t.id === panelTab);
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); next = (idx + 1) % TABS.length; }
|
||||
else if (e.key === "ArrowLeft") { e.preventDefault(); next = (idx - 1 + TABS.length) % TABS.length; }
|
||||
else if (e.key === "Home") { e.preventDefault(); next = 0; }
|
||||
else if (e.key === "End") { e.preventDefault(); next = TABS.length - 1; }
|
||||
if (next !== null) {
|
||||
setPanelTab(TABS[next].id);
|
||||
requestAnimationFrame(() => { const el = document.getElementById(`tab-${TABS[next!].id}`); el?.focus(); el?.scrollIntoView({ block: "nearest", inline: "nearest" }); });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{TABS.map((tab) => (
|
||||
<button
|
||||
type="button"
|
||||
key={tab.id}
|
||||
id={`tab-${tab.id}`}
|
||||
role="tab"
|
||||
aria-selected={panelTab === tab.id}
|
||||
aria-controls={`panel-${tab.id}`}
|
||||
tabIndex={panelTab === tab.id ? 0 : -1}
|
||||
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 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>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Needs Restart Banner */}
|
||||
{node.data.needsRestart && !node.data.currentTask && selectedNodeId && (
|
||||
<div className="px-4 py-2 bg-sky-950/20 border-b border-sky-800/20 flex items-center justify-between">
|
||||
<span className="text-[10px] text-sky-300/90">Config changed — restart to apply</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
useCanvasStore.getState().restartWorkspace(selectedNodeId).catch(() => showToast("Restart failed", "error"));
|
||||
}}
|
||||
className="text-[11px] px-2 py-1 bg-sky-800/40 hover:bg-sky-700/50 text-sky-200 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Restart Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Task Banner */}
|
||||
{node.data.currentTask && (
|
||||
<Tooltip text={node.data.currentTask as string}>
|
||||
<div className="px-4 py-2 bg-amber-950/20 border-b border-amber-800/20 flex items-center gap-2 cursor-default">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-warm/90 truncate">
|
||||
{node.data.currentTask}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`panel-${panelTab}`}
|
||||
aria-labelledby={`tab-${panelTab}`}
|
||||
tabIndex={0}
|
||||
className="flex-1 overflow-y-auto focus:outline-none"
|
||||
>
|
||||
{panelTab === "details" && <DetailsTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "skills" && <SkillsTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "activity" && <ActivityTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "container-config" && selectedNodeId && (
|
||||
<ContainerConfigTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />
|
||||
)}
|
||||
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "files" && <FilesTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
|
||||
{panelTab === "memory" && <MemoryInspectorPanel key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "traces" && <TracesTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "events" && <EventsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
{panelTab === "audit" && <AuditTrailPanel key={selectedNodeId} workspaceId={selectedNodeId} />}
|
||||
</div>
|
||||
{/* Tabs + tab content — extracted into WorkspacePanelTabs so the same
|
||||
tab bar/body is reused verbatim by the concierge Settings page. The
|
||||
map drawer stays store-driven: we thread the global panelTab /
|
||||
setPanelTab through as the controlled active-tab pair, preserving the
|
||||
existing selection + keyboard behaviour. */}
|
||||
<WorkspacePanelTabs node={node} activeTab={panelTab} onTabChange={setPanelTab} />
|
||||
|
||||
{/* Footer — workspace ID */}
|
||||
<div className="px-4 sm:px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
|
||||
|
||||
@@ -3,11 +3,9 @@
|
||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { SettingsButton } from "@/components/settings/SettingsButton";
|
||||
import { settingsGearRef } from "@/components/settings/SettingsPanel";
|
||||
import { WORKSPACE_KIND } from "@/lib/workspace-kind";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { statusDotClass } from "@/lib/design-tokens";
|
||||
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
|
||||
|
||||
@@ -55,8 +53,11 @@ export function Toolbar() {
|
||||
}, [wsStatus]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { total: nodes.length, roots: 0, children: 0, online: 0, offline: 0, failed: 0, provisioning: 0, activeTasks: 0 };
|
||||
for (const n of nodes) {
|
||||
// Exclude the org-level platform agent (the concierge) — it's the
|
||||
// undeletable org root surfaced in the shell, not a counted map workspace.
|
||||
const mapNodes = nodes.filter((n) => n.data.kind !== WORKSPACE_KIND.Platform);
|
||||
const c = { total: mapNodes.length, roots: 0, children: 0, online: 0, offline: 0, failed: 0, provisioning: 0, activeTasks: 0 };
|
||||
for (const n of mapNodes) {
|
||||
if (n.data.parentId) c.children++; else c.roots++;
|
||||
const s = n.data.status;
|
||||
if (s === "online") c.online++;
|
||||
@@ -460,11 +461,8 @@ export function Toolbar() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Theme picker — System / Light / Dark */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Settings gear icon */}
|
||||
<SettingsButton ref={settingsGearRef} />
|
||||
{/* Theme picker + settings gear removed from the map toolbar — both now
|
||||
live in the concierge global Settings (left rail) + topbar. */}
|
||||
|
||||
<ConfirmDialog
|
||||
open={restartConfirmOpen}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, type KeyboardEvent } from "react";
|
||||
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useMemo, type KeyboardEvent } from "react";
|
||||
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
|
||||
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
|
||||
import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
@@ -21,7 +21,8 @@ function useDescendantCount(nodeId: string): number {
|
||||
return useMemo(() => countDescendants(nodeId, nodes), [nodeId, nodes]);
|
||||
}
|
||||
|
||||
/** Boolean flag used to drive min-size and NodeResizer dimensions.
|
||||
/** Boolean flag used to drive the container's system-controlled size
|
||||
* (leaves render fixed-size; parents grow to fit children).
|
||||
* Selecting `nodes` stably avoids re-render loops (same issue as
|
||||
* useDescendantCount). */
|
||||
function useHasChildren(nodeId: string): boolean {
|
||||
@@ -87,16 +88,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* NodeResizer — visible only on the selected card. Lets the user
|
||||
* drag any edge/corner to grow or shrink the workspace, which is
|
||||
* useful on cards that contain nested child workspaces. */}
|
||||
<NodeResizer
|
||||
isVisible={isSelected}
|
||||
minWidth={hasChildren ? 360 : 210}
|
||||
minHeight={hasChildren ? 200 : 110}
|
||||
lineClassName="!border-accent/40"
|
||||
handleClassName="!w-2 !h-2 !bg-accent !border !border-blue-300"
|
||||
/>
|
||||
{/* Free-resize removed (was NodeResizer). Container size + shape are now
|
||||
* system-controlled: leaf workspaces render at a fixed width; parent
|
||||
* workspaces grow to fit their nested children (store grow logic). */}
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -161,20 +155,22 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
}
|
||||
}}
|
||||
className={`
|
||||
group relative rounded-xl h-full w-full
|
||||
${hasChildren && !data.collapsed ? "min-w-[360px] min-h-[200px]" : "min-w-[210px]"}
|
||||
group relative rounded-xl
|
||||
${hasChildren && !data.collapsed
|
||||
? "h-full w-full min-w-[420px] min-h-[240px]"
|
||||
: "w-[300px] min-h-[176px]"}
|
||||
cursor-pointer overflow-hidden
|
||||
transition-all duration-200 ease-out
|
||||
${isDragTarget
|
||||
? "bg-emerald-950/40 border-2 border-emerald-400/60 ring-2 ring-emerald-400/20 scale-[1.03]"
|
||||
: isBatchSelected
|
||||
? "bg-surface-sunken/95 border-2 border-accent/80 ring-2 ring-accent/30 shadow-lg shadow-blue-500/15"
|
||||
? "bg-surface-sunken/95 border-2 border-accent/80 ring-2 ring-accent/30 shadow-lg shadow-accent/15"
|
||||
: isSelected
|
||||
? "bg-surface-sunken/95 border border-accent/70 ring-1 ring-accent/30 shadow-lg shadow-blue-500/10"
|
||||
: "bg-surface-sunken/90 border border-line/80 hover:border-zinc-500/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"
|
||||
? "bg-surface-sunken/95 border border-accent/70 ring-1 ring-accent/30 shadow-lg shadow-accent/10"
|
||||
: "bg-surface-sunken/90 border border-line/80 hover:border-ink-soft/60 shadow-lg shadow-black/30 hover:shadow-xl hover:shadow-black/40"
|
||||
}
|
||||
backdrop-blur-sm
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950
|
||||
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/70 focus-visible:ring-offset-1 focus-visible:ring-offset-surface
|
||||
${deploy.isActivelyProvisioning ? "mol-deploy-shimmer" : ""}
|
||||
${deploy.isLockedChild ? "mol-deploy-locked" : ""}
|
||||
`}
|
||||
@@ -212,27 +208,45 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 focus-visible:!bg-blue-400 focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 transition-all"
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-accent hover:!h-1.5 focus-visible:!bg-accent focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface transition-all"
|
||||
/>
|
||||
|
||||
<div className="relative px-3.5 py-2.5">
|
||||
<div className="relative px-4 py-3.5">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center justify-between gap-2 mb-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
|
||||
<span className="text-[13px] font-semibold text-ink truncate leading-tight">
|
||||
<div className="flex items-center justify-between gap-2 mb-2.5">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className={`w-2.5 h-2.5 rounded-full shrink-0 ${statusCfg.dot} ${statusCfg.glow} shadow-sm`} />
|
||||
<span className="text-[15px] font-semibold text-ink truncate leading-tight">
|
||||
{data.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{hasChildren && (
|
||||
<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>
|
||||
)}
|
||||
<span className={`text-[10px] font-mono px-1.5 py-0.5 rounded-md ${tierCfg.color}`}>
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
{/* Model pill (concept top-right). Shortens the agent_card model to
|
||||
a family label (Opus/Sonnet/Haiku/Kimi); falls back to the raw
|
||||
last segment, then to the tier badge when no model is known. */}
|
||||
{(() => {
|
||||
const m = (data.agentCard as Record<string, unknown> | null)?.model;
|
||||
const model = typeof m === "string" && m ? m : null;
|
||||
if (!model) {
|
||||
return (
|
||||
<span className={`text-[11px] font-mono px-2 py-1 rounded-md ${tierCfg.color}`}>
|
||||
{tierCfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const label = /opus/i.test(model) ? "Opus"
|
||||
: /sonnet/i.test(model) ? "Sonnet"
|
||||
: /haiku/i.test(model) ? "Haiku"
|
||||
: /kimi/i.test(model) ? "Kimi"
|
||||
: /gpt|openai/i.test(model) ? "GPT"
|
||||
: /gemini/i.test(model) ? "Gemini"
|
||||
: (model.split(/[/:]/).pop() || model);
|
||||
return (
|
||||
<span className="text-[11px] font-mono px-2 py-1 rounded-md text-white bg-accent" title={model}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -242,6 +256,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
We treat empty-string DB values as "missing" so an unbackfilled
|
||||
row falls through to the agent-card value rather than rendering
|
||||
a blank pill. */}
|
||||
{/* Role pill (concept) — uppercase, accent-bordered. Platform root
|
||||
shows "PLATFORM · ROOT"; Phase 30 external-runtime agents get the
|
||||
REMOTE marker alongside. */}
|
||||
{(() => {
|
||||
const dbRuntime = typeof data.runtime === "string" && data.runtime !== ""
|
||||
? data.runtime : null;
|
||||
@@ -249,32 +266,46 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
? (data.agentCard as Record<string, string>).runtime
|
||||
: null;
|
||||
const runtime = dbRuntime ?? cardRuntime;
|
||||
if (!runtime) return null;
|
||||
const isRemote = !!runtime && isExternalLikeRuntime(runtime);
|
||||
const isPlatformRoot = !data.parentId && hasChildren;
|
||||
const roleLabel = isPlatformRoot ? "PLATFORM · ROOT" : (data.role || null);
|
||||
if (!roleLabel && !isRemote) return null;
|
||||
return (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
{isExternalLikeRuntime(runtime) ? (
|
||||
<div className="mb-2.5 flex items-center gap-1.5">
|
||||
{roleLabel && (
|
||||
<span className="max-w-[220px] truncate text-[10px] font-mono uppercase tracking-[0.04em] px-2 py-1 rounded-md text-accent bg-accent/12 border border-accent/35">
|
||||
{roleLabel}
|
||||
</span>
|
||||
)}
|
||||
{isRemote && (
|
||||
<span
|
||||
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-800 border border-violet-900"
|
||||
className="text-[10px] font-mono uppercase px-2 py-1 rounded-md text-white bg-violet-800 border border-violet-900"
|
||||
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 border border-line">
|
||||
{runtime}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Role — clamp to 2 lines. Without this, a verbose role
|
||||
* description (common on org-template imports) lets the card
|
||||
* grow arbitrarily tall, which wrecks the grid-slot layout
|
||||
* because siblings all plan for the same CHILD_DEFAULT_HEIGHT. */}
|
||||
{data.role && (
|
||||
<div className="text-[10px] text-ink-mid mb-1.5 leading-tight line-clamp-2">{data.role}</div>
|
||||
)}
|
||||
{/* Status line (concept) — uppercase status, "· N AGENTS" for parents,
|
||||
with a queued pill on the right. */}
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className={`text-[11px] font-mono uppercase tracking-[0.04em] ${
|
||||
isOnline ? "text-good"
|
||||
: effectiveStatus === "failed" ? "text-bad"
|
||||
: (effectiveStatus === "provisioning" || effectiveStatus === "degraded") ? "text-warm"
|
||||
: "text-ink-soft"
|
||||
}`}>
|
||||
{statusCfg.label}{hasChildren ? ` · ${descendantCount} agents` : ""}
|
||||
</span>
|
||||
{data.activeTasks > 0 && (
|
||||
<span className="shrink-0 text-[11px] font-mono px-2 py-1 rounded-md text-ink-mid bg-surface-card border border-line">
|
||||
≡ {data.activeTasks} queued
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Skills */}
|
||||
{skills.length > 0 && (
|
||||
@@ -328,29 +359,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Bottom row: status / active tasks */}
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
{effectiveStatus !== "online" ? (
|
||||
<div className={`text-[10px] uppercase tracking-widest font-medium ${
|
||||
effectiveStatus === "failed" ? "text-bad" :
|
||||
effectiveStatus === "degraded" ? "text-warm" :
|
||||
effectiveStatus === "not_configured" ? "text-warm" :
|
||||
effectiveStatus === "provisioning" ? "text-accent" :
|
||||
"text-ink-mid"
|
||||
}`}>
|
||||
{statusCfg.label}
|
||||
</div>
|
||||
) : <div />}
|
||||
|
||||
{data.activeTasks > 0 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
{/* (status + queued now rendered above, concept-style) */}
|
||||
|
||||
{/* Degraded error preview */}
|
||||
{data.status === "degraded" && data.lastSampleError && (
|
||||
@@ -395,7 +404,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 focus-visible:!bg-blue-400 focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 transition-all"
|
||||
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-accent hover:!h-1.5 focus-visible:!bg-accent focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface transition-all"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import type { Node } from "@xyflow/react";
|
||||
import {
|
||||
useCanvasStore,
|
||||
type PanelTab,
|
||||
type WorkspaceNodeData,
|
||||
} from "@/store/canvas";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import { Tooltip } from "./Tooltip";
|
||||
import { DetailsTab } from "./tabs/DetailsTab";
|
||||
import { SkillsTab } from "./tabs/SkillsTab";
|
||||
import { ChatTab } from "./tabs/ChatTab";
|
||||
import { ConfigTab } from "./tabs/ConfigTab";
|
||||
import { ContainerConfigTab } from "./tabs/ContainerConfigTab";
|
||||
import { DisplayTab } from "./tabs/DisplayTab";
|
||||
import { TerminalTab } from "./tabs/TerminalTab";
|
||||
import { FilesTab } from "./tabs/FilesTab";
|
||||
import { MemoryInspectorPanel } from "./MemoryInspectorPanel";
|
||||
import { AuditTrailPanel } from "./AuditTrailPanel";
|
||||
import { TracesTab } from "./tabs/TracesTab";
|
||||
import { EventsTab } from "./tabs/EventsTab";
|
||||
import { ActivityTab } from "./tabs/ActivityTab";
|
||||
import { ScheduleTab } from "./tabs/ScheduleTab";
|
||||
import { ChannelsTab } from "./tabs/ChannelsTab";
|
||||
|
||||
/**
|
||||
* Canonical workspace tab set — the SAME ids/labels/icons the map's
|
||||
* SidePanel has always rendered. Single source of truth so the map drawer
|
||||
* and any other host (the concierge Settings page) can't drift.
|
||||
*/
|
||||
export const WORKSPACE_PANEL_TABS: { id: PanelTab; label: string; icon: string }[] = [
|
||||
{ id: "chat", label: "Chat", icon: "◈" },
|
||||
{ id: "activity", label: "Activity", icon: "⊙" },
|
||||
{ id: "details", label: "Details", icon: "◉" },
|
||||
{ id: "skills", label: "Plugins", icon: "✦" },
|
||||
{ id: "terminal", label: "Terminal", icon: "▸" },
|
||||
{ id: "display", label: "Display", icon: "▣" },
|
||||
{ id: "container-config", label: "Container", icon: "▤" },
|
||||
{ id: "config", label: "Config", icon: "⚙" },
|
||||
{ id: "schedule", label: "Schedule", icon: "⏲" },
|
||||
{ id: "channels", label: "Channels", icon: "⇌" },
|
||||
{ id: "files", label: "Files", icon: "⊞" },
|
||||
{ id: "memory", label: "Memory", icon: "◇" },
|
||||
{ id: "traces", label: "Traces", icon: "◎" },
|
||||
{ id: "events", label: "Events", icon: "◊" },
|
||||
{ id: "audit", label: "Audit", icon: "⊟" },
|
||||
];
|
||||
|
||||
interface Props {
|
||||
/** The workspace node whose tabs to render (id + data blob). */
|
||||
node: Node<WorkspaceNodeData>;
|
||||
/**
|
||||
* Controlled active tab. When provided together with `onTabChange`, the
|
||||
* caller owns the active-tab state (the map's SidePanel threads the global
|
||||
* `panelTab`/`setPanelTab` here so the store stays the source of truth and
|
||||
* the existing keyboard/selection behaviour is preserved verbatim).
|
||||
* When omitted, the component manages its OWN local active-tab state —
|
||||
* which is what the concierge Settings page uses so the embedded tabs
|
||||
* don't fight the map's selection.
|
||||
*/
|
||||
activeTab?: PanelTab;
|
||||
onTabChange?: (tab: PanelTab) => void;
|
||||
/** Initial tab for the uncontrolled (local-state) mode. Defaults to "chat". */
|
||||
defaultTab?: PanelTab;
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace tab bar + tab body, extracted from SidePanel so it can be
|
||||
* reused verbatim outside the map (e.g. the concierge Settings "Platform
|
||||
* agent configuration" section). Renders the canonical ARIA tablist and the
|
||||
* exact same tab content components keyed on the active tab.
|
||||
*
|
||||
* Does NOT render the workspace header / meta pills / resize handle / footer —
|
||||
* those are host chrome and stay in the host (SidePanel for the map).
|
||||
*/
|
||||
export function WorkspacePanelTabs({ node, activeTab, onTabChange, defaultTab = "chat" }: Props) {
|
||||
const restartWorkspace = useCanvasStore((s) => s.restartWorkspace);
|
||||
|
||||
// Controlled when both props are present; otherwise own the state locally.
|
||||
const controlled = activeTab !== undefined && onTabChange !== undefined;
|
||||
const [localTab, setLocalTab] = useState<PanelTab>(defaultTab);
|
||||
const tab = controlled ? (activeTab as PanelTab) : localTab;
|
||||
const setTab = (next: PanelTab) => {
|
||||
if (controlled) onTabChange!(next);
|
||||
else setLocalTab(next);
|
||||
};
|
||||
|
||||
const workspaceId = node.id;
|
||||
const data = node.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 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-surface to-transparent z-10" aria-hidden="true" />
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Workspace panel tabs"
|
||||
className="flex overflow-x-auto bg-surface-sunken/20 px-1"
|
||||
onKeyDown={(e) => {
|
||||
const idx = WORKSPACE_PANEL_TABS.findIndex((t) => t.id === tab);
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowRight") { e.preventDefault(); next = (idx + 1) % WORKSPACE_PANEL_TABS.length; }
|
||||
else if (e.key === "ArrowLeft") { e.preventDefault(); next = (idx - 1 + WORKSPACE_PANEL_TABS.length) % WORKSPACE_PANEL_TABS.length; }
|
||||
else if (e.key === "Home") { e.preventDefault(); next = 0; }
|
||||
else if (e.key === "End") { e.preventDefault(); next = WORKSPACE_PANEL_TABS.length - 1; }
|
||||
if (next !== null) {
|
||||
setTab(WORKSPACE_PANEL_TABS[next].id);
|
||||
requestAnimationFrame(() => { const el = document.getElementById(`tab-${WORKSPACE_PANEL_TABS[next!].id}`); el?.focus(); el?.scrollIntoView({ block: "nearest", inline: "nearest" }); });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{WORKSPACE_PANEL_TABS.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
id={`tab-${t.id}`}
|
||||
role="tab"
|
||||
aria-selected={tab === t.id}
|
||||
aria-controls={`panel-${t.id}`}
|
||||
tabIndex={tab === t.id ? 0 : -1}
|
||||
onClick={() => setTab(t.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 ${
|
||||
tab === t.id
|
||||
? "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">{t.icon}</span>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Needs Restart Banner */}
|
||||
{data.needsRestart && !data.currentTask && (
|
||||
<div className="px-4 py-2 bg-sky-950/20 border-b border-sky-800/20 flex items-center justify-between">
|
||||
<span className="text-[10px] text-sky-300/90">Config changed — restart to apply</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
restartWorkspace(workspaceId).catch(() => showToast("Restart failed", "error"));
|
||||
}}
|
||||
className="text-[11px] px-2 py-1 bg-sky-800/40 hover:bg-sky-700/50 text-sky-200 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Restart Now
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Task Banner */}
|
||||
{data.currentTask && (
|
||||
<Tooltip text={data.currentTask as string}>
|
||||
<div className="px-4 py-2 bg-amber-950/20 border-b border-amber-800/20 flex items-center gap-2 cursor-default">
|
||||
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 motion-safe:animate-pulse shrink-0" />
|
||||
<span className="text-[10px] text-warm/90 truncate">
|
||||
{data.currentTask}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Tab Content */}
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`panel-${tab}`}
|
||||
aria-labelledby={`tab-${tab}`}
|
||||
tabIndex={0}
|
||||
className="flex-1 overflow-y-auto focus:outline-none"
|
||||
>
|
||||
{tab === "details" && <DetailsTab key={workspaceId} workspaceId={workspaceId} data={data} />}
|
||||
{tab === "skills" && <SkillsTab key={workspaceId} workspaceId={workspaceId} data={data} />}
|
||||
{tab === "activity" && <ActivityTab key={workspaceId} workspaceId={workspaceId} />}
|
||||
{tab === "chat" && <ChatTab key={workspaceId} workspaceId={workspaceId} data={data} />}
|
||||
{tab === "terminal" && <TerminalTab key={workspaceId} workspaceId={workspaceId} data={data} />}
|
||||
{tab === "display" && <DisplayTab key={workspaceId} workspaceId={workspaceId} />}
|
||||
{tab === "container-config" && (
|
||||
<ContainerConfigTab key={workspaceId} workspaceId={workspaceId} data={data} />
|
||||
)}
|
||||
{tab === "config" && <ConfigTab key={workspaceId} workspaceId={workspaceId} />}
|
||||
{tab === "schedule" && <ScheduleTab key={workspaceId} workspaceId={workspaceId} />}
|
||||
{tab === "channels" && <ChannelsTab key={workspaceId} workspaceId={workspaceId} />}
|
||||
{tab === "files" && <FilesTab key={workspaceId} workspaceId={workspaceId} data={data} />}
|
||||
{tab === "memory" && <MemoryInspectorPanel key={workspaceId} workspaceId={workspaceId} />}
|
||||
{tab === "traces" && <TracesTab key={workspaceId} workspaceId={workspaceId} />}
|
||||
{tab === "events" && <EventsTab key={workspaceId} workspaceId={workspaceId} />}
|
||||
{tab === "audit" && <AuditTrailPanel key={workspaceId} workspaceId={workspaceId} />}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -275,9 +275,9 @@ describe("WorkspaceNode — status states", () => {
|
||||
expect(screen.getByText("STARTING")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses status label for online node", () => {
|
||||
it("shows status label for online node (concept: status always visible)", () => {
|
||||
renderNode({ status: "online" });
|
||||
expect(screen.queryByText("ONLINE")).toBeNull();
|
||||
expect(screen.getByText("ONLINE")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows degraded error preview when status is degraded and lastSampleError is set", () => {
|
||||
@@ -404,14 +404,18 @@ describe("WorkspaceNode — double-click interactions", () => {
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — active tasks", () => {
|
||||
it("shows active tasks badge when activeTasks > 0", () => {
|
||||
it("shows the queued count when activeTasks > 0", () => {
|
||||
renderNode({ activeTasks: 3 });
|
||||
expect(screen.getByText("3 tasks")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.tagName === "SPAN" && (el.textContent ?? "").includes("3 queued")),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows singular 'task' when activeTasks is 1", () => {
|
||||
it("shows the queued count for a single task", () => {
|
||||
renderNode({ activeTasks: 1 });
|
||||
expect(screen.getByText("1 task")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.tagName === "SPAN" && (el.textContent ?? "").includes("1 queued")),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses badge when no active tasks", () => {
|
||||
@@ -471,13 +475,15 @@ describe("WorkspaceNode — needs restart", () => {
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — descendant badge", () => {
|
||||
it("shows descendant count badge when node has children in store", () => {
|
||||
it("shows the agent count in the status line when node has children", () => {
|
||||
store().nodes = [
|
||||
makeNode({ id: "ws-1" }),
|
||||
{ id: "child-1", data: { ...makeNode({ id: "ws-1" }).data, parentId: "ws-1" } },
|
||||
];
|
||||
renderNode();
|
||||
expect(screen.getByText("1 sub")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText((_, el) => el?.tagName === "SPAN" && (el.textContent ?? "").includes("1 agents")),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses badge when node has no children", () => {
|
||||
@@ -527,9 +533,9 @@ describe("WorkspaceNode — skills pills", () => {
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — runtime badge", () => {
|
||||
it("shows runtime badge when runtime is set", () => {
|
||||
renderNode({ runtime: "hermes" });
|
||||
expect(screen.getByText("hermes")).toBeTruthy();
|
||||
it("shows the role pill (runtime pill replaced by role pill in the concept redesign)", () => {
|
||||
renderNode({ role: "researcher" });
|
||||
expect(screen.getByText("researcher")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows REMOTE badge for external runtime", () => {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
// ── Mock every tab content component to a sentinel so we can assert which
|
||||
// body renders without dragging in API calls / heavy children. ───────────
|
||||
vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => <div data-testid="body-details" /> }));
|
||||
vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => <div data-testid="body-skills" /> }));
|
||||
vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => <div data-testid="body-chat" /> }));
|
||||
vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => <div data-testid="body-config" /> }));
|
||||
vi.mock("../tabs/ContainerConfigTab", () => ({ ContainerConfigTab: () => <div data-testid="body-container" /> }));
|
||||
vi.mock("../tabs/DisplayTab", () => ({ DisplayTab: () => <div data-testid="body-display" /> }));
|
||||
vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => <div data-testid="body-terminal" /> }));
|
||||
vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => <div data-testid="body-files" /> }));
|
||||
vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => <div data-testid="body-memory" /> }));
|
||||
vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => <div data-testid="body-traces" /> }));
|
||||
vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => <div data-testid="body-events" /> }));
|
||||
vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => <div data-testid="body-activity" /> }));
|
||||
vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => <div data-testid="body-schedule" /> }));
|
||||
vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => <div data-testid="body-channels" /> }));
|
||||
vi.mock("../AuditTrailPanel", () => ({ AuditTrailPanel: () => <div data-testid="body-audit" /> }));
|
||||
|
||||
vi.mock("../Tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
|
||||
|
||||
// The store is only consulted for restartWorkspace.
|
||||
const mockRestart = vi.fn(() => Promise.resolve());
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: { restartWorkspace: typeof mockRestart }) => unknown) =>
|
||||
selector({ restartWorkspace: mockRestart })
|
||||
),
|
||||
}));
|
||||
|
||||
import { WorkspacePanelTabs, WORKSPACE_PANEL_TABS } from "../WorkspacePanelTabs";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const node: any = {
|
||||
id: "platform-1",
|
||||
data: {
|
||||
name: "Org Concierge",
|
||||
status: "online",
|
||||
tier: 0,
|
||||
role: "platform",
|
||||
parentId: null,
|
||||
needsRestart: false,
|
||||
currentTask: null,
|
||||
agentCard: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe("WorkspacePanelTabs — uncontrolled (Settings usage)", () => {
|
||||
it("renders the canonical 15-tab tablist for an explicit node", () => {
|
||||
render(<WorkspacePanelTabs node={node} />);
|
||||
const tablist = screen.getByRole("tablist");
|
||||
expect(tablist.getAttribute("aria-label")).toBe("Workspace panel tabs");
|
||||
expect(screen.getAllByRole("tab").length).toBe(WORKSPACE_PANEL_TABS.length);
|
||||
expect(WORKSPACE_PANEL_TABS.length).toBe(15);
|
||||
});
|
||||
|
||||
it("defaults to the chat tab when no defaultTab is given", () => {
|
||||
render(<WorkspacePanelTabs node={node} />);
|
||||
expect(screen.getByTestId("body-chat")).toBeTruthy();
|
||||
expect(document.getElementById("tab-chat")?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("honours defaultTab='config' (the concierge Settings entry point)", () => {
|
||||
render(<WorkspacePanelTabs node={node} defaultTab="config" />);
|
||||
expect(screen.getByTestId("body-config")).toBeTruthy();
|
||||
expect(document.getElementById("tab-config")?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("clicking a tab swaps the body using local state (no store panelTab)", () => {
|
||||
render(<WorkspacePanelTabs node={node} />);
|
||||
fireEvent.click(document.getElementById("tab-channels")!);
|
||||
expect(screen.getByTestId("body-channels")).toBeTruthy();
|
||||
expect(document.getElementById("tab-channels")?.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspacePanelTabs — controlled (SidePanel usage)", () => {
|
||||
it("renders activeTab and calls onTabChange instead of local state", () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<WorkspacePanelTabs node={node} activeTab="details" onTabChange={onTabChange} />);
|
||||
expect(screen.getByTestId("body-details")).toBeTruthy();
|
||||
fireEvent.click(document.getElementById("tab-config")!);
|
||||
expect(onTabChange).toHaveBeenCalledWith("config");
|
||||
// Controlled: body does NOT change on its own (parent owns the state).
|
||||
expect(screen.getByTestId("body-details")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ArrowRight from chat calls onTabChange with the next tab", () => {
|
||||
const onTabChange = vi.fn();
|
||||
render(<WorkspacePanelTabs node={node} activeTab="chat" onTabChange={onTabChange} />);
|
||||
fireEvent.keyDown(screen.getByRole("tablist"), { key: "ArrowRight" });
|
||||
expect(onTabChange).toHaveBeenCalledWith("activity");
|
||||
});
|
||||
});
|
||||
@@ -188,11 +188,13 @@ describe("DropTargetBadge — renders ghost slot + badge for valid drag target",
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(screen.getByTestId("ghost-slot")).toBeTruthy();
|
||||
// Ghost uses slotBR from 3rd call: slotBR - slotTL = (712-232, 920-660)
|
||||
// Ghost spans one default child slot at zoom 2: width = CHILD_DEFAULT_WIDTH
|
||||
// (300) × 2 = 600; height = CHILD_DEFAULT_HEIGHT (176) × 2 = 352. left/top
|
||||
// are the column-0/row-0 slot origin (unchanged by the card-size bump).
|
||||
expect(screen.getByTestId("ghost-slot").style.left).toBe("232px");
|
||||
expect(screen.getByTestId("ghost-slot").style.top).toBe("660px");
|
||||
expect(screen.getByTestId("ghost-slot").style.width).toBe("480px");
|
||||
expect(screen.getByTestId("ghost-slot").style.height).toBe("260px");
|
||||
expect(screen.getByTestId("ghost-slot").style.width).toBe("600px");
|
||||
expect(screen.getByTestId("ghost-slot").style.height).toBe("352px");
|
||||
});
|
||||
|
||||
it("ghost is hidden when slot falls entirely outside parent bounds", () => {
|
||||
|
||||
@@ -325,7 +325,7 @@ describe("all shortcuts respect inInput guard", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cmd/Ctrl+Arrow — keyboard node resize", () => {
|
||||
describe("Cmd/Ctrl+Arrow — free-resize removed (system-controlled sizing)", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [
|
||||
{
|
||||
@@ -340,81 +340,15 @@ describe("Cmd/Ctrl+Arrow — keyboard node resize", () => {
|
||||
renderWithProvider();
|
||||
});
|
||||
|
||||
it("resizes height down (smaller) on Cmd/Ctrl+ArrowUp", () => {
|
||||
// Node starts at minHeight=110 (no children). Shrinking clamps to min —
|
||||
// height stays 110. Width is unchanged.
|
||||
it("no longer resizes the node on Cmd/Ctrl+Arrow (free-resize removed)", () => {
|
||||
// Sizing is system-controlled now: leaves render fixed-size and parents
|
||||
// grow to fit their children, so Cmd/Ctrl+Arrow must not emit a
|
||||
// `dimensions` change anymore.
|
||||
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
|
||||
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: "dimensions",
|
||||
id: "n1",
|
||||
dimensions: { width: 210, height: 110 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("resizes height up (larger) on Cmd/Ctrl+ArrowDown", () => {
|
||||
fireEvent.keyDown(window, { key: "ArrowDown", ctrlKey: true });
|
||||
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: "dimensions",
|
||||
id: "n1",
|
||||
dimensions: { width: 210, height: 120 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("resizes width down (smaller) on Cmd/Ctrl+ArrowLeft", () => {
|
||||
// Node starts at minWidth=210 (no children). Shrinking clamps to min —
|
||||
// width stays 210. Height is unchanged.
|
||||
fireEvent.keyDown(window, { key: "ArrowLeft", metaKey: true });
|
||||
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: "dimensions",
|
||||
id: "n1",
|
||||
dimensions: { width: 210, height: 110 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("resizes width up (larger) on Cmd/Ctrl+ArrowRight", () => {
|
||||
fireEvent.keyDown(window, { key: "ArrowRight", ctrlKey: true });
|
||||
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: "dimensions",
|
||||
id: "n1",
|
||||
dimensions: { width: 220, height: 110 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses 2px step with Shift held", () => {
|
||||
// Step is 2px with Shift, but minHeight=110 clamps the result.
|
||||
// 110 - 2 = 108, Math.max(110, 108) = 110. Width is unchanged.
|
||||
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true, shiftKey: true });
|
||||
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
dimensions: { width: 210, height: 110 },
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("respects min-height constraint (no children)", () => {
|
||||
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
|
||||
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
|
||||
// After shrinking from 110 to 100, another ArrowUp hits min-height of 110
|
||||
// (110 - 10 = 100, but 100 < 110 so it should stay at 110)
|
||||
// Actually: 110 -> 100 -> 110 (resets to min)
|
||||
// Let me check: the hook does Math.max(minHeight, currentHeight - step)
|
||||
// minHeight=110, step=10, so 110 - 10 = 100, but Math.max(110, 100) = 110
|
||||
// So two ArrowUp calls should both result in height=100 then height=110?
|
||||
// Wait: 110 - 10 = 100, Math.max(110, 100) = 110 (not 100)
|
||||
// So the height never goes below 110. After first: 110 -> 100, but clamped to 110.
|
||||
// Actually Math.max(110, 100) = 110, so the height never changes.
|
||||
// The min constraint is respected — height stays at 110.
|
||||
expect(mockStoreState.onNodesChange).toHaveBeenLastCalledWith([
|
||||
expect.objectContaining({ dimensions: { width: 210, height: 110 } }),
|
||||
]);
|
||||
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT fire when no node is selected", () => {
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { type NodeChange, type Node } from "@xyflow/react";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
/** Returns true if the node has any direct child in the node list. */
|
||||
function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean {
|
||||
return nodes.some((n) => n.data.parentId === nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas-wide keyboard shortcuts. All bound to the document window so
|
||||
@@ -22,8 +15,9 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
* Cmd/Ctrl+[ — bump selected node backward in z-order
|
||||
* Z — zoom-to-team if the selected node has children
|
||||
* Arrow keys — move selected node 10px (50px with Shift)
|
||||
* Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width)
|
||||
* Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control)
|
||||
*
|
||||
* Node resize shortcuts were removed: container size + shape are now
|
||||
* system-controlled (leaves fixed-size, parents grow to fit children).
|
||||
*/
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
@@ -96,8 +90,8 @@ export function useKeyboardShortcuts() {
|
||||
// Arrow-key node movement — Figma-style keyboard drag for keyboard users.
|
||||
// 10 px per press, 50 px with Shift held. Only fires when a node
|
||||
// is selected and the target isn't a form control. Skipped when a
|
||||
// modifier key (Cmd/Ctrl/Alt) is held so those combos can be used
|
||||
// for other shortcuts (e.g. Cmd+Arrow = resize).
|
||||
// modifier key (Cmd/Ctrl/Alt) is held so those combos stay free for
|
||||
// browser/OS shortcuts (node resize via Cmd+Arrow was removed).
|
||||
if (
|
||||
!inInput &&
|
||||
!e.metaKey &&
|
||||
@@ -125,43 +119,9 @@ export function useKeyboardShortcuts() {
|
||||
state.moveNode(selectedId, dx, dy);
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Arrow — keyboard-accessible node resize.
|
||||
// ↑/↓ resizes height, ←/→ resizes width.
|
||||
// 10 px per press (2 px with Shift for fine control).
|
||||
// Uses the same onNodesChange('dimensions') path that NodeResizer uses.
|
||||
if (
|
||||
!inInput &&
|
||||
(e.metaKey || e.ctrlKey) &&
|
||||
(e.key === "ArrowUp" ||
|
||||
e.key === "ArrowDown" ||
|
||||
e.key === "ArrowLeft" ||
|
||||
e.key === "ArrowRight")
|
||||
) {
|
||||
const state = useCanvasStore.getState();
|
||||
const selectedId = state.selectedNodeId;
|
||||
if (!selectedId) return;
|
||||
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
|
||||
e.preventDefault();
|
||||
const step = e.shiftKey ? 2 : 10;
|
||||
const node = state.nodes.find((n) => n.id === selectedId);
|
||||
if (!node) return;
|
||||
const currentWidth = (node.width ?? 210) as number;
|
||||
const currentHeight = (node.height ?? 110) as number;
|
||||
const minWidth = hasChildren(node.id, state.nodes) ? 360 : 210;
|
||||
const minHeight = hasChildren(node.id, state.nodes) ? 200 : 110;
|
||||
let newWidth = currentWidth;
|
||||
let newHeight = currentHeight;
|
||||
if (e.key === "ArrowUp") newHeight = Math.max(minHeight, currentHeight - step);
|
||||
else if (e.key === "ArrowDown") newHeight = currentHeight + step;
|
||||
else if (e.key === "ArrowLeft") newWidth = Math.max(minWidth, currentWidth - step);
|
||||
else newWidth = currentWidth + step;
|
||||
const change: NodeChange = {
|
||||
type: "dimensions",
|
||||
id: selectedId,
|
||||
dimensions: { width: newWidth, height: newHeight },
|
||||
};
|
||||
state.onNodesChange([change]);
|
||||
}
|
||||
// Node resize (was Cmd/Ctrl+Arrow) removed — container size + shape are
|
||||
// now system-controlled: leaves render at a fixed size and parents grow
|
||||
// to fit their children, so there is no user-driven resize affordance.
|
||||
};
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
/* Faithful port of the Org Concierge concept (molecule-concierge-v1).
|
||||
Scoped under .root so the concept's generic class names (.btn, .view,
|
||||
.msg, .node …) cannot collide with the rest of the canvas app. Theme
|
||||
tokens are redefined here (not the app tokens) so the port matches the
|
||||
concept palette exactly; they key off the same [data-theme] on <html>. */
|
||||
|
||||
.root {
|
||||
--mono: "JetBrains Mono", ui-monospace, monospace;
|
||||
--sans: var(--font-hanken), "Hanken Grotesk", system-ui, sans-serif;
|
||||
/* dark (default) */
|
||||
--bg: #08080a; --panel: #0d0d11; --panel-2: #101015;
|
||||
--card: #16161d; --card-2: #1b1b23; --card-hover: #1f1f28;
|
||||
--hair: rgba(255,255,255,.07); --hair-2: rgba(255,255,255,.11);
|
||||
--tx: #ececf1; --tx-2: #9b9baa; --tx-3: #65656f;
|
||||
--accent: #8b5cf6; --accent-2: #a78bfa; --accent-soft: rgba(139,92,246,.14);
|
||||
--green: #34d399; --green-soft: rgba(52,211,153,.13); --green-bd: rgba(52,211,153,.26);
|
||||
--amber: #fbbf24; --grey: #6a6a78; --warn: #f5a623; --red: #f87171;
|
||||
--dot: rgba(255,255,255,.06);
|
||||
--shadow: 0 18px 50px rgba(0,0,0,.5);
|
||||
--user-bubble-tx: #fff;
|
||||
|
||||
font-family: var(--sans);
|
||||
background: var(--bg);
|
||||
color: var(--tx);
|
||||
font-size: 14px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global([data-theme="light"]) .root {
|
||||
--bg: #f1efe8; --panel: #fbfaf6; --panel-2: #f6f4ee;
|
||||
--card: #ffffff; --card-2: #faf9f4; --card-hover: #f3f1ea;
|
||||
--hair: rgba(20,18,12,.10); --hair-2: rgba(20,18,12,.16);
|
||||
--tx: #21201b; --tx-2: #5c5a52; --tx-3: #8e8b81;
|
||||
--accent: #7c3aed; --accent-2: #7c3aed; --accent-soft: rgba(124,58,237,.10);
|
||||
--green: #0f9d63; --green-soft: rgba(15,157,99,.10); --green-bd: rgba(15,157,99,.24);
|
||||
--amber: #c98a04; --grey: #a8a59b; --warn: #c47e12; --red: #dc4d4d;
|
||||
--dot: rgba(20,18,12,.10);
|
||||
--shadow: 0 18px 50px rgba(60,56,40,.14);
|
||||
}
|
||||
.root *, .root *::before, .root *::after { box-sizing: border-box; }
|
||||
.root ::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
.root ::-webkit-scrollbar-thumb { background: var(--hair-2); border-radius: 8px; }
|
||||
.root ::-webkit-scrollbar-track { background: transparent; }
|
||||
|
||||
.app { display: flex; height: 100%; width: 100%; }
|
||||
|
||||
/* ===== ICON RAIL ===== */
|
||||
.rail {
|
||||
width: 52px; flex: 0 0 52px; background: var(--panel);
|
||||
border-right: 1px solid var(--hair);
|
||||
display: flex; flex-direction: column; padding: 12px 8px; gap: 3px;
|
||||
transition: width .22s cubic-bezier(.4,0,.2,1), flex-basis .22s cubic-bezier(.4,0,.2,1);
|
||||
overflow: hidden;
|
||||
}
|
||||
.app.railOpen .rail { width: 212px; flex-basis: 212px; }
|
||||
.railTop { display: flex; align-items: center; gap: 8px; height: 36px; margin-bottom: 8px; }
|
||||
.logo {
|
||||
width: 36px; height: 36px; flex: 0 0 36px; border-radius: 10px; display: grid; place-items: center; cursor: pointer;
|
||||
background: linear-gradient(150deg,#7c3aed,#a78bfa);
|
||||
box-shadow: 0 4px 14px rgba(124,58,237,.45), inset 0 1px 0 rgba(255,255,255,.25);
|
||||
}
|
||||
.railWordmark { font-weight: 700; font-size: 14.5px; letter-spacing: -.01em; white-space: nowrap; opacity: 0; transition: opacity .16s; pointer-events: none; }
|
||||
.app.railOpen .railWordmark { opacity: 1; transition: opacity .18s .08s; }
|
||||
.railToggle { margin-left: auto; width: 30px; height: 30px; flex: 0 0 30px; border-radius: 8px; display: grid; place-items: center; color: var(--tx-3); cursor: pointer; transition: .16s; border: none; background: none; }
|
||||
.railToggle:hover { color: var(--tx); background: var(--hair); }
|
||||
.railToggle svg { width: 18px; height: 18px; }
|
||||
.app:not(.railOpen) .railToggle { display: none; }
|
||||
.navbtn { height: 40px; border-radius: 10px; color: var(--tx-3); cursor: pointer; position: relative; transition: .16s; display: flex; align-items: center; gap: 12px; padding: 0; justify-content: flex-start; width: 100%; background: none; border: none; }
|
||||
.app.railOpen .navbtn { padding: 0 11px; }
|
||||
.navbtn .ico { width: 36px; flex: 0 0 36px; display: grid; place-items: center; }
|
||||
.app.railOpen .navbtn .ico { width: 20px; flex: 0 0 20px; }
|
||||
.navbtn .lbl { font-size: 13.5px; font-weight: 500; white-space: nowrap; opacity: 0; transition: opacity .16s; pointer-events: none; }
|
||||
.app.railOpen .navbtn .lbl { opacity: 1; transition: opacity .18s .08s; }
|
||||
.navbtn:hover { color: var(--tx-2); background: var(--hair); }
|
||||
.navbtn.active { color: var(--accent-2); background: var(--accent-soft); }
|
||||
.navbtn.active::before { content: ""; position: absolute; left: -8px; top: 50%; transform: translateY(-50%); width: 3px; height: 18px; border-radius: 0 3px 3px 0; background: var(--accent-2); }
|
||||
.navbtn svg { width: 20px; height: 20px; }
|
||||
.spacer { flex: 1; }
|
||||
|
||||
/* ===== MAIN ===== */
|
||||
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
||||
.topbar { height: 56px; flex: 0 0 56px; border-bottom: 1px solid var(--hair); background: var(--panel); display: flex; align-items: center; justify-content: space-between; padding: 0 18px 0 20px; }
|
||||
.org { display: flex; align-items: center; gap: 10px; cursor: pointer; padding: 6px 10px; border-radius: 9px; transition: .16s; margin-left: -6px; }
|
||||
.org:hover { background: var(--hair); }
|
||||
.orgBadge { width: 24px; height: 24px; border-radius: 7px; display: grid; place-items: center; background: linear-gradient(150deg,#2d2d36,#3a3a46); font-size: 12px; font-weight: 700; color: #d8d8e2; border: 1px solid var(--hair-2); }
|
||||
:global([data-theme="light"]) .orgBadge { background: linear-gradient(150deg,#7c3aed,#a78bfa); color: #fff; border: none; }
|
||||
.orgName { font-weight: 600; font-size: 14.5px; letter-spacing: -.01em; }
|
||||
.chev { color: var(--tx-3); display: flex; }
|
||||
.chev svg { width: 15px; height: 15px; }
|
||||
.topbarRight { display: flex; align-items: center; gap: 10px; }
|
||||
.iconPill { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; color: var(--tx-3); cursor: pointer; transition: .16s; border: none; background: none; }
|
||||
.iconPill:hover { color: var(--tx-2); background: var(--hair); }
|
||||
.iconPill svg { width: 18px; height: 18px; }
|
||||
.themeToggle { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center; color: var(--tx-2); cursor: pointer; transition: .16s; border: 1px solid var(--hair); background: none; }
|
||||
.themeToggle:hover { background: var(--hair); color: var(--tx); }
|
||||
.themeToggle svg { width: 17px; height: 17px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: linear-gradient(150deg,#f0a36b,#e8638a); display: grid; place-items: center; font-weight: 700; font-size: 12.5px; color: #1a0d12; cursor: pointer; border: 1px solid rgba(255,255,255,.16); box-shadow: 0 2px 8px rgba(0,0,0,.3); margin-left: 4px; }
|
||||
|
||||
/* ===== VIEWS ===== */
|
||||
.viewArea { flex: 1; min-height: 0; position: relative; }
|
||||
.view { position: absolute; inset: 0; display: none; }
|
||||
.view.active { display: flex; }
|
||||
/* A transform turns this into the containing block for its position:fixed
|
||||
descendants — so the canvas's own overlays (Toolbar, Legend, Communications,
|
||||
New Workspace, minimap) anchor to THIS box (the map view area, right of the
|
||||
rail and below the topbar) instead of the viewport, and stop overlapping the
|
||||
shell chrome. */
|
||||
.canvasMount { position: absolute; inset: 0; transform: translateZ(0); overflow: hidden; }
|
||||
|
||||
/* ===== HOME VIEW ===== */
|
||||
.homeSidebar { flex: 0 0 296px; max-width: 296px; background: var(--panel-2); border-right: 1px solid var(--hair); display: flex; flex-direction: column; min-height: 0; }
|
||||
.sbTabs { display: flex; gap: 2px; padding: 12px 12px 0; border-bottom: 1px solid var(--hair); }
|
||||
.sbTab { flex: 1; text-align: center; padding: 9px 4px 11px; font-size: 12.5px; font-weight: 600; color: var(--tx-3); cursor: pointer; position: relative; transition: .14s; border-radius: 8px 8px 0 0; border: none; background: none; }
|
||||
.sbTab:hover { color: var(--tx-2); }
|
||||
.sbTab.active { color: var(--tx); }
|
||||
.sbTab.active::after { content: ""; position: absolute; left: 8px; right: 8px; bottom: -1px; height: 2px; border-radius: 2px; background: var(--accent); }
|
||||
.cnt { font-family: var(--mono); font-size: 10px; font-weight: 600; margin-left: 5px; background: var(--hair); color: var(--tx-2); padding: 1px 5px; border-radius: 10px; }
|
||||
.sbTab.active .cnt { background: var(--accent-soft); color: var(--accent-2); }
|
||||
.sbBody { flex: 1; overflow-y: auto; padding: 14px 12px; }
|
||||
|
||||
.wsList { display: flex; flex-direction: column; gap: 6px; }
|
||||
.treeChildren { position: relative; padding-left: 22px; display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
|
||||
.tnode { position: relative; display: flex; flex-direction: column; gap: 6px; }
|
||||
.tnode::before { content: ""; position: absolute; left: -14px; top: -6px; width: 1.5px; height: calc(100% + 6px); background: var(--hair-2); }
|
||||
.tnode.last::before { height: 33px; }
|
||||
.tnode::after { content: ""; position: absolute; left: -14px; top: 27px; width: 14px; height: 1.5px; background: var(--hair-2); }
|
||||
.ws { display: flex; align-items: center; gap: 11px; padding: 10px 11px; border-radius: 13px; cursor: pointer; border: 1px solid transparent; background: transparent; transition: .16s; position: relative; width: 100%; text-align: left; }
|
||||
.ws:hover { background: var(--card); }
|
||||
.ws.active { background: var(--accent-soft); border-color: rgba(139,92,246,.34); }
|
||||
.wsAv { width: 34px; height: 34px; border-radius: 50%; flex: 0 0 34px; position: relative; display: grid; place-items: center; font-weight: 700; font-size: 12px; color: #0c0c10; box-shadow: inset 0 1px 0 rgba(255,255,255,.3); }
|
||||
.wsAv .dot { position: absolute; right: -1px; bottom: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2.5px solid var(--panel-2); }
|
||||
.ws.active .wsAv .dot { border-color: var(--card); }
|
||||
.wsMeta { min-width: 0; flex: 1; }
|
||||
.wsName { font-weight: 600; font-size: 13.5px; letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.wsSub { display: flex; align-items: center; gap: 6px; margin-top: 1px; }
|
||||
.wsRole { font-family: var(--mono); font-size: 10.5px; color: var(--tx-3); }
|
||||
.wsStatus { font-size: 10.5px; font-weight: 500; display: flex; align-items: center; gap: 4px; }
|
||||
.wsStatus .sdot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.rootTag { margin-left: auto; font-family: var(--mono); font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--accent-2); background: var(--accent-soft); padding: 3px 6px; border-radius: 6px; border: 1px solid rgba(139,92,246,.28); }
|
||||
.wsQ { margin-left: auto; flex: 0 0 auto; font-family: var(--mono); font-size: 10px; font-weight: 700; color: var(--tx-2); background: var(--hair); border: 1px solid var(--hair-2); padding: 2px 7px; border-radius: 20px; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.wsQ svg { width: 9px; height: 9px; color: var(--tx-3); }
|
||||
.wsQ.zero { color: var(--tx-3); opacity: .65; }
|
||||
.wsCaret { flex: 0 0 auto; width: 20px; height: 20px; margin-left: 4px; border: none; background: none; color: var(--tx-3); cursor: pointer; display: grid; place-items: center; border-radius: 6px; transition: .14s; }
|
||||
.wsCaret:hover { background: var(--hair); color: var(--tx); }
|
||||
.wsCaret svg { width: 13px; height: 13px; }
|
||||
|
||||
.sbSection { font-size: 11px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; color: var(--tx-3); font-family: var(--mono); padding: 18px 4px 10px; }
|
||||
|
||||
/* tasks */
|
||||
.task { display: flex; flex-direction: column; align-items: stretch; gap: 0; padding: 11px; border-radius: 12px; border: 1px solid var(--hair); background: var(--card); margin-bottom: 7px; }
|
||||
.taskRow { display: flex; gap: 11px; }
|
||||
.taskIc { width: 28px; height: 28px; border-radius: 8px; flex: 0 0 28px; display: grid; place-items: center; }
|
||||
.taskIc svg { width: 15px; height: 15px; }
|
||||
.taskIc.done { background: var(--green-soft); color: var(--green); border: 1px solid var(--green-bd); }
|
||||
.taskIc.run { background: rgba(245,166,35,.12); color: var(--amber); border: 1px solid rgba(245,166,35,.28); }
|
||||
.taskIc.sched { background: var(--accent-soft); color: var(--accent-2); border: 1px solid rgba(139,92,246,.26); }
|
||||
.taskMeta { flex: 1; min-width: 0; }
|
||||
.taskT { font-size: 13px; font-weight: 600; letter-spacing: -.01em; line-height: 1.35; }
|
||||
.taskS { font-size: 11px; color: var(--tx-3); margin-top: 3px; display: flex; align-items: center; gap: 6px; }
|
||||
.taskS .pip { width: 4px; height: 4px; border-radius: 50%; background: var(--tx-3); }
|
||||
.taskActions { display: flex; gap: 7px; margin-top: 11px; padding-left: 39px; }
|
||||
.tbtn { font-family: var(--sans); font-size: 11.5px; font-weight: 600; cursor: pointer; padding: 5px 12px; border-radius: 8px; border: 1px solid var(--hair-2); background: var(--card-2); color: var(--tx-2); transition: .14s; display: inline-flex; align-items: center; gap: 5px; }
|
||||
.tbtn svg { width: 13px; height: 13px; }
|
||||
.tbtn:hover { background: var(--card-hover); color: var(--tx); }
|
||||
.tbtn.done { background: var(--green-soft); color: var(--green); border-color: var(--green-bd); }
|
||||
.task.isDone .taskT { color: var(--tx-2); }
|
||||
|
||||
/* activity */
|
||||
.act { display: flex; gap: 11px; padding: 6px 4px; }
|
||||
.actTime { font-family: var(--mono); font-size: 10.5px; color: var(--tx-3); flex: 0 0 52px; padding-top: 1px; font-variant-numeric: tabular-nums; }
|
||||
.actLine { position: relative; padding-left: 15px; flex: 1; }
|
||||
.actLine::before { content: ""; position: absolute; left: 0; top: 6px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
||||
.actLine.grn::before { background: var(--green); }
|
||||
.actText { font-size: 12px; color: var(--tx-2); line-height: 1.45; }
|
||||
.actText b { color: var(--tx); font-weight: 600; }
|
||||
|
||||
/* approvals */
|
||||
.apprCard { background: var(--card); border: 1px solid var(--hair); border-radius: 14px; overflow: hidden; }
|
||||
.apprRow { display: flex; align-items: flex-start; gap: 11px; padding: 13px; }
|
||||
.apprIc { width: 30px; height: 30px; border-radius: 8px; flex: 0 0 30px; display: grid; place-items: center; background: rgba(239,68,68,.12); color: var(--red); border: 1px solid rgba(239,68,68,.22); }
|
||||
.apprIc svg { width: 15px; height: 15px; }
|
||||
.apprMeta { flex: 1; min-width: 0; }
|
||||
.apprT { font-size: 13px; font-weight: 600; letter-spacing: -.01em; line-height: 1.35; }
|
||||
.apprT code { font-family: var(--mono); font-size: 11px; color: var(--tx-2); background: var(--hair); padding: 1px 5px; border-radius: 5px; font-weight: 500; }
|
||||
.apprS { font-size: 11px; color: var(--tx-3); margin-top: 3px; }
|
||||
.apprActions { display: flex; gap: 7px; padding: 0 13px 13px; }
|
||||
.empty { text-align: center; color: var(--tx-3); font-size: 12.5px; padding: 30px 16px; line-height: 1.6; }
|
||||
.empty svg { width: 30px; height: 30px; margin-bottom: 10px; color: var(--tx-3); opacity: .6; }
|
||||
|
||||
/* buttons */
|
||||
.btn { font-family: var(--sans); font-size: 12px; font-weight: 600; cursor: pointer; padding: 6px 13px; border-radius: 8px; border: 1px solid var(--hair-2); background: var(--card-2); color: var(--tx-2); transition: .14s; white-space: nowrap; }
|
||||
.btn:hover { background: var(--card-hover); color: var(--tx); }
|
||||
.btn.approve { background: var(--accent); color: #fff; border-color: transparent; box-shadow: 0 2px 10px rgba(124,58,237,.4); }
|
||||
.btn.approve:hover { background: #9d6ef8; }
|
||||
.btn.deny:hover { background: rgba(239,68,68,.14); color: var(--red); border-color: rgba(239,68,68,.3); }
|
||||
.btn.flex { flex: 1; text-align: center; }
|
||||
|
||||
/* ===== CHAT ===== */
|
||||
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; background: var(--bg); }
|
||||
.chatHead { height: 56px; flex: 0 0 56px; border-bottom: 1px solid var(--hair); display: flex; align-items: center; gap: 12px; padding: 0 22px; background: var(--panel-2); }
|
||||
.chAv { width: 30px; height: 30px; border-radius: 9px; display: grid; place-items: center; background: linear-gradient(150deg,#7c3aed,#a78bfa); color: #fff; box-shadow: 0 2px 8px rgba(124,58,237,.4); }
|
||||
.chAv svg { width: 16px; height: 16px; }
|
||||
.chMeta { flex: 1; }
|
||||
.chTitle { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; }
|
||||
.chSub { font-size: 11.5px; color: var(--tx-3); display: flex; align-items: center; gap: 6px; margin-top: 1px; }
|
||||
.chSub .sdot { width: 6px; height: 6px; border-radius: 50%; background: var(--green); }
|
||||
.chTools { display: flex; gap: 6px; }
|
||||
.chatScroll { flex: 1; overflow-y: auto; padding: 30px 0; }
|
||||
.chatInner { max-width: 720px; margin: 0 auto; padding: 0 28px; display: flex; flex-direction: column; gap: 22px; }
|
||||
.msg { display: flex; gap: 13px; max-width: 100%; }
|
||||
.msg.user { flex-direction: row-reverse; }
|
||||
.msgAv { width: 30px; height: 30px; border-radius: 9px; flex: 0 0 30px; display: grid; place-items: center; font-weight: 700; font-size: 12px; }
|
||||
.msg.user .msgAv { background: linear-gradient(150deg,#f0a36b,#e8638a); color: #1a0d12; }
|
||||
.msg.bot .msgAv { background: linear-gradient(150deg,#7c3aed,#a78bfa); color: #fff; }
|
||||
.msg.bot .msgAv svg { width: 16px; height: 16px; }
|
||||
.bubbleWrap { display: flex; flex-direction: column; gap: 11px; min-width: 0; max-width: 560px; }
|
||||
.msg.user .bubbleWrap { align-items: flex-end; }
|
||||
.bubble { padding: 12px 15px; border-radius: 15px; font-size: 14px; line-height: 1.55; letter-spacing: -.005em; }
|
||||
.msg.user .bubble { background: var(--accent); color: var(--user-bubble-tx); border-bottom-right-radius: 5px; box-shadow: 0 3px 14px rgba(124,58,237,.3); }
|
||||
.msg.bot .bubble { background: var(--card); border: 1px solid var(--hair); border-bottom-left-radius: 5px; color: var(--tx); }
|
||||
.bubble b { font-weight: 600; }
|
||||
.actionCard { background: var(--card); border: 1px solid var(--hair); border-radius: 14px; padding: 13px 15px; display: flex; align-items: center; gap: 13px; width: 100%; }
|
||||
.acIc { width: 34px; height: 34px; border-radius: 10px; flex: 0 0 34px; display: grid; place-items: center; background: var(--green-soft); border: 1px solid var(--green-bd); color: var(--green); }
|
||||
.acIc svg { width: 18px; height: 18px; }
|
||||
.acMeta { flex: 1; min-width: 0; }
|
||||
.acLabel { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--tx-3); margin-bottom: 3px; }
|
||||
.acTitle { font-size: 13.5px; font-weight: 600; letter-spacing: -.01em; display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
.acTitle .pill { font-family: var(--mono); font-size: 11px; font-weight: 500; color: var(--accent-2); white-space: nowrap; background: var(--accent-soft); padding: 2px 8px; border-radius: 6px; border: 1px solid rgba(139,92,246,.24); }
|
||||
.acCheck { color: var(--green); display: flex; }
|
||||
.acCheck svg { width: 18px; height: 18px; }
|
||||
.reqCard { background: linear-gradient(180deg,rgba(245,166,35,.08),rgba(245,166,35,.02)); border: 1px solid rgba(245,166,35,.3); border-radius: 16px; padding: 16px; width: 100%; }
|
||||
.reqTop { display: flex; align-items: flex-start; gap: 13px; }
|
||||
.reqIc { width: 36px; height: 36px; border-radius: 10px; flex: 0 0 36px; display: grid; place-items: center; background: rgba(245,166,35,.15); border: 1px solid rgba(245,166,35,.34); color: var(--warn); }
|
||||
.reqIc svg { width: 19px; height: 19px; }
|
||||
.reqMeta { flex: 1; }
|
||||
.reqLabel { font-family: var(--mono); font-size: 10px; letter-spacing: .1em; text-transform: uppercase; color: var(--warn); margin-bottom: 4px; font-weight: 600; }
|
||||
.reqTitle { font-size: 14.5px; font-weight: 600; letter-spacing: -.01em; line-height: 1.4; }
|
||||
.reqTitle code { font-family: var(--mono); font-size: 12.5px; color: var(--amber); background: rgba(245,166,35,.12); padding: 1px 6px; border-radius: 5px; font-weight: 500; }
|
||||
.reqDesc { font-size: 12.5px; color: var(--tx-2); margin-top: 6px; line-height: 1.5; }
|
||||
.reqActions { display: flex; gap: 9px; margin-top: 14px; padding-left: 49px; }
|
||||
.reqActions .btn { padding: 8px 18px; font-size: 12.5px; }
|
||||
|
||||
.composer { padding: 14px 28px 20px; border-top: 1px solid var(--hair); background: var(--panel-2); }
|
||||
.composerInner { max-width: 720px; margin: 0 auto; }
|
||||
.inputBox { background: var(--card); border: 1px solid var(--hair-2); border-radius: 16px; padding: 12px 12px 10px 16px; transition: .16s; }
|
||||
.inputBox:focus-within { border-color: rgba(139,92,246,.5); box-shadow: 0 0 0 3px rgba(139,92,246,.12); }
|
||||
.inputTop { display: flex; align-items: flex-end; gap: 10px; }
|
||||
.msgInput { flex: 1; background: none; border: none; outline: none; color: var(--tx); font-family: var(--sans); font-size: 14px; line-height: 1.5; resize: none; max-height: 120px; padding: 5px 0; }
|
||||
.msgInput::placeholder { color: var(--tx-3); }
|
||||
.send { width: 36px; height: 36px; flex: 0 0 36px; border-radius: 11px; border: none; cursor: pointer; background: var(--accent); color: #fff; display: grid; place-items: center; transition: .16s; box-shadow: 0 2px 10px rgba(124,58,237,.4); }
|
||||
.send:hover { background: #9d6ef8; transform: translateY(-1px); }
|
||||
.send svg { width: 17px; height: 17px; }
|
||||
.inputBottom { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
|
||||
.hint { margin-left: auto; font-size: 11px; color: var(--tx-3); font-family: var(--mono); }
|
||||
.hint kbd { background: var(--hair); border: 1px solid var(--hair); border-radius: 4px; padding: 1px 5px; font-family: var(--mono); font-size: 10px; }
|
||||
|
||||
/* greeting (empty chat state) */
|
||||
.greetWrap { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 26px; padding: 0 28px; }
|
||||
.greet { display: flex; align-items: center; gap: 14px; font-size: 34px; font-weight: 400; letter-spacing: -.02em; color: var(--tx); }
|
||||
.greet .stamp { color: #f0a36b; }
|
||||
.greetChips { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
||||
.chip { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; font-weight: 600; color: var(--tx-2); background: var(--card); border: 1px solid var(--hair); padding: 8px 13px; border-radius: 10px; cursor: pointer; transition: .14s; }
|
||||
.chip:hover { background: var(--card-hover); color: var(--tx); border-color: var(--hair-2); }
|
||||
|
||||
/* placeholder (settings) */
|
||||
.ph { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; color: var(--tx-3); text-align: center; }
|
||||
.ph svg { width: 42px; height: 42px; opacity: .5; }
|
||||
.ph h2 { font-size: 18px; font-weight: 600; color: var(--tx-2); }
|
||||
.ph p { font-size: 13.5px; max-width: 340px; line-height: 1.55; }
|
||||
|
||||
/* settings view */
|
||||
.settingsScroll { flex: 1; min-height: 0; overflow-y: auto; padding: 28px 32px 60px; }
|
||||
.settingsInner { max-width: 720px; margin: 0 auto; display: flex; flex-direction: column; gap: 26px; }
|
||||
.settingsHead { display: flex; flex-direction: column; gap: 5px; }
|
||||
.settingsHead h1 { font-size: 21px; font-weight: 600; letter-spacing: -.01em; color: var(--tx); }
|
||||
.settingsHead p { font-size: 13px; color: var(--tx-3); line-height: 1.55; max-width: 540px; }
|
||||
|
||||
.scard { background: var(--card); border: 1px solid var(--hair); border-radius: 14px; padding: 18px 20px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.scardHead { display: flex; flex-direction: column; gap: 4px; }
|
||||
.scardTitle { font-size: 14.5px; font-weight: 600; color: var(--tx); display: flex; align-items: center; gap: 9px; }
|
||||
.scardDesc { font-size: 12.5px; color: var(--tx-3); line-height: 1.5; }
|
||||
|
||||
/* billing radio options */
|
||||
.optList { display: flex; flex-direction: column; gap: 10px; }
|
||||
.opt { display: flex; gap: 12px; padding: 13px 14px; border: 1px solid var(--hair); border-radius: 11px; cursor: pointer; transition: .14s; background: var(--card-2); align-items: flex-start; }
|
||||
.opt:hover { border-color: var(--hair-2); background: var(--card-hover); }
|
||||
.opt.optActive { border-color: rgba(139,92,246,.5); background: var(--accent-soft); }
|
||||
.optRadio { width: 16px; height: 16px; flex: 0 0 16px; border-radius: 50%; border: 2px solid var(--hair-2); margin-top: 2px; position: relative; transition: .14s; }
|
||||
.opt.optActive .optRadio { border-color: var(--accent); }
|
||||
.opt.optActive .optRadio::after { content: ""; position: absolute; inset: 2px; border-radius: 50%; background: var(--accent); }
|
||||
.optBody { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||||
.optTitle { font-size: 13px; font-weight: 600; color: var(--tx); display: flex; align-items: center; gap: 8px; }
|
||||
.optDesc { font-size: 12px; color: var(--tx-3); line-height: 1.5; }
|
||||
.optTag { font-family: var(--mono); font-size: 9.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--green); background: var(--green-soft); border: 1px solid var(--green-bd); padding: 1px 7px; border-radius: 20px; }
|
||||
.optTagCur { color: var(--accent-2); background: var(--accent-soft); border-color: rgba(139,92,246,.3); }
|
||||
|
||||
/* byok key entry */
|
||||
.keyRow { display: flex; flex-direction: column; gap: 9px; padding: 14px; border: 1px solid var(--hair); border-radius: 11px; background: var(--card-2); }
|
||||
.keyLabel { font-size: 11px; font-weight: 600; letter-spacing: .04em; color: var(--tx-2); font-family: var(--mono); }
|
||||
.keyInputRow { display: flex; gap: 9px; }
|
||||
.keyInput { flex: 1; min-width: 0; background: var(--panel); border: 1px solid var(--hair-2); border-radius: 8px; padding: 8px 11px; font-family: var(--mono); font-size: 12px; color: var(--tx); outline: none; transition: .14s; }
|
||||
.keyInput:focus { border-color: var(--accent); }
|
||||
.keyInput::placeholder { color: var(--tx-3); }
|
||||
.keyNote { font-size: 11.5px; color: var(--tx-3); line-height: 1.5; }
|
||||
.keyNote code { font-family: var(--mono); font-size: 11px; color: var(--tx-2); background: var(--hair); padding: 1px 5px; border-radius: 4px; }
|
||||
|
||||
.sMsg { font-size: 12px; padding: 8px 11px; border-radius: 8px; line-height: 1.45; }
|
||||
.sMsgErr { color: var(--red); background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.28); }
|
||||
.sMsgOk { color: var(--green); background: var(--green-soft); border: 1px solid var(--green-bd); }
|
||||
|
||||
.btn.primary { background: var(--accent); color: #fff; border-color: transparent; box-shadow: 0 2px 10px rgba(124,58,237,.4); }
|
||||
.btn.primary:hover { background: #9d6ef8; }
|
||||
.btn.primary:disabled { opacity: .4; cursor: default; box-shadow: none; }
|
||||
|
||||
/* embedded canvas settings tabs */
|
||||
.embedSettings { border: 1px solid var(--hair); border-radius: 14px; overflow: hidden; background: var(--card); }
|
||||
|
||||
/* embedded full workspace tab panel (the SAME WorkspacePanelTabs the Org-map
|
||||
SidePanel renders), pointed at the platform agent. A bordered card with a
|
||||
bounded height + flex column so the tab body's own overflow-y scroller works
|
||||
inside it (mirrors .embedChat's min-height:0 trick). */
|
||||
.embedPanel {
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
background: var(--card);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 70vh;
|
||||
max-height: 760px;
|
||||
}
|
||||
|
||||
/* embedded canonical ChatTab (shared with the Org-map SidePanel).
|
||||
Fills the chat column below the concierge header; min-height:0 lets the
|
||||
ChatTab's own overflow-y scroller work inside the flex column. */
|
||||
.embedChat { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
@@ -0,0 +1,596 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCanvasStore, type TopView } from "@/store/canvas";
|
||||
import { WORKSPACE_KIND } from "@/lib/workspace-kind";
|
||||
import { useTheme } from "@/lib/theme-provider";
|
||||
import { api } from "@/lib/api";
|
||||
import { showToast } from "@/components/Toaster";
|
||||
import type { ActivityEntry } from "@/types/activity";
|
||||
import { Canvas } from "@/components/Canvas";
|
||||
import { CommunicationOverlay } from "@/components/CommunicationOverlay";
|
||||
import { ChatTab } from "@/components/tabs/ChatTab";
|
||||
import { WorkspacePanelTabs } from "@/components/WorkspacePanelTabs";
|
||||
import { SettingsTabs } from "@/components/settings";
|
||||
import s from "./Concierge.module.css";
|
||||
import {
|
||||
IcHome, IcOrgMap, IcSettings, IcSearch, IcBell, IcSun, IcMoon, IcChevDown,
|
||||
IcQueue, IcCaret, IcMolecule, IcClock, IcCheck, IcTrash, IcChat,
|
||||
} from "./icons";
|
||||
|
||||
/* ── status → concept palette ─────────────────────────────────────────── */
|
||||
function statusInfo(status: string): { color: string; label: string } {
|
||||
switch (status) {
|
||||
case "online": return { color: "var(--green)", label: "online" };
|
||||
case "provisioning":
|
||||
case "starting": return { color: "var(--amber)", label: "starting" };
|
||||
case "degraded": return { color: "var(--amber)", label: "degraded" };
|
||||
case "building": return { color: "var(--amber)", label: "building" };
|
||||
case "failed": return { color: "var(--red)", label: "failed" };
|
||||
case "paused": return { color: "var(--accent-2)", label: "paused" };
|
||||
default: return { color: "var(--grey)", label: status || "idle" };
|
||||
}
|
||||
}
|
||||
|
||||
const AV_GRADIENTS = [
|
||||
"linear-gradient(150deg,#a78bfa,#7c3aed)",
|
||||
"linear-gradient(150deg,#60a5fa,#3b82f6)",
|
||||
"linear-gradient(150deg,#34d399,#10b981)",
|
||||
"linear-gradient(150deg,#fbbf77,#f59e0b)",
|
||||
"linear-gradient(150deg,#5eead4,#14b8a6)",
|
||||
"linear-gradient(150deg,#f0a36b,#e8638a)",
|
||||
];
|
||||
function initials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) return "?";
|
||||
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
||||
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
|
||||
}
|
||||
function gradientFor(id: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||
return AV_GRADIENTS[h % AV_GRADIENTS.length];
|
||||
}
|
||||
|
||||
type SbTab = "agents" | "tasks" | "approvals";
|
||||
|
||||
interface PendingApproval {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
workspace_name: string;
|
||||
action: string;
|
||||
reason: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
interface UserTask {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
workspace_name: string;
|
||||
title: string;
|
||||
detail: string | null;
|
||||
status: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** ISO timestamp → "9:05 PM" (local). Empty string on a bad/missing value. */
|
||||
function clockTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** A human action label from an activity row. */
|
||||
function activityText(a: ActivityEntry): string {
|
||||
if (a.summary) return a.summary;
|
||||
const verb = a.activity_type?.replace(/_/g, " ") ?? "activity";
|
||||
return a.method ? `${verb} · ${a.method}` : verb;
|
||||
}
|
||||
|
||||
export function ConciergeShell() {
|
||||
const nodes = useCanvasStore((st) => st.nodes);
|
||||
const topView = useCanvasStore((st) => st.topView);
|
||||
const setTopView = useCanvasStore((st) => st.setTopView);
|
||||
const selectNode = useCanvasStore((st) => st.selectNode);
|
||||
const selectedNodeId = useCanvasStore((st) => st.selectedNodeId);
|
||||
const { resolvedTheme, setTheme } = useTheme();
|
||||
|
||||
const [railOpen, setRailOpen] = useState(false);
|
||||
const [sbTab, setSbTab] = useState<SbTab>("agents");
|
||||
const [settingsTab, setSettingsTab] = useState<"platform" | "org">("platform");
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Dynamic org name for the topbar. Sourced from GET /org/identity
|
||||
// ({name} ← MOLECULE_ORG_NAME, added by a parallel backend change).
|
||||
// Falls back to "Molecule AI" when the endpoint 404s / errors or
|
||||
// returns an empty name, so the topbar never breaks before the backend
|
||||
// lands.
|
||||
const [orgName, setOrgName] = useState("Molecule AI");
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.get<{ name?: string }>("/org/identity")
|
||||
.then((r) => {
|
||||
const name = (r?.name || "").trim();
|
||||
if (!cancelled && name) setOrgName(name);
|
||||
})
|
||||
.catch(() => {
|
||||
// No endpoint / not reachable — keep the "Molecule AI" fallback.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Build the agent hierarchy from live nodes.
|
||||
const { roots, childrenOf } = useMemo(() => {
|
||||
const childrenOf = new Map<string, typeof nodes>();
|
||||
const roots: typeof nodes = [];
|
||||
for (const n of nodes) {
|
||||
const p = n.data.parentId;
|
||||
if (p) {
|
||||
const arr = childrenOf.get(p) ?? [];
|
||||
arr.push(n);
|
||||
childrenOf.set(p, arr);
|
||||
} else {
|
||||
roots.push(n);
|
||||
}
|
||||
}
|
||||
return { roots, childrenOf };
|
||||
}, [nodes]);
|
||||
|
||||
const platformRoot = useMemo(
|
||||
() =>
|
||||
// Resolve the platform agent by the authoritative kind='platform' marker
|
||||
// only — the backend in this branch always returns kind
|
||||
// (COALESCE(w.kind,'workspace')) and the map-side filter
|
||||
// (canvas-topology/Canvas/Toolbar) is kind-only, so the shell must not
|
||||
// disagree via a name/role heuristic. Fall back to the first root only as
|
||||
// graceful degradation if no node is tagged platform.
|
||||
roots.find((r) => r.data.kind === WORKSPACE_KIND.Platform) ??
|
||||
roots[0] ??
|
||||
null,
|
||||
[roots],
|
||||
);
|
||||
|
||||
const platformId = platformRoot?.id ?? null;
|
||||
|
||||
// ── live data: approvals + user-tasks (org-wide), activity (platform agent) ──
|
||||
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
|
||||
const [userTasks, setUserTasks] = useState<UserTask[]>([]);
|
||||
const [activity, setActivity] = useState<ActivityEntry[]>([]);
|
||||
const [deciding, setDeciding] = useState<string | null>(null);
|
||||
const [resolving, setResolving] = useState<string | null>(null);
|
||||
|
||||
const loadApprovals = useCallback(() => {
|
||||
api.get<PendingApproval[]>("/approvals/pending")
|
||||
.then((r) => setApprovals(r ?? []))
|
||||
.catch(() => setApprovals([]));
|
||||
}, []);
|
||||
const loadUserTasks = useCallback(() => {
|
||||
api.get<UserTask[]>("/user-tasks/pending")
|
||||
.then((r) => setUserTasks(r ?? []))
|
||||
.catch(() => setUserTasks([]));
|
||||
}, []);
|
||||
useEffect(() => { loadApprovals(); loadUserTasks(); }, [loadApprovals, loadUserTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!platformId) return;
|
||||
let cancelled = false;
|
||||
api.get<ActivityEntry[]>(`/workspaces/${platformId}/activity?limit=12`)
|
||||
.then((r) => { if (!cancelled) setActivity(r ?? []); })
|
||||
.catch(() => { if (!cancelled) setActivity([]); });
|
||||
return () => { cancelled = true; };
|
||||
}, [platformId]);
|
||||
|
||||
const decide = useCallback(async (a: PendingApproval, decision: "approved" | "denied") => {
|
||||
if (deciding) return;
|
||||
setDeciding(a.id);
|
||||
try {
|
||||
await api.post(`/workspaces/${a.workspace_id}/approvals/${a.id}/decide`, {
|
||||
decision, decided_by: "human",
|
||||
});
|
||||
showToast(decision === "approved" ? "Approved" : "Denied", decision === "approved" ? "success" : "info");
|
||||
setApprovals((prev) => prev.filter((x) => x.id !== a.id));
|
||||
} catch {
|
||||
showToast("Failed to record decision", "error");
|
||||
} finally {
|
||||
setDeciding(null);
|
||||
}
|
||||
}, [deciding]);
|
||||
|
||||
const resolveTask = useCallback(async (t: UserTask, status: "done" | "dismissed") => {
|
||||
if (resolving) return;
|
||||
setResolving(t.id);
|
||||
try {
|
||||
await api.post(`/workspaces/${t.workspace_id}/user-tasks/${t.id}/resolve`, {
|
||||
status, resolved_by: "human",
|
||||
});
|
||||
showToast(status === "done" ? "Marked done" : "Dismissed", status === "done" ? "success" : "info");
|
||||
setUserTasks((prev) => prev.filter((x) => x.id !== t.id));
|
||||
} catch {
|
||||
showToast("Failed to resolve task", "error");
|
||||
} finally {
|
||||
setResolving(null);
|
||||
}
|
||||
}, [resolving]);
|
||||
|
||||
const nav = (v: TopView) => setTopView(v);
|
||||
|
||||
/* ── agents tree (recursive) ──────────────────────────────────────── */
|
||||
function renderNode(n: (typeof nodes)[number], depth: number) {
|
||||
const kids = childrenOf.get(n.id) ?? [];
|
||||
const hasKids = kids.length > 0;
|
||||
const isCollapsed = collapsed[n.id];
|
||||
const st = statusInfo(n.data.status);
|
||||
const isRoot = depth === 0;
|
||||
const isPlatform = n.id === platformRoot?.id;
|
||||
const q = (n.data.activeTasks as number) ?? 0;
|
||||
const row = (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="agent-tree-node"
|
||||
data-node-name={n.data.name}
|
||||
data-platform={isPlatform ? "true" : "false"}
|
||||
data-depth={depth}
|
||||
className={`${s.ws} ${selectedNodeId === n.id ? s.active : ""}`}
|
||||
onClick={() => selectNode(n.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
selectNode(n.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={s.wsAv} style={{ background: gradientFor(n.id) }}>
|
||||
{initials(n.data.name)}
|
||||
<span className={s.dot} style={{ background: st.color }} />
|
||||
</div>
|
||||
<div className={s.wsMeta}>
|
||||
<div className={s.wsName}>{n.data.name}</div>
|
||||
<div className={s.wsSub}>
|
||||
<span className={s.wsRole}>{isPlatform ? "platform" : n.data.role || "agent"}</span>
|
||||
<span className={s.wsStatus} style={{ color: st.color }}>
|
||||
<span className={s.sdot} style={{ background: st.color }} />
|
||||
{st.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{isRoot && isPlatform ? (
|
||||
<span data-testid="agent-tree-root-tag" className={s.rootTag}>root</span>
|
||||
) : (
|
||||
<span className={`${s.wsQ} ${q === 0 ? s.zero : ""}`} title="Tasks in queue">
|
||||
<IcQueue />
|
||||
{q}
|
||||
</span>
|
||||
)}
|
||||
{hasKids && (
|
||||
<button
|
||||
className={s.wsCaret}
|
||||
title="Expand / collapse"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollapsed((c) => ({ ...c, [n.id]: !c[n.id] }));
|
||||
}}
|
||||
style={{ transform: isCollapsed ? "none" : "rotate(90deg)", transition: "transform .18s" }}
|
||||
>
|
||||
<IcCaret />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div key={n.id} className={s.tnode}>
|
||||
{row}
|
||||
{hasKids && !isCollapsed && (
|
||||
<div className={s.treeChildren}>
|
||||
{kids.map((k) => renderNode(k, depth + 1))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={`${s.app} ${railOpen ? s.railOpen : ""}`}>
|
||||
{/* ICON RAIL */}
|
||||
<nav className={s.rail}>
|
||||
<div className={s.railTop}>
|
||||
<div className={s.logo} title="Toggle sidebar" onClick={() => setRailOpen((o) => !o)}>
|
||||
<IcMolecule />
|
||||
</div>
|
||||
<span className={s.railWordmark}>Molecule</span>
|
||||
<button className={s.railToggle} title="Collapse sidebar" onClick={() => setRailOpen((o) => !o)}>
|
||||
<IcOrgMap />
|
||||
</button>
|
||||
</div>
|
||||
<button data-testid="nav-home" className={`${s.navbtn} ${topView === "home" ? s.active : ""}`} title="Home" onClick={() => nav("home")}>
|
||||
<span className={s.ico}><IcHome /></span><span className={s.lbl}>Home</span>
|
||||
</button>
|
||||
<button data-testid="nav-map" className={`${s.navbtn} ${topView === "map" ? s.active : ""}`} title="Org map" onClick={() => nav("map")}>
|
||||
<span className={s.ico}><IcOrgMap /></span><span className={s.lbl}>Org map</span>
|
||||
</button>
|
||||
<div className={s.spacer} />
|
||||
<button data-testid="nav-settings" className={`${s.navbtn} ${topView === "settings" ? s.active : ""}`} title="Settings" onClick={() => nav("settings")}>
|
||||
<span className={s.ico}><IcSettings /></span><span className={s.lbl}>Settings</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<div className={s.main}>
|
||||
{/* TOPBAR */}
|
||||
<header className={s.topbar}>
|
||||
<div className={s.org}>
|
||||
<div className={s.orgBadge}>{initials(orgName).slice(0, 1)}</div>
|
||||
<span data-testid="topbar-org-name" className={s.orgName}>{orgName}</span>
|
||||
<span className={s.chev}><IcChevDown /></span>
|
||||
</div>
|
||||
<div className={s.topbarRight}>
|
||||
<button className={s.iconPill} title="Search"><IcSearch /></button>
|
||||
<button className={s.iconPill} title="Notifications"><IcBell /></button>
|
||||
<button
|
||||
className={s.themeToggle}
|
||||
title="Toggle theme"
|
||||
onClick={() => setTheme(resolvedTheme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
{resolvedTheme === "dark" ? <IcMoon /> : <IcSun />}
|
||||
</button>
|
||||
<div className={s.avatar} title="You">HW</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={s.viewArea}>
|
||||
{/* HOME VIEW */}
|
||||
<div className={`${s.view} ${topView === "home" ? s.active : ""}`}>
|
||||
<aside className={s.homeSidebar}>
|
||||
<div className={s.sbTabs}>
|
||||
<button data-testid="home-subtab-agents" className={`${s.sbTab} ${sbTab === "agents" ? s.active : ""}`} onClick={() => setSbTab("agents")}>Agents</button>
|
||||
<button data-testid="home-subtab-tasks" className={`${s.sbTab} ${sbTab === "tasks" ? s.active : ""}`} onClick={() => setSbTab("tasks")}>
|
||||
Tasks{userTasks.length > 0 && <span className={s.cnt}>{userTasks.length}</span>}
|
||||
</button>
|
||||
<button data-testid="home-subtab-approvals" className={`${s.sbTab} ${sbTab === "approvals" ? s.active : ""}`} onClick={() => setSbTab("approvals")}>
|
||||
Approvals{approvals.length > 0 && <span className={s.cnt}>{approvals.length}</span>}
|
||||
</button>
|
||||
</div>
|
||||
<div className={s.sbBody}>
|
||||
{sbTab === "agents" && (
|
||||
<>
|
||||
<div className={s.wsList}>
|
||||
{roots.length === 0 && (
|
||||
<div className={s.empty}>No agents yet. Ask the concierge to spin up a team.</div>
|
||||
)}
|
||||
{roots.map((r) => renderNode(r, 0))}
|
||||
</div>
|
||||
<div className={s.sbSection}>Recent activity</div>
|
||||
<div>
|
||||
{activity.length === 0 && (
|
||||
<div className={s.empty}>No recent activity yet.</div>
|
||||
)}
|
||||
{activity.map((a) => {
|
||||
const ok = a.status !== "error" && a.status !== "failed";
|
||||
return (
|
||||
<div key={a.id} className={s.act}>
|
||||
<span className={s.actTime}>{clockTime(a.created_at)}</span>
|
||||
<div className={`${s.actLine} ${ok ? s.grn : ""}`}>
|
||||
<div className={s.actText}>{activityText(a)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{sbTab === "tasks" && (
|
||||
<>
|
||||
{userTasks.length === 0 && (
|
||||
<div className={s.empty}>Nothing needs you right now. When an agent needs you to do something, it shows up here.</div>
|
||||
)}
|
||||
{userTasks.map((t) => (
|
||||
<div key={t.id} className={s.task}>
|
||||
<div className={s.taskRow}>
|
||||
<div className={`${s.taskIc} ${s.run}`}><IcClock /></div>
|
||||
<div className={s.taskMeta}>
|
||||
<div className={s.taskT}>{t.title}</div>
|
||||
<div className={s.taskS}>
|
||||
{t.workspace_name}<span className={s.pip} />asked {clockTime(t.created_at)}
|
||||
</div>
|
||||
{t.detail && (
|
||||
<div style={{ fontSize: 12, color: "var(--tx-3)", marginTop: 6, lineHeight: 1.45 }}>
|
||||
{t.detail}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.taskActions}>
|
||||
<button className={`${s.tbtn} ${s.done}`} disabled={resolving === t.id} onClick={() => resolveTask(t, "done")}>
|
||||
<IcCheck />Done
|
||||
</button>
|
||||
<button className={s.tbtn} disabled={resolving === t.id} onClick={() => resolveTask(t, "dismissed")}>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{sbTab === "approvals" && (
|
||||
<>
|
||||
{approvals.length === 0 && (
|
||||
<div className={s.empty}>No pending approvals. Destructive actions await sign-off here.</div>
|
||||
)}
|
||||
{approvals.map((a) => (
|
||||
<div key={a.id} className={s.apprCard} style={{ marginBottom: 7 }}>
|
||||
<div className={s.apprRow}>
|
||||
<div className={s.apprIc}><IcTrash /></div>
|
||||
<div className={s.apprMeta}>
|
||||
<div className={s.apprT}>{a.action.replace(/_/g, " ")} <code>{a.workspace_name}</code></div>
|
||||
<div className={s.apprS}>{a.reason || "destructive"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.apprActions}>
|
||||
<button className={`${s.btn} ${s.approve} ${s.flex}`} disabled={deciding === a.id} onClick={() => decide(a, "approved")}>
|
||||
{deciding === a.id ? "…" : "Approve"}
|
||||
</button>
|
||||
<button className={`${s.btn} ${s.deny} ${s.flex}`} disabled={deciding === a.id} onClick={() => decide(a, "denied")}>
|
||||
{deciding === a.id ? "…" : "Deny"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* CHAT — reuses the EXACT canonical chat the Org-map SidePanel
|
||||
renders (My Chat / Agent Comms sub-tabs, attachments, history,
|
||||
delivery-mode handling), pointed at the platform agent. A thin
|
||||
concierge-styled header keeps the Home look; the ChatTab body
|
||||
below is identical to the map path so features can't drift. */}
|
||||
{platformId && platformRoot ? (
|
||||
<section className={s.chat}>
|
||||
<div className={s.chatHead}>
|
||||
<div className={s.chAv}><IcChat /></div>
|
||||
<div className={s.chMeta}>
|
||||
<div className={s.chTitle}>{platformRoot.data.name ?? "Org Concierge"}</div>
|
||||
<div className={s.chSub}>
|
||||
{(() => {
|
||||
const online =
|
||||
platformRoot.data.status === "online" ||
|
||||
platformRoot.data.status === "degraded";
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className={s.sdot}
|
||||
style={{ background: online ? "var(--green)" : "var(--grey)" }}
|
||||
/>
|
||||
{online ? "online" : statusInfo(platformRoot.data.status ?? "").label} · platform agent
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.embedChat}>
|
||||
<ChatTab key={platformId} workspaceId={platformId} data={platformRoot.data} />
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<section className={s.chat}>
|
||||
<div className={s.greetWrap}>
|
||||
<div className={s.greet}>
|
||||
<span className={s.stamp}>✷</span> No platform agent yet
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ORG MAP VIEW — the live canvas */}
|
||||
<div className={`${s.view} ${topView === "map" ? s.active : ""}`}>
|
||||
{topView === "map" && (
|
||||
<div className={s.canvasMount}>
|
||||
<main aria-label="Agent canvas" style={{ position: "absolute", inset: 0 }}>
|
||||
<Canvas />
|
||||
</main>
|
||||
<CommunicationOverlay />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SETTINGS VIEW */}
|
||||
<div className={`${s.view} ${topView === "settings" ? s.active : ""}`}>
|
||||
<div className={s.settingsScroll}>
|
||||
<div className={s.settingsInner}>
|
||||
<div className={s.settingsHead}>
|
||||
<h1>Settings</h1>
|
||||
<p>
|
||||
Org-level settings for the platform concierge. Configure the
|
||||
concierge exactly like any workspace — config.yaml, plugins
|
||||
and skills, container/compute, display, channels, schedule
|
||||
and secrets — plus how it pays for model usage and org
|
||||
identity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Two tabs instead of one long sheet: Platform agent
|
||||
configuration vs Org & canvas settings. Reuses the same
|
||||
.sbTabs purple-underline tab style as the Home sub-tabs. */}
|
||||
<div className={s.sbTabs} role="tablist" aria-label="Settings sections">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-testid="settings-tab-platform"
|
||||
aria-selected={settingsTab === "platform"}
|
||||
className={`${s.sbTab} ${settingsTab === "platform" ? s.active : ""}`}
|
||||
onClick={() => setSettingsTab("platform")}
|
||||
>
|
||||
Platform agent configuration
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
data-testid="settings-tab-org"
|
||||
aria-selected={settingsTab === "org"}
|
||||
className={`${s.sbTab} ${settingsTab === "org" ? s.active : ""}`}
|
||||
onClick={() => setSettingsTab("org")}
|
||||
>
|
||||
Org & canvas settings
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Platform agent configuration — the FULL workspace tab UI
|
||||
(Config, Plugins/Skills, Container, Display, Details,
|
||||
Activity, Terminal, Channels, Schedule, Files, Memory,
|
||||
Traces, Events, Audit), reusing the exact same
|
||||
WorkspacePanelTabs the Org-map SidePanel renders so the two
|
||||
surfaces can't drift. Pointed at the platform agent; the
|
||||
panel owns its own local active-tab state so it doesn't
|
||||
fight the map's node selection. */}
|
||||
{settingsTab === "platform" && (
|
||||
<div data-testid="settings-pane-platform" className={s.scard}>
|
||||
<div className={s.scardHead}>
|
||||
<div className={s.scardDesc}>
|
||||
Update the concierge like any workspace: its config.yaml,
|
||||
plugins & skills, container/compute, display, channels,
|
||||
schedule and more.
|
||||
</div>
|
||||
</div>
|
||||
{platformRoot ? (
|
||||
<div className={s.embedPanel}>
|
||||
<WorkspacePanelTabs key={platformRoot.id} node={platformRoot} defaultTab="config" />
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.scardDesc}>
|
||||
No platform agent yet. Spin one up from Home to configure it.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settingsTab === "org" && (
|
||||
<div data-testid="settings-pane-org" className={s.scard}>
|
||||
<div className={s.scardHead}>
|
||||
<div className={s.scardDesc}>
|
||||
Secrets, workspace tokens, org API keys and organization
|
||||
identity. These also live behind the gear in the top bar.
|
||||
</div>
|
||||
</div>
|
||||
{platformId && (
|
||||
<div className={s.embedSettings}>
|
||||
<SettingsTabs workspaceId={platformId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/* Inline SVG icons lifted from the Org Concierge concept (molecule-concierge-v1).
|
||||
Stroke icons inherit currentColor; size comes from the CSS (svg{width/height}). */
|
||||
import type { SVGProps } from "react";
|
||||
|
||||
const stroke = {
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
strokeWidth: 1.8,
|
||||
strokeLinecap: "round" as const,
|
||||
strokeLinejoin: "round" as const,
|
||||
};
|
||||
|
||||
export const IcMolecule = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
||||
<circle cx="12" cy="5" r="2.4" fill="#fff" />
|
||||
<circle cx="5.5" cy="16" r="2.4" fill="#fff" opacity=".85" />
|
||||
<circle cx="18.5" cy="16" r="2.4" fill="#fff" opacity=".85" />
|
||||
<path d="M12 7.4L6 14.2M12 7.4L18 14.2M7.6 16h8.8" stroke="#fff" strokeWidth="1.4" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcChat = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" {...p}>
|
||||
<circle cx="12" cy="5" r="2.2" fill="#fff" />
|
||||
<circle cx="5.5" cy="16" r="2.2" fill="#fff" opacity=".85" />
|
||||
<circle cx="18.5" cy="16" r="2.2" fill="#fff" opacity=".85" />
|
||||
<path d="M12 7.2L6 14M12 7.2L18 14M7.6 16h8.8" stroke="#fff" strokeWidth="1.3" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcHome = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><path d="M3 10.5L12 3l9 7.5" /><path d="M5 9.5V20h14V9.5" /></svg>
|
||||
);
|
||||
|
||||
export const IcOrgMap = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}>
|
||||
<rect x="8.5" y="3" width="7" height="6" rx="1.5" />
|
||||
<rect x="2.5" y="15" width="6.5" height="6" rx="1.5" />
|
||||
<rect x="15" y="15" width="6.5" height="6" rx="1.5" />
|
||||
<path d="M12 9v3M12 12H5.75v3M12 12h6.25v3" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcSettings = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}>
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.7 1.7 0 0 0-1.87-.34 1.7 1.7 0 0 0-1.03 1.56V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.56-1.03H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1.03-1.56V3a2 2 0 1 1 4 0v.09A1.7 1.7 0 0 0 15 4.6a1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.13.31.4.55.73.66" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const IcSearch = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" {...p}><circle cx="11" cy="11" r="7" /><path d="M20 20l-3.5-3.5" /></svg>
|
||||
);
|
||||
|
||||
export const IcBell = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><path d="M18 8a6 6 0 1 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" /><path d="M13.7 21a2 2 0 0 1-3.4 0" /></svg>
|
||||
);
|
||||
|
||||
export const IcSun = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><circle cx="12" cy="12" r="4.2" /><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" /></svg>
|
||||
);
|
||||
|
||||
export const IcMoon = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" /></svg>
|
||||
);
|
||||
|
||||
export const IcChevDown = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M6 9l6 6 6-6" /></svg>
|
||||
);
|
||||
|
||||
export const IcCaret = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M9 6l6 6-6 6" /></svg>
|
||||
);
|
||||
|
||||
export const IcQueue = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" {...p}><path d="M8 6h12M8 12h12M8 18h12M4 6h.01M4 12h.01M4 18h.01" /></svg>
|
||||
);
|
||||
|
||||
export const IcCheck = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M20 6L9 17l-5-5" /></svg>
|
||||
);
|
||||
|
||||
export const IcSchedule = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><rect x="3.5" y="4.5" width="17" height="16" rx="2.5" /><path d="M3.5 9h17M8 3v3M16 3v3" /></svg>
|
||||
);
|
||||
|
||||
export const IcWorkspace = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><rect x="3.5" y="3.5" width="7" height="7" rx="1.5" /><rect x="13.5" y="13.5" width="7" height="7" rx="1.5" /><path d="M13.5 7h7M7 13.5v7" /></svg>
|
||||
);
|
||||
|
||||
export const IcWarn = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" /></svg>
|
||||
);
|
||||
|
||||
export const IcSend = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...p}><path d="M5 12h14M13 6l6 6-6 6" /></svg>
|
||||
);
|
||||
|
||||
export const IcHistory = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><path d="M3 12a9 9 0 1 0 3-6.7L3 8" /><path d="M3 4v4h4M12 8v4l3 2" /></svg>
|
||||
);
|
||||
|
||||
export const IcDots = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" {...p}><circle cx="5" cy="12" r="1.6" /><circle cx="12" cy="12" r="1.6" /><circle cx="19" cy="12" r="1.6" /></svg>
|
||||
);
|
||||
|
||||
export const IcClock = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><path d="M12 7v5l3 2" /><circle cx="12" cy="12" r="9" /></svg>
|
||||
);
|
||||
|
||||
export const IcTrash = (p: SVGProps<SVGSVGElement>) => (
|
||||
<svg viewBox="0 0 24 24" {...stroke} {...p}><path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6M10 11v5M14 11v5" /></svg>
|
||||
);
|
||||
@@ -120,7 +120,7 @@ export { usePalette } from "./palette-context";
|
||||
// References the CSS variables that next/font/google emits in
|
||||
// app/layout.tsx. Falls through to system fonts if the variable is
|
||||
// undefined (e.g. in unit tests with no <body> font class).
|
||||
export const MOBILE_FONT_SANS = "var(--font-inter), 'Inter', ui-sans-serif, system-ui, sans-serif";
|
||||
export const MOBILE_FONT_SANS = "var(--font-hanken), 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif";
|
||||
export const MOBILE_FONT_MONO = "var(--font-jetbrains), 'JetBrains Mono', ui-monospace, monospace";
|
||||
|
||||
// Status keys we surface in the mobile UI. Anything else from the
|
||||
|
||||
@@ -15,12 +15,21 @@ import { Spinner } from '@/components/Spinner';
|
||||
* currently-active org, plus a switcher list when the user belongs to
|
||||
* multiple orgs.
|
||||
*
|
||||
* Data path:
|
||||
* Data path (SaaS — control plane present):
|
||||
* 1. fetchSession() → /cp/auth/me → current org_id
|
||||
* 2. api.get('/cp/orgs') → list of all orgs the user belongs to
|
||||
* 3. Match by id === session.org_id; fall back to host-slug match
|
||||
* if the session probe loses the race.
|
||||
*
|
||||
* Data path (self-host — NO control plane):
|
||||
* /cp/orgs is a control-plane route that does not exist on a self-hosted
|
||||
* stack, so it 404s. When that probe fails we fall back to the open
|
||||
* GET /org/identity route (served by the tenant workspace-server in both
|
||||
* modes) and render a single org card from name + slug + org_id. On a
|
||||
* fresh self-host only `name` is populated (MOLECULE_ORG_SLUG /
|
||||
* MOLECULE_ORG_ID are unset) — the card omits the empty rows and shows
|
||||
* no error and no "other organizations" list.
|
||||
*
|
||||
* Read-only — this tab never mutates. Org creation/switching lives at
|
||||
* /orgs (the post-signup landing page).
|
||||
*/
|
||||
@@ -36,25 +45,50 @@ interface Org {
|
||||
// for the same defensive unwrap.
|
||||
type OrgsResponse = Org[] | { orgs?: Org[] };
|
||||
|
||||
// GET /org/identity (self-host fallback) — open route on the tenant
|
||||
// workspace-server. slug/org_id are "" on a fresh self-host.
|
||||
interface OrgIdentity {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
org_id?: string;
|
||||
}
|
||||
|
||||
export function OrgInfoTab() {
|
||||
const [orgs, setOrgs] = useState<Org[] | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
// selfHostOrg is set only when /cp/orgs is unavailable (self-host) and the
|
||||
// /org/identity fallback yields an org. When non-null we render exactly one
|
||||
// card from it and never show the "other organizations" list or an error.
|
||||
const [selfHostOrg, setSelfHostOrg] = useState<Org | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const sess = await fetchSession().catch(() => null);
|
||||
if (cancelled) return;
|
||||
setSession(sess);
|
||||
try {
|
||||
const [sess, body] = await Promise.all([
|
||||
fetchSession().catch(() => null),
|
||||
api.get<OrgsResponse>('/cp/orgs'),
|
||||
]);
|
||||
const body = await api.get<OrgsResponse>('/cp/orgs');
|
||||
if (cancelled) return;
|
||||
setSession(sess);
|
||||
setOrgs(Array.isArray(body) ? body : body.orgs ?? []);
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load org info');
|
||||
} catch {
|
||||
// /cp/orgs is a control-plane route — absent on a self-hosted stack
|
||||
// (404 / network error). Fall back to the open /org/identity route on
|
||||
// the tenant server instead of surfacing a red error banner.
|
||||
try {
|
||||
const id = await api.get<OrgIdentity>('/org/identity');
|
||||
if (cancelled) return;
|
||||
setSelfHostOrg({
|
||||
id: id.org_id ?? '',
|
||||
slug: id.slug ?? '',
|
||||
name: id.name ?? '',
|
||||
});
|
||||
} catch (e2) {
|
||||
if (!cancelled)
|
||||
setError(e2 instanceof Error ? e2.message : 'Failed to load org info');
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
@@ -66,10 +100,14 @@ export function OrgInfoTab() {
|
||||
|
||||
const tenantSlug = getTenantSlug();
|
||||
const currentOrg =
|
||||
selfHostOrg ??
|
||||
orgs?.find((o) => session && o.id === session.org_id) ??
|
||||
orgs?.find((o) => tenantSlug && o.slug === tenantSlug) ??
|
||||
null;
|
||||
const otherOrgs = orgs?.filter((o) => o.id !== currentOrg?.id) ?? [];
|
||||
// Self-host renders a single org only — no "other organizations" list.
|
||||
const otherOrgs = selfHostOrg
|
||||
? []
|
||||
: orgs?.filter((o) => o.id !== currentOrg?.id) ?? [];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -127,21 +165,25 @@ export function OrgInfoTab() {
|
||||
}
|
||||
|
||||
function OrgIdentityCard({ org, highlighted }: { org: Org; highlighted?: boolean }) {
|
||||
// On self-host, slug / UUID may be unconfigured ("") — omit those rows
|
||||
// gracefully rather than rendering an empty code box.
|
||||
return (
|
||||
<div
|
||||
className={`rounded-lg border p-3 space-y-2 ${
|
||||
highlighted ? 'border-accent/40 bg-accent-strong/5' : 'border-line/40 bg-surface-card/40'
|
||||
}`}
|
||||
data-testid={`org-card-${org.slug}`}
|
||||
data-testid={`org-card-${org.slug || org.id || 'self-host'}`}
|
||||
>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-[12px] font-medium text-ink truncate">{org.name}</span>
|
||||
<span className="text-[12px] font-medium text-ink truncate">
|
||||
{org.name || 'This organization'}
|
||||
</span>
|
||||
{org.status && (
|
||||
<span className="text-[9px] text-ink-mid uppercase tracking-wider shrink-0">{org.status}</span>
|
||||
)}
|
||||
</div>
|
||||
<IdentityRow label="Slug" value={org.slug} />
|
||||
<IdentityRow label="UUID" value={org.id} mono />
|
||||
{org.slug && <IdentityRow label="Slug" value={org.slug} />}
|
||||
{org.id && <IdentityRow label="UUID" value={org.id} mono />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
|
||||
import { createRef, useCallback, useEffect, useState } from 'react';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { useSecretsStore } from '@/stores/secrets-store';
|
||||
import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut';
|
||||
import { SecretsTab } from './SecretsTab';
|
||||
import { TokensTab } from './TokensTab';
|
||||
import { OrgTokensTab } from './OrgTokensTab';
|
||||
import { OrgInfoTab } from './OrgInfoTab';
|
||||
import { SettingsTabs } from './SettingsTabs';
|
||||
import { UnsavedChangesGuard } from './UnsavedChangesGuard';
|
||||
|
||||
/** Module-level ref so TopBar's SettingsButton can receive focus back on close. */
|
||||
@@ -106,38 +102,7 @@ export function SettingsPanel({ workspaceId }: SettingsPanelProps) {
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<Tabs.Root defaultValue="api-keys">
|
||||
<Tabs.List className="settings-panel__tabs" aria-label="Settings sections">
|
||||
<Tabs.Trigger value="api-keys" className="settings-panel__tab">
|
||||
Secrets
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="tokens" className="settings-panel__tab">
|
||||
Workspace Tokens
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="org-tokens" className="settings-panel__tab">
|
||||
Org API Keys
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="org-info" className="settings-panel__tab">
|
||||
Organization
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="api-keys" className="settings-panel__content">
|
||||
<SecretsTab workspaceId={workspaceId} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="tokens" className="settings-panel__content">
|
||||
<TokensTab workspaceId={workspaceId} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="org-tokens" className="settings-panel__content">
|
||||
<OrgTokensTab />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="org-info" className="settings-panel__content">
|
||||
<OrgInfoTab />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
<SettingsTabs workspaceId={workspaceId} />
|
||||
|
||||
<div className="settings-panel__footer">
|
||||
<span className="settings-panel__shortcut-hint">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import { SecretsTab } from './SecretsTab';
|
||||
import { TokensTab } from './TokensTab';
|
||||
import { OrgTokensTab } from './OrgTokensTab';
|
||||
import { OrgInfoTab } from './OrgInfoTab';
|
||||
|
||||
interface SettingsTabsProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The tabbed body of the workspace settings surface — Secrets, Workspace
|
||||
* Tokens, Org API Keys, Organization.
|
||||
*
|
||||
* Extracted from SettingsPanel so the same content can render in two
|
||||
* places without duplication:
|
||||
* 1. The right-anchored slide-over drawer (the gear popover) — SettingsPanel.
|
||||
* 2. The concierge Settings view (embedded inline) — ConciergeShell.
|
||||
*
|
||||
* Pure presentation of the four tabs; all dirty-form / unsaved-guard /
|
||||
* keyboard-shortcut wiring stays in SettingsPanel where the popover owns it.
|
||||
*/
|
||||
export function SettingsTabs({ workspaceId }: SettingsTabsProps) {
|
||||
return (
|
||||
<Tabs.Root defaultValue="api-keys">
|
||||
<Tabs.List className="settings-panel__tabs" aria-label="Settings sections">
|
||||
<Tabs.Trigger value="api-keys" className="settings-panel__tab">
|
||||
Secrets
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="tokens" className="settings-panel__tab">
|
||||
Workspace Tokens
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="org-tokens" className="settings-panel__tab">
|
||||
Org API Keys
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="org-info" className="settings-panel__tab">
|
||||
Organization
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="api-keys" className="settings-panel__content">
|
||||
<SecretsTab workspaceId={workspaceId} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="tokens" className="settings-panel__content">
|
||||
<TokensTab workspaceId={workspaceId} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="org-tokens" className="settings-panel__content">
|
||||
<OrgTokensTab />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="org-info" className="settings-panel__content">
|
||||
<OrgInfoTab />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
* - Copy button writes the UUID to navigator.clipboard
|
||||
* - Falls back to host-slug match when session lookup fails
|
||||
* - Lists other orgs when user belongs to multiple
|
||||
* - Error banner when /cp/orgs throws
|
||||
* - Self-host fallback: /cp/orgs 404 → /org/identity single-org card (no error)
|
||||
* - Self-host fallback with only a name (slug/org_id unset) — omits empty rows
|
||||
* - Error banner only when BOTH /cp/orgs AND /org/identity fail
|
||||
* - Empty/no-match state renders the recovery hint, not a crash
|
||||
*/
|
||||
import React from "react";
|
||||
@@ -180,12 +182,69 @@ describe("OrgInfoTab — fallbacks", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Self-host fallback: /cp/orgs absent → /org/identity ─────────────────────
|
||||
|
||||
describe("OrgInfoTab — self-host fallback", () => {
|
||||
it("renders a single org card from /org/identity when /cp/orgs 404s", async () => {
|
||||
mockFetchSession.mockResolvedValue(null);
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === "/cp/orgs")
|
||||
return Promise.reject(new Error("API GET /cp/orgs: 404 page not found"));
|
||||
if (path === "/org/identity")
|
||||
return Promise.resolve({
|
||||
name: "Molecule AI",
|
||||
slug: "molecule-ai",
|
||||
org_id: "abc-123",
|
||||
});
|
||||
return Promise.reject(new Error(`unexpected path ${path}`));
|
||||
});
|
||||
|
||||
render(<OrgInfoTab />);
|
||||
await flush();
|
||||
await waitFor(() => screen.getByText("Current Organization"));
|
||||
|
||||
// Single card from /org/identity — name + slug + UUID, no error banner.
|
||||
expect(screen.getByText("Molecule AI")).toBeTruthy();
|
||||
expect(screen.getByText("molecule-ai")).toBeTruthy();
|
||||
expect(screen.getByText("abc-123")).toBeTruthy();
|
||||
// No "other organizations" list and no error.
|
||||
expect(screen.queryByText(/Your other organizations/)).toBeNull();
|
||||
expect(screen.queryByText(/404/)).toBeNull();
|
||||
});
|
||||
|
||||
it("renders only the name when slug/org_id are unset (fresh self-host)", async () => {
|
||||
mockFetchSession.mockResolvedValue(null);
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === "/cp/orgs")
|
||||
return Promise.reject(new Error("API GET /cp/orgs: 404 page not found"));
|
||||
if (path === "/org/identity")
|
||||
return Promise.resolve({ name: "Molecule AI", slug: "", org_id: "" });
|
||||
return Promise.reject(new Error(`unexpected path ${path}`));
|
||||
});
|
||||
|
||||
render(<OrgInfoTab />);
|
||||
await flush();
|
||||
await waitFor(() => screen.getByText("Current Organization"));
|
||||
|
||||
expect(screen.getByText("Molecule AI")).toBeTruthy();
|
||||
// Empty slug/UUID rows omitted — no copy buttons rendered.
|
||||
expect(screen.queryByRole("button", { name: /Copy Slug/i })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Copy UUID/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Error + empty handling ──────────────────────────────────────────────────
|
||||
|
||||
describe("OrgInfoTab — error + empty", () => {
|
||||
it("renders an error banner when /cp/orgs throws", async () => {
|
||||
it("renders an error banner only when BOTH /cp/orgs and /org/identity fail", async () => {
|
||||
mockFetchSession.mockResolvedValue(null);
|
||||
mockGet.mockRejectedValue(new Error("API GET /cp/orgs: 500 boom"));
|
||||
mockGet.mockImplementation((path: string) => {
|
||||
if (path === "/cp/orgs")
|
||||
return Promise.reject(new Error("API GET /cp/orgs: 404 page not found"));
|
||||
if (path === "/org/identity")
|
||||
return Promise.reject(new Error("API GET /org/identity: 500 boom"));
|
||||
return Promise.reject(new Error(`unexpected path ${path}`));
|
||||
});
|
||||
|
||||
render(<OrgInfoTab />);
|
||||
await flush();
|
||||
@@ -193,10 +252,14 @@ describe("OrgInfoTab — error + empty", () => {
|
||||
expect(screen.queryByText("Current Organization")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the recovery hint when no org matches (no crash)", async () => {
|
||||
it("renders the recovery hint when /cp/orgs returns an empty list (no crash)", async () => {
|
||||
mockFetchSession.mockResolvedValue(null);
|
||||
mockGetTenantSlug.mockReturnValue("");
|
||||
mockGet.mockResolvedValue([]);
|
||||
mockGet.mockImplementation((path: string) =>
|
||||
path === "/cp/orgs"
|
||||
? Promise.resolve([])
|
||||
: Promise.reject(new Error(`unexpected path ${path}`)),
|
||||
);
|
||||
|
||||
render(<OrgInfoTab />);
|
||||
await flush();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { SettingsPanel } from './SettingsPanel';
|
||||
export { SettingsTabs } from './SettingsTabs';
|
||||
export { SettingsButton } from './SettingsButton';
|
||||
export { SecretsTab } from './SecretsTab';
|
||||
export { SecretRow } from './SecretRow';
|
||||
|
||||
@@ -197,6 +197,14 @@ describe("DisplayTab", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
const desktop = await screen.findByTitle("Workspace desktop");
|
||||
// Wait for the RFB instance to actually connect before pasting. The component
|
||||
// sets rfbRef.current synchronously right after `new RFB()` (which fires
|
||||
// mockRFBConstructor) INSIDE the async connect() — but the "Workspace desktop"
|
||||
// title renders before that await resolves. Firing paste immediately races
|
||||
// rfbRef.current===null, so the window paste handler's
|
||||
// `rfbRef.current?.clipboardPasteFrom(text)` no-ops (0 calls). This lost the
|
||||
// race under CI runner load; waiting for the constructor makes it deterministic.
|
||||
await waitFor(() => expect(mockRFBConstructor).toHaveBeenCalled());
|
||||
fireEvent.paste(desktop, {
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === "text/plain" ? "Paste Me" : ""),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Secret } from '@/types/secrets';
|
||||
import { getTenantSlug } from '../tenant';
|
||||
import { platformAuthHeaders } from '@/lib/api';
|
||||
|
||||
const PLATFORM_URL = process.env.NEXT_PUBLIC_PLATFORM_URL ?? 'http://localhost:8080';
|
||||
|
||||
@@ -13,16 +13,19 @@ function apiUrl(workspaceId: string, path = ''): string {
|
||||
}
|
||||
|
||||
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
// Match api.ts shape — slug header + cross-origin credentials so SaaS
|
||||
// cross-subdomain fetches work. See lib/api.ts for the rationale.
|
||||
const slug = getTenantSlug();
|
||||
const saasHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (slug) saasHeaders['X-Molecule-Org-Slug'] = slug;
|
||||
// Auth pair (admin/org Bearer token + tenant slug) + JSON Content-Type come
|
||||
// from the shared `platformAuthHeaders()` helper. This bespoke fetch
|
||||
// previously hand-rolled only the slug + Content-Type and OMITTED the
|
||||
// Authorization bearer — so against a workspace-server with ADMIN_TOKEN set
|
||||
// (local dev, every SaaS tenant), WorkspaceAuth saw no bearer and no verified
|
||||
// CP session and returned 401 "missing workspace auth token". That's exactly
|
||||
// the #178 raw-fetch-forgets-a-header bug shape the helper exists to prevent.
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...saasHeaders,
|
||||
'Content-Type': 'application/json',
|
||||
...platformAuthHeaders(),
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/** Canonical workspace `kind` values — the TS mirror of Go's models.Kind*
|
||||
* constants (`models.KindPlatform` / `models.KindWorkspace`).
|
||||
*
|
||||
* Single source of truth for the `kind` magic strings used across the canvas
|
||||
* (topology, map strip, toolbar, concierge shell). Kept in a leaf module so
|
||||
* both `@/store/canvas` and `@/store/canvas-topology` can import it without a
|
||||
* circular dependency. `WorkspaceNodeData.kind` stays a plain `string` — these
|
||||
* are the well-known values to compare against, not an exhaustive enum.
|
||||
*
|
||||
* - `Platform` = the org-level concierge (the undeletable org root, hidden
|
||||
* from the map graph, surfaced as the shell's org root).
|
||||
* - `Workspace` = an ordinary agent. Also the fallback for older ws-server
|
||||
* builds that predate the `kind` column. */
|
||||
export const WORKSPACE_KIND = {
|
||||
Platform: "platform",
|
||||
Workspace: "workspace",
|
||||
} as const;
|
||||
@@ -11,7 +11,25 @@ import {
|
||||
childSlotInGrid,
|
||||
parentMinSize,
|
||||
parentMinSizeFromChildren,
|
||||
CHILD_DEFAULT_WIDTH,
|
||||
CHILD_DEFAULT_HEIGHT,
|
||||
CHILD_GUTTER,
|
||||
PARENT_SIDE_PADDING,
|
||||
PARENT_HEADER_PADDING,
|
||||
PARENT_BOTTOM_PADDING,
|
||||
stripPlatformRootForMap,
|
||||
} from "../canvas-topology";
|
||||
import { WORKSPACE_KIND } from "../../lib/workspace-kind";
|
||||
|
||||
// Layout-math aliases so these assertions track the card-size constants
|
||||
// instead of hard-coding pixel values (which drift when the card size
|
||||
// changes — e.g. the 240×130 → 300×176 "bigger cards" redesign).
|
||||
const W = CHILD_DEFAULT_WIDTH;
|
||||
const H = CHILD_DEFAULT_HEIGHT;
|
||||
const GUT = CHILD_GUTTER;
|
||||
const SIDE = PARENT_SIDE_PADDING;
|
||||
const HEAD = PARENT_HEADER_PADDING;
|
||||
const BOTTOM = PARENT_BOTTOM_PADDING;
|
||||
|
||||
// ─── sortParentsBeforeChildren ─────────────────────────────────────────────────
|
||||
|
||||
@@ -115,34 +133,34 @@ describe("sortParentsBeforeChildren", () => {
|
||||
|
||||
// ─── defaultChildSlot ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("defaultChildSlot — 2-column grid (240×130 cards)", () => {
|
||||
describe("defaultChildSlot — 2-column grid", () => {
|
||||
it("slot 0 → column 0, row 0", () => {
|
||||
const s = defaultChildSlot(0);
|
||||
expect(s).toEqual({ x: 16, y: 130 });
|
||||
expect(s).toEqual({ x: SIDE, y: HEAD });
|
||||
});
|
||||
|
||||
it("slot 1 → column 1, row 0", () => {
|
||||
const s = defaultChildSlot(1);
|
||||
expect(s.x).toBe(16 + 240 + 14); // PARENT_SIDE_PADDING + CHILD_DEFAULT_WIDTH + CHILD_GUTTER
|
||||
expect(s.y).toBe(130);
|
||||
expect(s.x).toBe(SIDE + W + GUT); // PARENT_SIDE_PADDING + CHILD_DEFAULT_WIDTH + CHILD_GUTTER
|
||||
expect(s.y).toBe(HEAD);
|
||||
});
|
||||
|
||||
it("slot 2 → column 0, row 1", () => {
|
||||
const s = defaultChildSlot(2);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + 130 + 14); // row 0 height + gutter
|
||||
expect(s.x).toBe(SIDE);
|
||||
expect(s.y).toBe(HEAD + H + GUT); // row 0 height + gutter
|
||||
});
|
||||
|
||||
it("slot 3 → column 1, row 1", () => {
|
||||
const s = defaultChildSlot(3);
|
||||
expect(s.x).toBe(16 + 240 + 14);
|
||||
expect(s.y).toBe(130 + 130 + 14);
|
||||
expect(s.x).toBe(SIDE + W + GUT);
|
||||
expect(s.y).toBe(HEAD + H + GUT);
|
||||
});
|
||||
|
||||
it("slot 4 → column 0, row 2", () => {
|
||||
const s = defaultChildSlot(4);
|
||||
expect(s.x).toBe(16);
|
||||
expect(s.y).toBe(130 + (130 + 14) * 2); // row 1 end + gutter
|
||||
expect(s.x).toBe(SIDE);
|
||||
expect(s.y).toBe(HEAD + (H + GUT) * 2); // row 1 end + gutter
|
||||
});
|
||||
});
|
||||
|
||||
@@ -194,36 +212,35 @@ describe("parentMinSize — uniform-size children", () => {
|
||||
|
||||
it("1 child → 1 col, 1 row", () => {
|
||||
const s = parentMinSize(1);
|
||||
// width = 16*2 + 1*240 + 0 = 272; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 240);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
// width = SIDE*2 + 1*W; height = HEAD + 1*H + BOTTOM
|
||||
expect(s.width).toBe(SIDE * 2 + W);
|
||||
expect(s.height).toBe(HEAD + H + BOTTOM);
|
||||
});
|
||||
|
||||
it("2 children → 2 cols, 1 row", () => {
|
||||
const s = parentMinSize(2);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526; height = 130 + 1*130 + 0 + 16 = 276
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 130 + 16);
|
||||
// width = SIDE*2 + 2*W + 1*GUT; height = HEAD + 1*H + BOTTOM
|
||||
expect(s.width).toBe(SIDE * 2 + 2 * W + GUT);
|
||||
expect(s.height).toBe(HEAD + H + BOTTOM);
|
||||
});
|
||||
|
||||
it("3 children → 2 cols, 2 rows", () => {
|
||||
const s = parentMinSize(3);
|
||||
// width = 16*2 + 2*240 + 1*14 = 526
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
// height = 130 + 2*130 + 1*14 + 16 = 416
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
expect(s.width).toBe(SIDE * 2 + 2 * W + GUT);
|
||||
// height = HEAD + 2*H + 1*GUT + BOTTOM
|
||||
expect(s.height).toBe(HEAD + 2 * H + GUT + BOTTOM);
|
||||
});
|
||||
|
||||
it("4 children → 2 cols, 2 rows (full grid)", () => {
|
||||
const s = parentMinSize(4);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 2 * 130 + 14 + 16);
|
||||
expect(s.width).toBe(SIDE * 2 + 2 * W + GUT);
|
||||
expect(s.height).toBe(HEAD + 2 * H + GUT + BOTTOM);
|
||||
});
|
||||
|
||||
it("5 children → 2 cols, 3 rows", () => {
|
||||
const s = parentMinSize(5);
|
||||
expect(s.width).toBe(16 * 2 + 2 * 240 + 14);
|
||||
expect(s.height).toBe(130 + 3 * 130 + 2 * 14 + 16);
|
||||
expect(s.width).toBe(SIDE * 2 + 2 * W + GUT);
|
||||
expect(s.height).toBe(HEAD + 3 * H + 2 * GUT + BOTTOM);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -243,8 +260,8 @@ describe("parentMinSizeFromChildren — variable-size children", () => {
|
||||
|
||||
it("two equal-width children → same as parentMinSize(2)", () => {
|
||||
const fromChildren = parentMinSizeFromChildren([
|
||||
{ width: 240, height: 130 },
|
||||
{ width: 240, height: 130 },
|
||||
{ width: W, height: H },
|
||||
{ width: W, height: H },
|
||||
]);
|
||||
expect(fromChildren.width).toBe(parentMinSize(2).width);
|
||||
expect(fromChildren.height).toBe(parentMinSize(2).height);
|
||||
@@ -262,3 +279,74 @@ describe("parentMinSizeFromChildren — variable-size children", () => {
|
||||
expect(wide.width).toBeGreaterThan(narrow.width);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── stripPlatformRootForMap ───────────────────────────────────────────────────
|
||||
|
||||
describe("stripPlatformRootForMap", () => {
|
||||
// Minimal Node<WorkspaceNodeData> builder — only the fields the function reads.
|
||||
const node = (
|
||||
id: string,
|
||||
opts: { kind?: string; parentId?: string; x?: number; y?: number } = {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
): any => ({
|
||||
id,
|
||||
position: { x: opts.x ?? 0, y: opts.y ?? 0 },
|
||||
parentId: opts.parentId,
|
||||
data: { kind: opts.kind ?? WORKSPACE_KIND.Workspace, parentId: opts.parentId ?? null },
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const edge = (source: string, target: string): any => ({ id: `${source}->${target}`, source, target });
|
||||
|
||||
it("returns input unchanged when there is no platform node", () => {
|
||||
const nodes = [node("a"), node("b", { parentId: "a", x: 5, y: 5 })];
|
||||
const edges = [edge("a", "b")];
|
||||
const out = stripPlatformRootForMap(nodes, edges);
|
||||
expect(out.nodes).toBe(nodes); // same reference — no work done
|
||||
expect(out.edges).toBe(edges);
|
||||
});
|
||||
|
||||
it("removes the platform root, promotes its direct children to absolute positions, and drops platform-touching edges", () => {
|
||||
const platform = node("P", { kind: WORKSPACE_KIND.Platform, x: 100, y: 50 });
|
||||
const child = node("c", { parentId: "P", x: 10, y: 20 }); // RF-relative to P
|
||||
const grandchild = node("g", { parentId: "c", x: 5, y: 5 });
|
||||
const out = stripPlatformRootForMap(
|
||||
[platform, child, grandchild],
|
||||
[edge("P", "c"), edge("c", "g")],
|
||||
);
|
||||
|
||||
// Platform node is gone.
|
||||
expect(out.nodes.find((n) => n.id === "P")).toBeUndefined();
|
||||
|
||||
// Direct child promoted to top-level with absolute position (parentPos + childPos).
|
||||
const c = out.nodes.find((n) => n.id === "c")!;
|
||||
expect(c.parentId).toBeUndefined();
|
||||
expect(c.extent).toBeUndefined();
|
||||
expect(c.position).toEqual({ x: 110, y: 70 });
|
||||
expect(c.data.parentId).toBeNull();
|
||||
|
||||
// Grandchild (child of a non-platform node) is untouched.
|
||||
const g = out.nodes.find((n) => n.id === "g")!;
|
||||
expect(g.parentId).toBe("c");
|
||||
expect(g.position).toEqual({ x: 5, y: 5 });
|
||||
|
||||
// Edge touching the platform node dropped; the other preserved.
|
||||
expect(out.edges.map((e) => e.id)).toEqual(["c->g"]);
|
||||
});
|
||||
|
||||
it("leaves children of an ordinary (non-platform) parent untouched", () => {
|
||||
const platform = node("P", { kind: WORKSPACE_KIND.Platform });
|
||||
const ordinaryParent = node("op", { parentId: "P", x: 200, y: 0 });
|
||||
const grandchild = node("gc", { parentId: "op", x: 7, y: 9 });
|
||||
const out = stripPlatformRootForMap([platform, ordinaryParent, grandchild], []);
|
||||
|
||||
// op is a direct child of platform → promoted (absolute = 200+0, 0+0).
|
||||
const op = out.nodes.find((n) => n.id === "op")!;
|
||||
expect(op.parentId).toBeUndefined();
|
||||
expect(op.position).toEqual({ x: 200, y: 0 });
|
||||
|
||||
// gc's parent is the ordinary node, not platform → relative position preserved.
|
||||
const gc = out.nodes.find((n) => n.id === "gc")!;
|
||||
expect(gc.parentId).toBe("op");
|
||||
expect(gc.position).toEqual({ x: 7, y: 9 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { WorkspaceData } from "./socket";
|
||||
import type { WorkspaceNodeData } from "./canvas";
|
||||
import { WORKSPACE_KIND } from "@/lib/workspace-kind";
|
||||
|
||||
const H_SPACING = 320;
|
||||
const V_SPACING = 200;
|
||||
@@ -51,13 +52,13 @@ export function sortParentsBeforeChildren<T extends { id: string; parentId?: str
|
||||
}
|
||||
|
||||
// Grid-slot defaults for children laid under a parent. The card
|
||||
// component (WorkspaceNode.tsx) sets `max-w-[240px]` on leaves, so a
|
||||
// slot stride of CHILD_DEFAULT_WIDTH + CHILD_GUTTER guarantees cards
|
||||
// never bleed into their neighbour's slot. Keep these in sync with
|
||||
// the Go mirror in workspace-server/internal/handlers/org.go —
|
||||
// changing one without the other leads to import-time / runtime drift.
|
||||
export const CHILD_DEFAULT_WIDTH = 240;
|
||||
export const CHILD_DEFAULT_HEIGHT = 130;
|
||||
// component (WorkspaceNode.tsx) renders leaves at exactly w-[300px] /
|
||||
// min-h-[176px], so a slot stride of CHILD_DEFAULT_WIDTH + CHILD_GUTTER
|
||||
// guarantees cards never bleed into their neighbour's slot. Keep these
|
||||
// in sync with the Go mirror in workspace-server/internal/handlers/org.go
|
||||
// — changing one without the other leads to import-time / runtime drift.
|
||||
export const CHILD_DEFAULT_WIDTH = 300;
|
||||
export const CHILD_DEFAULT_HEIGHT = 176;
|
||||
// Parent header space — reserves room above the child grid so the
|
||||
// parent's own name + runtime pill + clamped role + currentTask
|
||||
// banner aren't covered by the first row of child cards. The
|
||||
@@ -529,6 +530,10 @@ export function buildNodesAndEdges(
|
||||
// — leave undefined so the chat UI's "?? 'push'" fallback applies.
|
||||
deliveryMode: ws.delivery_mode,
|
||||
compute: ws.compute,
|
||||
// Org-level platform agent ('platform') vs ordinary workspace. The map
|
||||
// view hides the platform root (it's the undeletable org anchor) via
|
||||
// stripPlatformRootForMap; the shell home tree keeps it as ROOT.
|
||||
kind: ws.kind ?? WORKSPACE_KIND.Workspace,
|
||||
},
|
||||
};
|
||||
if (hasParent) {
|
||||
@@ -553,10 +558,10 @@ export function buildNodesAndEdges(
|
||||
// - Collapsed parents: leaf-sized (header-only card).
|
||||
// - Leaves: leaf-sized — they land in their grid slot cleanly.
|
||||
//
|
||||
// NodeResizer still drives user-initiated growth at runtime; these
|
||||
// are only the initial values, and React Flow updates them in place
|
||||
// when the user drags a resize handle. A future hydrate() will
|
||||
// reset to the default until we persist width/height server-side.
|
||||
// Sizes are fully system-controlled (free-resize was removed): these
|
||||
// initial values stand, and at runtime React Flow re-measures leaves
|
||||
// from their fixed-size card CSS while parents grow to fit children
|
||||
// (growParentsToFitChildren). Width/height are never persisted.
|
||||
const kids = childCounts.get(ws.id) ?? 0;
|
||||
if (kids > 0 && !ws.collapsed) {
|
||||
const size = parentSize.get(ws.id)!;
|
||||
@@ -625,3 +630,53 @@ export function getConfigurationError(
|
||||
const raw = agentCard.configuration_error;
|
||||
return typeof raw === "string" && raw.length > 0 ? raw : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map-view filter: removes the org-level platform agent (the concierge) from
|
||||
* the node graph. The platform agent is the undeletable org ROOT — every other
|
||||
* workspace hangs under it — so it is surfaced as the shell's org anchor
|
||||
* (topbar + Home tree), NOT as a draggable/deletable map node.
|
||||
*
|
||||
* Its direct children are promoted to top-level: React Flow stores child
|
||||
* positions RELATIVE to the parent, so when the parent is dropped each child is
|
||||
* converted back to an absolute position (parent.position + child.position) and
|
||||
* its parent binding cleared. Edges touching the platform node are dropped.
|
||||
*
|
||||
* The store keeps the full node set (the shell's Home agent tree renders the
|
||||
* platform as ROOT); only the map's React Flow input is stripped.
|
||||
*/
|
||||
export function stripPlatformRootForMap(
|
||||
nodes: Node<WorkspaceNodeData>[],
|
||||
edges: Edge[],
|
||||
): { nodes: Node<WorkspaceNodeData>[]; edges: Edge[] } {
|
||||
const platformIds = new Set(
|
||||
nodes.filter((n) => n.data.kind === WORKSPACE_KIND.Platform).map((n) => n.id),
|
||||
);
|
||||
if (platformIds.size === 0) return { nodes, edges };
|
||||
|
||||
const posById = new Map(nodes.map((n) => [n.id, n.position]));
|
||||
const outNodes = nodes
|
||||
.filter((n) => !platformIds.has(n.id))
|
||||
.map((n) => {
|
||||
const pid = n.parentId;
|
||||
if (pid && platformIds.has(pid)) {
|
||||
const parentPos = posById.get(pid) ?? { x: 0, y: 0 };
|
||||
return {
|
||||
...n,
|
||||
parentId: undefined,
|
||||
extent: undefined,
|
||||
position: {
|
||||
x: parentPos.x + n.position.x,
|
||||
y: parentPos.y + n.position.y,
|
||||
},
|
||||
data: { ...n.data, parentId: null },
|
||||
} as Node<WorkspaceNodeData>;
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
const outEdges = edges.filter(
|
||||
(e) => !platformIds.has(e.source) && !platformIds.has(e.target),
|
||||
);
|
||||
return { nodes: outNodes, edges: outEdges };
|
||||
}
|
||||
|
||||
@@ -25,8 +25,8 @@ import {
|
||||
/**
|
||||
* Walk every parent node and bump its width/height (if explicitly set)
|
||||
* so the union of its children's relative bboxes plus padding fits. A
|
||||
* parent's size never shrinks via this path — only grows — because
|
||||
* shrinking on resize would fight the user's own NodeResizer drag.
|
||||
* parent's size never shrinks via this path — only grows — so a parent
|
||||
* that expanded to fit children stays expanded as their layout settles.
|
||||
*/
|
||||
function growParentsToFitChildren<T extends Record<string, unknown>>(
|
||||
nodes: Node<T>[],
|
||||
@@ -74,6 +74,12 @@ function growParentsToFitChildren<T extends Record<string, unknown>>(
|
||||
export { summarizeWorkspaceCapabilities } from "./canvas-capabilities";
|
||||
export type { WorkspaceCapabilitySummary } from "./canvas-capabilities";
|
||||
|
||||
/** Canonical workspace `kind` values — the TS mirror of Go's models.Kind*
|
||||
* constants. Defined in a leaf module (`@/lib/workspace-kind`) and re-exported
|
||||
* here for convenience so consumers can keep importing from `@/store/canvas`.
|
||||
* Use these instead of the bare "platform"/"workspace" string literals. */
|
||||
export { WORKSPACE_KIND } from "@/lib/workspace-kind";
|
||||
|
||||
export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
name: string;
|
||||
status: string;
|
||||
@@ -86,6 +92,10 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
lastSampleError: string;
|
||||
url: string;
|
||||
parentId: string | null;
|
||||
/** 'platform' = the org concierge (hidden from the map graph, surfaced as the
|
||||
* shell's org root); 'workspace' = ordinary agent. Optional: absent on older
|
||||
* ws-server builds / some event-constructed nodes — treat absent as ordinary. */
|
||||
kind?: string;
|
||||
currentTask: string;
|
||||
runtime: string;
|
||||
workspaceAccess?: string | null;
|
||||
@@ -142,6 +152,12 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
|
||||
|
||||
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
|
||||
|
||||
/**
|
||||
* Top-level canvas view. "home" is the Org Concierge view (chat with the
|
||||
* platform agent); "map" is the node-graph canvas (the original view).
|
||||
*/
|
||||
export type TopView = "home" | "map" | "settings";
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -154,6 +170,8 @@ interface CanvasState {
|
||||
edges: Edge[];
|
||||
selectedNodeId: string | null;
|
||||
panelTab: PanelTab;
|
||||
/** Top-level view: Org Concierge home (chat) vs the node-graph map. */
|
||||
topView: TopView;
|
||||
dragOverNodeId: string | null;
|
||||
contextMenu: ContextMenuState | null;
|
||||
// Live width of the SidePanel in pixels. Only meaningful when
|
||||
@@ -174,6 +192,7 @@ interface CanvasState {
|
||||
savePosition: (nodeId: string, x: number, y: number) => void;
|
||||
selectNode: (id: string | null) => void;
|
||||
setPanelTab: (tab: PanelTab) => void;
|
||||
setTopView: (view: TopView) => void;
|
||||
getSelectedNode: () => Node<WorkspaceNodeData> | null;
|
||||
updateNodeData: (id: string, data: Partial<WorkspaceNodeData>) => void;
|
||||
restartWorkspace: (id: string, options?: { applyTemplate?: boolean }) => Promise<void>;
|
||||
@@ -283,6 +302,7 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
edges: [],
|
||||
selectedNodeId: null,
|
||||
panelTab: "chat",
|
||||
topView: "home",
|
||||
dragOverNodeId: null,
|
||||
contextMenu: null,
|
||||
sidePanelWidth: 480, // matches SIDEPANEL_DEFAULT_WIDTH in SidePanel.tsx
|
||||
@@ -418,6 +438,7 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
setPanelTab: (tab) => set({ panelTab: tab }),
|
||||
setTopView: (view) => set({ topView: view }),
|
||||
setDragOverNode: (id) => set({ dragOverNodeId: id }),
|
||||
|
||||
batchNest: async (nodeIds, targetId) => {
|
||||
@@ -951,8 +972,9 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
// response to the child near its edge, the child's relative
|
||||
// position becomes valid again and the grow stops mid-drag, only to
|
||||
// resume on the next tick. Commit-on-release: only run grow when a
|
||||
// change set contains a `dimensions` change (NodeResizer commit),
|
||||
// not on pure `position` changes. Drag-stop grow is handled
|
||||
// change set contains a `dimensions` change (React Flow's auto-measure
|
||||
// of a card's fixed-size CSS), not on pure `position` changes. Drag-stop
|
||||
// grow is handled
|
||||
// explicitly in Canvas.onNodeDragStop via growOnce().
|
||||
const hasDimensionChange = changes.some((c) => c.type === "dimensions");
|
||||
set({ nodes: hasDimensionChange ? growParentsToFitChildren(next) : next });
|
||||
|
||||
@@ -319,6 +319,11 @@ export interface WorkspaceData {
|
||||
agent_card: Record<string, unknown> | null;
|
||||
url: string;
|
||||
parent_id: string | null;
|
||||
/** Workspace kind: 'platform' = the org-level concierge (the undeletable org
|
||||
* root, hidden from the map graph); 'workspace' = an ordinary agent. Absent
|
||||
* on older ws-server builds that predate the kind column — treat as
|
||||
* 'workspace'. (migration 20260606000000_workspaces_kind) */
|
||||
kind?: string;
|
||||
active_tasks: number;
|
||||
max_concurrent_tasks?: number | null;
|
||||
last_error_rate: number;
|
||||
|
||||
+39
-1
@@ -69,10 +69,43 @@ services:
|
||||
# Override to "production" for SaaS/staged deploys; in those modes
|
||||
# ADMIN_TOKEN must also be set or every request rejects.
|
||||
MOLECULE_ENV: "${MOLECULE_ENV:-development}"
|
||||
# Self-hosted: no control plane to install the org's platform agent
|
||||
# (concierge), so the tenant server seeds it on boot. Idempotent; unset it
|
||||
# if you don't want the auto-seeded Org Concierge root.
|
||||
MOLECULE_SEED_PLATFORM_AGENT: "${MOLECULE_SEED_PLATFORM_AGENT:-true}"
|
||||
# Org display name. Drives the platform-agent name ("<MOLECULE_ORG_NAME>
|
||||
# Agent", e.g. "Molecule AI Agent") and the canvas topbar (via the open
|
||||
# GET /org/identity route). Empty → legacy "Org Concierge" + no topbar name.
|
||||
MOLECULE_ORG_NAME: "${MOLECULE_ORG_NAME:-Molecule AI}"
|
||||
CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:${CANVAS_PUBLISH_PORT:-3000},http://127.0.0.1:${CANVAS_PUBLISH_PORT:-3000},http://localhost:3001}
|
||||
RATE_LIMIT: "${RATE_LIMIT:-1000}"
|
||||
CONFIGS_DIR: /configs
|
||||
# Runtime/template SSOT parity with production. The image bakes the FULL
|
||||
# template set (claude-code-default, codex, google-adk, hermes, openclaw,
|
||||
# seo-agent) at /workspace-configs-templates, but the ./workspace-configs-
|
||||
# templates:/configs mount below only carries claude-code-default on the
|
||||
# host — so without this, GET /templates (the runtime-picker SSOT) listed
|
||||
# only claude-code locally while production lists them all. Pointing the
|
||||
# template cache-dir at the baked bundle makes the local runtime LIST match
|
||||
# production. NOTE: the local Docker provisioner bind-mounts a template
|
||||
# from CONFIGS_HOST_DIR (host path) at provision time, and the host dir
|
||||
# only has claude-code-default — so the other runtimes are SELECTABLE but
|
||||
# only claude-code is PROVISIONABLE locally (their images + host templates
|
||||
# aren't present in this lightweight dev stack). Real provisioning of the
|
||||
# other runtimes is covered by the staging e2e, which carries all images.
|
||||
TEMPLATE_CACHE_DIR: "${TEMPLATE_CACHE_DIR:-/workspace-configs-templates}"
|
||||
CONFIGS_HOST_DIR: "${CONFIGS_HOST_DIR:-${PWD}/workspace-configs-templates}"
|
||||
# ORG-TEMPLATE SSOT parity — same shadowing fix as TEMPLATE_CACHE_DIR
|
||||
# above, for ORG templates (the Home page's ORG TEMPLATES section). The
|
||||
# image bakes the default org templates (molecule-dev,
|
||||
# molecule-worker-gemini, ux-ab-lab) at /org-templates. Previously the
|
||||
# `./org-templates:/org-templates:ro` mount bind-mounted an EMPTY host dir
|
||||
# over that exact path, shadowing the baked defaults — so the Home page
|
||||
# showed "No org templates in org-templates/" locally while production
|
||||
# listed all three. The shadowing mount is removed below; this env points
|
||||
# findOrgDir() at the baked bundle so the local listing matches production.
|
||||
# Override to a populated host dir to develop your own org templates.
|
||||
ORG_TEMPLATES_DIR: "${ORG_TEMPLATES_DIR:-/org-templates}"
|
||||
PLUGINS_HOST_DIR: "${PLUGINS_HOST_DIR:-${PWD}/plugins}"
|
||||
# github-app-auth plugin — injects GITHUB_TOKEN / GH_TOKEN into every
|
||||
# workspace env from the App installation token. Remap the host-side
|
||||
@@ -125,7 +158,12 @@ services:
|
||||
IMAGE_AUTO_REFRESH: "${IMAGE_AUTO_REFRESH:-true}"
|
||||
volumes:
|
||||
- ./workspace-configs-templates:/configs
|
||||
- ./org-templates:/org-templates:ro
|
||||
# NOTE: the empty host ./org-templates is intentionally NOT mounted over
|
||||
# the baked /org-templates — that shadowed the image's default org
|
||||
# templates and made the Home page show "No org templates". The platform
|
||||
# reads org templates from ORG_TEMPLATES_DIR (set to the baked
|
||||
# /org-templates above). To develop custom org templates, mount a
|
||||
# POPULATED host dir at a different path and point ORG_TEMPLATES_DIR at it.
|
||||
- ./plugins:/plugins:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# App private key — read-only bind-mount. The host-side path is
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# RFC: User Tasks — agent→user action requests
|
||||
|
||||
**Status:** Draft — pre-implementation design SSOT. New primitive; normally
|
||||
needs CTO sign-off before merge (authorized in-session by the CTO for the
|
||||
concierge build).
|
||||
|
||||
**Author:** core-devops (canvas concierge work)
|
||||
**Related:** RFC #2360 (platform agent / Org Concierge), PR #2385 (canvas redesign)
|
||||
|
||||
## Problem
|
||||
|
||||
The Org Concierge home has a **Tasks** tab. "Tasks" is meant to be **things an
|
||||
agent asks the *user* to do** — e.g. "Review the launch draft", "Provide the
|
||||
Stripe API key", "Confirm the publish date". Today there is **no backend** for
|
||||
this: the only agent→user mechanisms are
|
||||
|
||||
- **Approvals** (`approval_requests`) — sign-off for *destructive* ops only, and
|
||||
- **`send_message_to_user` / `notify_user`** — unstructured chat messages with no
|
||||
state (you can't mark them done, and they don't form a worklist).
|
||||
|
||||
So the Tasks tab had to be wired to **schedules** as an interim stand-in, which
|
||||
is the wrong concept.
|
||||
|
||||
## Design
|
||||
|
||||
A small structured primitive that mirrors the **approvals** subsystem (same
|
||||
shape, minus the destructive-gating semantics).
|
||||
|
||||
### Data — `user_tasks`
|
||||
|
||||
```sql
|
||||
CREATE TABLE user_tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
workspace_id UUID NOT NULL, -- the agent that raised the ask
|
||||
title TEXT NOT NULL, -- the ask, one line
|
||||
detail TEXT, -- optional longer context
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK (status IN ('pending','done','dismissed')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
resolved_at TIMESTAMPTZ,
|
||||
resolved_by TEXT
|
||||
);
|
||||
CREATE INDEX idx_user_tasks_pending ON user_tasks (status, created_at DESC);
|
||||
```
|
||||
|
||||
### Endpoints (mirror `approvals`)
|
||||
|
||||
| Method + path | Auth | Purpose |
|
||||
|---|---|---|
|
||||
| `POST /workspaces/:id/user-tasks` | WorkspaceAuth | Agent raises an ask `{title, detail?}` → `201 {user_task_id, status:"pending"}` |
|
||||
| `GET /workspaces/:id/user-tasks` | WorkspaceAuth | A workspace **reads its own** tasks (any status) |
|
||||
| `PATCH /workspaces/:id/user-tasks/:taskId` | WorkspaceAuth | A workspace **updates its own** task `{title?, detail?, status?}` (scoped by `workspace_id`) |
|
||||
| `DELETE /workspaces/:id/user-tasks/:taskId` | WorkspaceAuth | A workspace **deletes its own** task (scoped by `workspace_id`) |
|
||||
| `GET /user-tasks/pending` | AdminAuth | Cross-workspace pending list for the concierge Tasks tab → `[{id, workspace_id, workspace_name, title, detail, status, created_at}]` |
|
||||
| `POST /workspaces/:id/user-tasks/:taskId/resolve` | WorkspaceAuth | User marks `{status:"done"|"dismissed", resolved_by?}` → `200` |
|
||||
|
||||
**Any** workspace (not just the platform agent) can create and manage its own
|
||||
tasks; the `:id` workspace scope on update/delete means an agent can only touch
|
||||
tasks it raised. The Home Tasks list (`/user-tasks/pending`) is org-wide, so
|
||||
every workspace's asks surface in one place for the user.
|
||||
|
||||
`/user-tasks/pending` is AdminAuth + cross-workspace exactly like
|
||||
`/approvals/pending` (an unauthenticated caller must not enumerate every org's
|
||||
asks).
|
||||
|
||||
### MCP tool — `request_user_action`
|
||||
|
||||
Added to the **in-workspace `a2a` MCP** (same place as `send_message_to_user`)
|
||||
so every agent can raise an ask:
|
||||
|
||||
```
|
||||
request_user_action(title, detail?) → raise an ask (insert + USER_TASK_REQUESTED)
|
||||
list_user_tasks() → read the asks this workspace raised + status
|
||||
update_user_task(user_task_id, title?, detail?, status?) → edit own task
|
||||
delete_user_task(user_task_id) → delete own task
|
||||
```
|
||||
|
||||
So every agent (any workspace, via MCP) can create AND manage its own asks —
|
||||
`request_user_action` is the create; `list_/update_/delete_user_task` are the
|
||||
read/update/delete, all scoped to tasks the calling workspace raised. None are
|
||||
gated behind `MOLECULE_MCP_ALLOW_SEND_MESSAGE` (that gate is specific to
|
||||
`send_message_to_user`); raising/managing an ask is always allowed.
|
||||
|
||||
### Events
|
||||
|
||||
`USER_TASK_REQUESTED`, `USER_TASK_RESOLVED` — broadcast on the existing
|
||||
Broadcaster so the canvas updates live (same pattern as `APPROVAL_*`).
|
||||
|
||||
### Canvas wiring (PR #2385)
|
||||
|
||||
The concierge **Tasks** tab fetches `GET /user-tasks/pending`, renders each as a
|
||||
task card (title + detail + originating agent), with **Done** / **Dismiss**
|
||||
buttons calling the resolve endpoint. The tab count badge reflects the pending
|
||||
count. Replaces the interim schedules wiring.
|
||||
|
||||
## SSOT discipline / non-goals
|
||||
|
||||
- Reuses the approvals pattern, Broadcaster, and WorkspaceAuth/AdminAuth split —
|
||||
no new auth path, no new event bus.
|
||||
- **Not** an approval/gate: resolving a user-task has no server-side enforcement
|
||||
effect; it's a worklist signal. (Destructive gating stays in `approvals`.)
|
||||
- No `org_id` column; cross-workspace listing joins `workspaces` like approvals.
|
||||
|
||||
## Rollout
|
||||
|
||||
Phase 0 migration ships idempotently (`IF NOT EXISTS`). Backend + MCP tool +
|
||||
canvas wiring land together behind the concierge Home (already gated as the new
|
||||
UI). Full molecule-core SOP gate applies (tier label + qa-review +
|
||||
security-review + green CI).
|
||||
@@ -278,7 +278,7 @@ receive **HTTP 401** on every API call. Affected workflows in molecule-core:
|
||||
|
||||
| Workflow | Symptom | Workaround |
|
||||
|---|---|---|
|
||||
| `gate-check-v3.yml` | Reports BLOCKED on every PR | Provision `SOP_TIER_CHECK_TOKEN`; update workflow to use it |
|
||||
| `gate-check-v3.yml` | Reports BLOCKED on every PR | Provision `SOP_CHECKLIST_GATE_TOKEN`; update workflow to use it |
|
||||
| `qa-review.yml` | Fails immediately on PR open | Same — needs named secret |
|
||||
| `security-review.yml` | Fails immediately on PR open | Same — needs named secret |
|
||||
|
||||
@@ -313,7 +313,7 @@ dispatcher may fire **only 1 of N eligible workflows** on the initial
|
||||
|
||||
This was observed on molecule-core PR #558 (created 2026-05-11T19:54:10Z):
|
||||
12+ workflows had no `paths:` filter and should have fired, but only
|
||||
`sop-tier-check.yml` dispatched.
|
||||
`sop-checklist.yml` dispatched.
|
||||
|
||||
Concurrent PRs created within the same minute received 12–30 dispatches each,
|
||||
confirming this is specific to the PR-create event dispatch, not a general
|
||||
|
||||
@@ -229,6 +229,11 @@ ssm_refresh_ecr_auth() {
|
||||
# to guarantee correct string escaping (OFFSEC-001 / CWE-78 hardening).
|
||||
# Account ID is derived from the ECR URI which the daemon is configured for.
|
||||
local acct="${ECR_ACCOUNT_ID:-153263036946}"
|
||||
# #676: validate account ID is exactly 12 digits (AWS account ID format).
|
||||
if ! [[ "$acct" =~ ^[0-9]{12}$ ]]; then
|
||||
err "invalid ECR_ACCOUNT_ID (must be 12 digits): $acct"
|
||||
return 1
|
||||
fi
|
||||
local params
|
||||
params=$(mktemp)
|
||||
python3 -c "
|
||||
@@ -290,6 +295,11 @@ validate_slug() {
|
||||
|
||||
preflight() {
|
||||
log "preflight: source=$SOURCE_TAG dest=$DEST_TAG repo=$REPO region=$REGION"
|
||||
# Region validation: reject obviously malformed input (CWE-78 / injection guard).
|
||||
if ! [[ "$REGION" =~ ^[a-z][a-z0-9-]*[0-9]$ ]]; then
|
||||
err "invalid AWS region: $REGION"
|
||||
exit 64
|
||||
fi
|
||||
local src_manifest
|
||||
src_manifest=$(aws_ecr_get_image "$SOURCE_TAG") || {
|
||||
err "source tag '$SOURCE_TAG' not found in $REPO"
|
||||
|
||||
@@ -311,7 +311,22 @@ for slug in $valid_slugs; do
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n== Test 11: ROLLBACK_TAG follows YYYYMMDD via NOW_OVERRIDE_DATE ==\n'
|
||||
printf '\n== Test 11: region validation — malicious region rejected with exit 64 (#676) ==\n'
|
||||
# Attack vectors: shell metacharacters, path traversal, command substitution.
|
||||
_invalid_regions='us;rm -rf / $(whoami) us"east-1 ../etc/passwd `id` $HOME us/east-1'
|
||||
for bad_region in $_invalid_regions; do
|
||||
set +e
|
||||
out=$(AWS_REGION="$bad_region" "$SCRIPT" --source-tag x --dest-tag y --tenants chloe-dong --mock-dir /nonexistent 2>&1); rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 64 ]] && printf '%s' "$out" | grep -q 'invalid AWS region'; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ region rejected: %s\n' "$(printf '%q' "$bad_region")"
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("region-reject:$bad_region")
|
||||
printf ' ✗ region should be rejected: %s — got exit %s\n' "$(printf '%q' "$bad_region")" "$rc"
|
||||
fi
|
||||
done
|
||||
|
||||
printf '\n== Test 12: ROLLBACK_TAG follows YYYYMMDD via NOW_OVERRIDE_DATE ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
@@ -333,7 +348,7 @@ fi
|
||||
assert_calls_contain "rollback tag uses NOW_OVERRIDE_DATE (20260603)" "$m" 'aws_ecr_put_image b-prev-20260603'
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 12: empty source manifest fails preflight ==\n'
|
||||
printf '\n== Test 13: empty source manifest fails preflight ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '' 0 # rc=0 but empty body (the "None" case)
|
||||
out=$(run_script "$m")
|
||||
@@ -341,7 +356,7 @@ assert_exit "empty source manifest fails preflight" "$out" 1
|
||||
assert_contains "empty manifest message" "$out" 'returned empty manifest'
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 13: tenant_buildinfo failure during verify → rollback ==\n'
|
||||
printf '\n== Test 14: tenant_buildinfo failure during verify → rollback ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
@@ -355,7 +370,7 @@ assert_contains "logs buildinfo failure" "$out" '/buildinfo failed for chloe-don
|
||||
assert_contains "rollback fired after verify fail" "$out" 'ROLLBACK:'
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 14: ssm_refresh_ecr_auth JSON escaping (CWE-78 / OFFSEC-001) ==\n'
|
||||
printf '\n== Test 15: ssm_refresh_ecr_auth JSON escaping (CWE-78 / OFFSEC-001) ==\n'
|
||||
# Verify the python3 snippet in ssm_refresh_ecr_auth produces valid JSON and
|
||||
# correctly escapes shell-injection characters in region + account ID fields.
|
||||
# The fix replaces unquoted shell-printf interpolation with json.dumps.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Tiny stub runtime image for the local Docker-provisioner lifecycle e2e.
|
||||
#
|
||||
# It impersonates a real workspace runtime's platform contract (register +
|
||||
# heartbeat + A2A message/send) with ZERO LLM/SDK weight so the lifecycle e2e
|
||||
# (provision -> online -> restart-survive -> proxy-reach) runs in seconds and
|
||||
# without the 2.5GB real claude-code image.
|
||||
#
|
||||
# Resolution trick (see tests/e2e/test_local_provision_lifecycle_e2e.sh):
|
||||
# the local provisioner resolves runtime=claude-code via RegistryModeLocal,
|
||||
# which is a `docker image inspect` cache-check on
|
||||
# molecule-local/workspace-template-claude-code:<gitea-HEAD-sha12>
|
||||
# BEFORE it clones+builds. Pre-tagging THIS image to that exact cache tag makes
|
||||
# the provisioner cache-hit the stub instead of building the real template.
|
||||
#
|
||||
# linux/amd64: the provisioner forces --platform=linux/amd64 for every workspace
|
||||
# container (defaultImagePlatform, #1875) for parity with the amd64-only prod
|
||||
# images. Build the stub amd64 too so the platforms match and Docker doesn't
|
||||
# refuse the create with a manifest mismatch.
|
||||
FROM --platform=linux/amd64 python:3.12-alpine
|
||||
|
||||
# /configs is the named-volume mount point the provisioner attaches
|
||||
# (ws-<id>-configs:/configs). The real entrypoint chowns it; the stub just
|
||||
# needs the dir to exist so a missing-mount never trips it up.
|
||||
RUN mkdir -p /configs /workspace
|
||||
|
||||
WORKDIR /app
|
||||
COPY server.py /app/server.py
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# No gosu/agent-uid drop here — the stub does no privileged work and the e2e
|
||||
# only cares about the platform contract, not the agent-uid security posture.
|
||||
ENTRYPOINT ["python3", "/app/server.py"]
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal stub runtime for the local Docker-provisioner lifecycle e2e.
|
||||
|
||||
This is NOT a real agent — it carries no LLM, no claude-code SDK, no plugin
|
||||
host. Its only job is to satisfy the platform's runtime<->platform contract so
|
||||
the `test_local_provision_lifecycle_e2e.sh` harness can prove the LOCAL Docker
|
||||
provisioner can provision a workspace, bring it online, SURVIVE A RESTART
|
||||
(reusing the config volume), and route an A2A `message/send` through the
|
||||
platform proxy — all WITHOUT building/booting the 2.5GB real claude-code image.
|
||||
|
||||
Contract it replicates (discovered from workspace-server):
|
||||
|
||||
* registration is done BY the runtime container on boot (NOT the provisioner).
|
||||
The provisioner only sets status=provisioning + pre-stores the host URL; the
|
||||
container must POST /registry/register itself, and the heartbeat loop is what
|
||||
transitions provisioning -> online (registry.go evaluateStatus, #1784).
|
||||
|
||||
* env vars the real entrypoint reads, injected by buildContainerEnv():
|
||||
WORKSPACE_ID - this workspace's UUID
|
||||
PLATFORM_URL - canonical platform base URL (e.g. http://platform:8080)
|
||||
We read exactly those (with WORKSPACE_CONFIG_PATH for the config.yaml probe).
|
||||
|
||||
* POST {PLATFORM_URL}/registry/register
|
||||
body: {"id", "url", "agent_card":{"name","skills":[]}}
|
||||
- url MUST be push-routable. The provisioner runs the platform inside
|
||||
Docker, so it rewrites a stored http://127.0.0.1:<port> URL to the
|
||||
container-DNS form http://ws-<id[:12]>:8000 before proxying
|
||||
(a2a_proxy.go resolveAgentURL). We register our OWN container-DNS URL
|
||||
(http://<hostname>:8000) so SSRF validation passes in SaaS mode AND the
|
||||
proxy can reach us; in self-hosted (non-saas) mode RFC-1918 is blocked,
|
||||
so we fall back to registering by the ws-<id> alias hostname which
|
||||
resolves on molecule-core-net.
|
||||
- first register returns {"auth_token": ...}; we keep it for heartbeats.
|
||||
|
||||
* POST {PLATFORM_URL}/registry/heartbeat (every ~10s)
|
||||
header: Authorization: Bearer <auth_token>
|
||||
body: {"workspace_id","error_rate","sample_error","active_tasks",
|
||||
"uptime_seconds","current_task"}
|
||||
This is what lifts the workspace provisioning -> online and keeps the
|
||||
Redis liveness TTL fresh (so the restart re-online assertion can pass).
|
||||
|
||||
* listen on :8000 and answer the A2A JSON-RPC the proxy forwards:
|
||||
POST / {"jsonrpc","id","method":"message/send","params":{...}}
|
||||
-> 200 {"jsonrpc":"2.0","id":<echoed>,
|
||||
"result":{"kind":"message","role":"agent",
|
||||
"parts":[{"kind":"text","text":"STUB OK"}],
|
||||
"messageId":<uuid>}}
|
||||
The result envelope matches what test_a2a_e2e.sh asserts on
|
||||
(result.parts[0].text, role=agent, kind=text). A health path (/health and
|
||||
GET /) returns 200 so any probe sees the container as alive.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
PORT = 8000
|
||||
|
||||
WORKSPACE_ID = os.environ.get("WORKSPACE_ID", "").strip()
|
||||
PLATFORM_URL = (os.environ.get("PLATFORM_URL") or os.environ.get("MOLECULE_URL") or "").rstrip("/")
|
||||
HOSTNAME = os.environ.get("HOSTNAME", "").strip() # docker sets this to the container id; ws-<id> alias also resolves
|
||||
|
||||
# URL we register with. Two hard constraints, discovered from workspace-server:
|
||||
#
|
||||
# * validateAgentURL (registry.go) blocks RFC-1918 ranges in NON-saas mode
|
||||
# (this dev stack sets neither MOLECULE_DEPLOY_MODE=saas nor MOLECULE_ORG_ID
|
||||
# -> strict mode). The molecule-core-net bridge is 172.18.0.0/16, INSIDE the
|
||||
# blocked 172.16/12 — so registering our own ws-<id>:8000 DNS name (which
|
||||
# resolves to a 172.18.x bridge IP) would be REJECTED and we'd never get an
|
||||
# auth_token. "localhost" is explicitly allowed BY NAME (no DNS lookup).
|
||||
#
|
||||
# * the proxy doesn't use the URL we register anyway: the provisioner
|
||||
# pre-stores http://127.0.0.1:<host-port>, the register upsert PRESERVES any
|
||||
# existing 127.0.0.1 URL (CASE WHEN url LIKE 'http://127.0.0.1%'), and when
|
||||
# the platform runs in Docker resolveAgentURL rewrites that to the container
|
||||
# -DNS form http://ws-<id[:12]>:8000 before forwarding. So our listen
|
||||
# address (0.0.0.0:8000, reachable as ws-<id>:8000 on the bridge) is what
|
||||
# the proxy actually hits — independent of the URL string we register.
|
||||
#
|
||||
# Net: register a name-form localhost URL purely to satisfy push-mode's
|
||||
# "url required + must pass SSRF check" and to get our auth_token. Routing is
|
||||
# handled by the provisioner-stored 127.0.0.1 URL + the proxy rewrite.
|
||||
_short = WORKSPACE_ID[:12] if len(WORKSPACE_ID) > 12 else WORKSPACE_ID
|
||||
SELF_URL = os.environ.get("STUB_REGISTER_URL", f"http://localhost:{PORT}")
|
||||
|
||||
CONFIG_PATH = (os.environ.get("WORKSPACE_CONFIG_PATH") or "/configs").rstrip("/")
|
||||
AUTH_TOKEN_FILE = f"{CONFIG_PATH}/.auth_token"
|
||||
|
||||
AUTH_TOKEN = None
|
||||
_started = time.time()
|
||||
|
||||
|
||||
def _log(msg):
|
||||
print(f"[stub-runtime {_short}] {msg}", flush=True)
|
||||
|
||||
|
||||
def read_volume_token():
|
||||
"""The provisioner pre-writes the CURRENT workspace bearer to
|
||||
/configs/.auth_token before every container start (issueAndInjectToken,
|
||||
#1877), and ROTATES it on every (re)provision (RevokeAllForWorkspace +
|
||||
IssueToken). So the volume file — NOT the register-response token — is the
|
||||
authoritative, rotation-proof bearer. Reading it on each heartbeat means a
|
||||
provision-time token rotation never wedges our heartbeat at 401 (which is
|
||||
what kept the workspace stuck in 'provisioning' instead of flipping online).
|
||||
"""
|
||||
try:
|
||||
with open(AUTH_TOKEN_FILE, "r") as f:
|
||||
tok = f.read().strip()
|
||||
return tok or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _post_json(path, payload, token=None):
|
||||
url = f"{PLATFORM_URL}{path}"
|
||||
data = json.dumps(payload).encode()
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
if token:
|
||||
req.add_header("Authorization", f"Bearer {token}")
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
body = resp.read().decode()
|
||||
return resp.status, body
|
||||
|
||||
|
||||
def register():
|
||||
"""POST /registry/register. Returns the issued auth_token (first register).
|
||||
|
||||
C18 hijack guard: once the workspace has ANY live token on file (the
|
||||
provisioner mints+injects one into /configs/.auth_token before start), a
|
||||
register MUST carry that workspace's bearer or it 401s. So we send the
|
||||
volume token (if present). First-ever boot has no live token yet → bootstrap
|
||||
register (no bearer) is allowed and returns the freshly-issued auth_token.
|
||||
"""
|
||||
global AUTH_TOKEN
|
||||
payload = {
|
||||
"id": WORKSPACE_ID,
|
||||
"url": SELF_URL,
|
||||
"delivery_mode": "push",
|
||||
"agent_card": {
|
||||
"name": WORKSPACE_ID,
|
||||
"description": "stub runtime (e2e lifecycle)",
|
||||
"skills": [],
|
||||
},
|
||||
}
|
||||
status, body = _post_json("/registry/register", payload, token=read_volume_token())
|
||||
_log(f"register -> {status} {body[:200]}")
|
||||
try:
|
||||
parsed = json.loads(body)
|
||||
except Exception:
|
||||
parsed = {}
|
||||
tok = parsed.get("auth_token")
|
||||
if tok:
|
||||
AUTH_TOKEN = tok
|
||||
_log("captured auth_token from register response")
|
||||
return status
|
||||
|
||||
|
||||
def current_token():
|
||||
# Volume file is authoritative (rotation-proof); fall back to the token we
|
||||
# captured from the register response if the file isn't there yet.
|
||||
return read_volume_token() or AUTH_TOKEN
|
||||
|
||||
|
||||
def heartbeat():
|
||||
payload = {
|
||||
"workspace_id": WORKSPACE_ID,
|
||||
"error_rate": 0.0,
|
||||
"sample_error": "",
|
||||
"active_tasks": 0,
|
||||
"uptime_seconds": int(time.time() - _started),
|
||||
"current_task": "",
|
||||
}
|
||||
status, body = _post_json("/registry/heartbeat", payload, token=current_token())
|
||||
return status, body
|
||||
|
||||
|
||||
def register_with_retry():
|
||||
# The platform may still be wiring the row when we boot; retry a few times.
|
||||
# Register is best-effort for the e2e (heartbeat drives online); a sticky
|
||||
# 401 just means the workspace already has a live token and our volume token
|
||||
# is momentarily stale — the heartbeat path re-reads the volume each beat.
|
||||
for attempt in range(1, 11):
|
||||
try:
|
||||
status = register()
|
||||
if status == 200:
|
||||
return True
|
||||
_log(f"register attempt {attempt}: HTTP {status}, retrying")
|
||||
except urllib.error.HTTPError as e:
|
||||
_log(f"register attempt {attempt}: HTTPError {e.code} {e.read().decode()[:200]}")
|
||||
except Exception as e:
|
||||
_log(f"register attempt {attempt}: {e}")
|
||||
time.sleep(2)
|
||||
return False
|
||||
|
||||
|
||||
def heartbeat_loop():
|
||||
# Fire the FIRST heartbeat immediately (no initial 5s wait) — the
|
||||
# provisioning->online transition is driven by the heartbeat handler
|
||||
# (registry.go evaluateStatus, #1784), so an eager first beat minimises the
|
||||
# provision->online latency the e2e polls on.
|
||||
while True:
|
||||
try:
|
||||
status, body = heartbeat()
|
||||
if status != 200:
|
||||
_log(f"heartbeat -> {status} {body[:160]}")
|
||||
# A 401 means our token was rotated (every provision rotates the
|
||||
# workspace token, issueAndInjectToken -> RevokeAllForWorkspace).
|
||||
# Re-register to mint a fresh one. This is what lets the SAME
|
||||
# container process survive a platform-side token rotation.
|
||||
if status == 401:
|
||||
_log("heartbeat 401 — re-registering to refresh token")
|
||||
register_with_retry()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 401:
|
||||
_log("heartbeat 401 (HTTPError) — re-registering")
|
||||
register_with_retry()
|
||||
else:
|
||||
_log(f"heartbeat HTTPError {e.code}")
|
||||
except Exception as e:
|
||||
_log(f"heartbeat error: {e}")
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *args): # silence default access logging
|
||||
pass
|
||||
|
||||
def _send(self, code, obj):
|
||||
body = json.dumps(obj).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
# Health: any GET returns 200 so probes see us as alive.
|
||||
self._send(200, {"status": "ok", "stub": True, "workspace_id": WORKSPACE_ID})
|
||||
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
req = json.loads(raw or b"{}")
|
||||
except Exception:
|
||||
req = {}
|
||||
|
||||
method = req.get("method", "")
|
||||
req_id = req.get("id", str(uuid.uuid4()))
|
||||
|
||||
if method and method != "message/send":
|
||||
# Match the proxy's -32601 method-not-found contract for unknowns.
|
||||
self._send(200, {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"error": {"code": -32601, "message": f"method not found: {method}"},
|
||||
})
|
||||
return
|
||||
|
||||
# Canned A2A reply — exact envelope the canvas/proxy + test_a2a_e2e.sh
|
||||
# assert on: result.role=agent, result.parts[0].kind=text/text.
|
||||
self._send(200, {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req_id,
|
||||
"result": {
|
||||
"kind": "message",
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "text": "STUB OK"}],
|
||||
"messageId": str(uuid.uuid4()),
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
def main():
|
||||
if not WORKSPACE_ID or not PLATFORM_URL:
|
||||
_log(f"FATAL: WORKSPACE_ID={WORKSPACE_ID!r} PLATFORM_URL={PLATFORM_URL!r} — both required")
|
||||
sys.exit(1)
|
||||
|
||||
_log(f"booting: platform={PLATFORM_URL} self_url={SELF_URL} hostname={HOSTNAME}")
|
||||
|
||||
# Start the HTTP server FIRST so the platform can reach us the instant we
|
||||
# register (avoids a race where the proxy forwards before we're listening).
|
||||
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
_log(f"listening on :{PORT}")
|
||||
|
||||
# Try to register, but do NOT make heartbeating contingent on it. The
|
||||
# provisioning->online transition is driven by the HEARTBEAT handler
|
||||
# (registry.go evaluateStatus, #1784), and heartbeats authenticate with the
|
||||
# volume token (rotation-proof). If register transiently 401s (e.g. a token
|
||||
# rotation mid-boot), we must still heartbeat so the workspace can come
|
||||
# online — blocking the heartbeat loop on register success is exactly what
|
||||
# kept the workspace stuck in 'provisioning'. register_with_retry runs in a
|
||||
# background thread; the foreground heartbeat loop starts immediately.
|
||||
threading.Thread(target=register_with_retry, daemon=True).start()
|
||||
heartbeat_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env bash
|
||||
# LOCAL functional variant of the concierge-creates-a-workspace gate.
|
||||
#
|
||||
# Same proof as tests/e2e/test_staging_concierge_creates_workspace_e2e.sh but
|
||||
# against the ALREADY-RUNNING local stack (BASE, default http://localhost:8080),
|
||||
# so the "concierge actually invokes create_workspace via the platform MCP" claim
|
||||
# can be demonstrated locally — far faster than provisioning an EC2 tenant.
|
||||
#
|
||||
# Drive the AGENT (not the REST API): send the concierge an A2A message/send
|
||||
# ("create a workspace named e2e-cncrg-worker-<runid> with role engineer") and
|
||||
# assert the DETERMINISTIC SIDE EFFECT — that named workspace now EXISTS in
|
||||
# GET /workspaces — which can only happen if the concierge's LLM really invoked
|
||||
# the create_workspace platform-MCP tool.
|
||||
#
|
||||
# SKIP-LOUD GATE (this is the whole point of the local variant). The platform MCP
|
||||
# tools — incl. create_workspace — only light up on the DEDICATED platform-agent
|
||||
# image (Dockerfile.platform-agent, ships /opt/molecule-mcp-server). The ordinary
|
||||
# `claude-code` image the default local stack provisions the concierge on does
|
||||
# NOT ship it (platform_agent.go SELF-HOST CAVEAT). So before driving the agent
|
||||
# this script PROBES the concierge's own MCP tool list (POST /workspaces/:id/mcp
|
||||
# tools/list) and SKIPs LOUD (exit 0) unless create_workspace is actually present.
|
||||
# It also skips-loud when no concierge is seeded or it isn't online. That makes
|
||||
# this runnable on any local stack: it only EXERCISES the path when the local
|
||||
# stack can actually run it, and never false-reds when it can't.
|
||||
#
|
||||
# To make the local stack able to run this GREEN you need BOTH:
|
||||
# 1. A concierge seeded as the kind='platform' root. The self-hosted compose
|
||||
# sets MOLECULE_SEED_PLATFORM_AGENT=1 so the ws-server self-seeds it
|
||||
# (EnsureSelfHostedPlatformAgent) + best-effort provisions it on boot
|
||||
# (MaybeProvisionPlatformAgentOnBoot).
|
||||
# 2. That concierge running on the platform-agent image (so create_workspace
|
||||
# exists) WITH a working model key (e.g. MINIMAX_API_KEY / a BYOK key) so its
|
||||
# LLM can run the tool. The default `claude-code` image will SKIP at the MCP
|
||||
# probe — that's expected and honest, not a failure.
|
||||
#
|
||||
# Env contract:
|
||||
# BASE default http://localhost:8080
|
||||
# MOLECULE_ADMIN_TOKEN platform admin bearer IF the local stack sets
|
||||
# ADMIN_TOKEN (devmode fail-open if unset). Used by
|
||||
# _lib.sh helpers for admin-gated GET/DELETE.
|
||||
# E2E_CONCIERGE_ONLINE_SECS default 300 (local boot budget)
|
||||
# E2E_AGENT_ACT_SECS default 300 (LLM think+tool-call budget)
|
||||
# E2E_RUN_ID slug/name suffix; default $$-based
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 concierge created the workspace, OR honest skip-loud (path not runnable)
|
||||
# 1 generic / assertion failure (agent didn't act, or the tool failed)
|
||||
set -euo pipefail
|
||||
|
||||
: "${BASE:=http://localhost:8080}"
|
||||
export BASE
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=_lib.sh
|
||||
source "$(dirname "$0")/_lib.sh"
|
||||
# Error-as-text scanner so a concierge that surfaces a tool error AS its reply
|
||||
# is distinguished from a clean "created it" reply.
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=lib/completion_assert.sh
|
||||
source "$(dirname "$0")/lib/completion_assert.sh"
|
||||
|
||||
CONCIERGE_ONLINE_SECS="${E2E_CONCIERGE_ONLINE_SECS:-300}"
|
||||
AGENT_ACT_SECS="${E2E_AGENT_ACT_SECS:-300}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
WORKER_NAME="e2e-cncrg-worker-${RUN_ID_SUFFIX}"
|
||||
WORKER_NAME=$(echo "$WORKER_NAME" | tr -cd 'a-zA-Z0-9-' | head -c 48)
|
||||
export WORKER_NAME
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
skip_loud() { echo "[$(date +%H:%M:%S)] ⏭️ SKIP (local path not runnable): $*" >&2; exit 0; }
|
||||
|
||||
# Admin-auth curl args (if the local stack set ADMIN_TOKEN; else empty / fail-open).
|
||||
ADMIN_AUTH=()
|
||||
e2e_admin_auth_args ADMIN_AUTH
|
||||
|
||||
WORKER_ID=""
|
||||
cleanup() {
|
||||
# Targeted delete of the worker the concierge created (best-effort). _lib.sh's
|
||||
# helper sends the admin bearer + confirm header.
|
||||
if [ -n "$WORKER_ID" ]; then
|
||||
log "🧹 deleting concierge-created worker $WORKER_ID ($WORKER_NAME)..."
|
||||
e2e_delete_workspace "$WORKER_ID" "$WORKER_NAME" || true
|
||||
fi
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
list_ws() { curl -sS --max-time 15 "$BASE/workspaces" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"}; }
|
||||
|
||||
find_platform_root() {
|
||||
list_ws | python3 -c "
|
||||
import sys, json
|
||||
try: rows = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
for w in rows if isinstance(rows, list) else []:
|
||||
if w.get('kind') == 'platform' and not w.get('parent_id'):
|
||||
print(w.get('id','')); break
|
||||
else:
|
||||
print('')"
|
||||
}
|
||||
|
||||
ws_field() { # <id> <field>
|
||||
curl -sS --max-time 15 "$BASE/workspaces/$1" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
print(d.get('$2','') if isinstance(d, dict) else '')"
|
||||
}
|
||||
|
||||
find_worker_by_name() {
|
||||
list_ws | python3 -c "
|
||||
import sys, json, os
|
||||
want = os.environ['WORKER_NAME']
|
||||
try: rows = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
for w in rows if isinstance(rows, list) else []:
|
||||
if w.get('name') == want:
|
||||
print(w.get('id','')); break
|
||||
else:
|
||||
print('')"
|
||||
}
|
||||
|
||||
# concierge_has_create_workspace_tool <id>: probe POST /workspaces/:id/mcp
|
||||
# tools/list and echo "yes" iff create_workspace is in the advertised tool set.
|
||||
# This is THE gate distinguishing the platform-agent image (has the tool) from
|
||||
# the ordinary claude-code image (does not).
|
||||
concierge_has_create_workspace_tool() { # <id>
|
||||
local wid="$1" out
|
||||
out=$(curl -sS --max-time 30 -X POST "$BASE/workspaces/$wid/mcp" \
|
||||
${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' 2>/dev/null || echo '{}')
|
||||
echo "$out" | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: print('no'); sys.exit(0)
|
||||
tools = (d.get('result') or {}).get('tools', []) if isinstance(d, dict) else []
|
||||
names = {t.get('name','') for t in tools if isinstance(t, dict)}
|
||||
# Accept the bare name or any mcp_*_create_workspace alias the bridge may expose.
|
||||
print('yes' if any(n == 'create_workspace' or n.endswith('create_workspace') for n in names) else 'no')"
|
||||
}
|
||||
|
||||
# ─── 0. Preflight ────────────────────────────────────────────────────────────
|
||||
log "═══ LOCAL concierge CREATES-A-WORKSPACE (real-LLM) E2E ═══ BASE=$BASE"
|
||||
log " worker the concierge will be asked to create: name=$WORKER_NAME"
|
||||
curl -sS --max-time 10 "$BASE/health" >/dev/null 2>&1 || skip_loud "local stack not reachable at $BASE/health — run \`make up\` first"
|
||||
ok "Local stack reachable"
|
||||
|
||||
# ─── 1. Discover the concierge (kind='platform' root) ─────────────────────────
|
||||
CONCIERGE_ID=$(find_platform_root)
|
||||
if [ -z "$CONCIERGE_ID" ]; then
|
||||
skip_loud "no kind='platform' concierge seeded on the local stack. Set MOLECULE_SEED_PLATFORM_AGENT=1 \
|
||||
on the ws-server (self-hosted compose does this) so it self-seeds + provisions the concierge."
|
||||
fi
|
||||
ok "Concierge (platform root) = $CONCIERGE_ID"
|
||||
|
||||
# ─── 2. Ensure the concierge is online ────────────────────────────────────────
|
||||
log "Waiting for the concierge to be online (up to ${CONCIERGE_ONLINE_SECS}s)..."
|
||||
ONLINE_DEADLINE=$(( $(date +%s) + CONCIERGE_ONLINE_SECS ))
|
||||
C_STATUS=""; LAST_C_STATUS=""
|
||||
while true; do
|
||||
C_STATUS=$(ws_field "$CONCIERGE_ID" status)
|
||||
if [ "$C_STATUS" != "$LAST_C_STATUS" ]; then log " concierge → ${C_STATUS:-<none>}"; LAST_C_STATUS="$C_STATUS"; fi
|
||||
[ "$C_STATUS" = "online" ] && break
|
||||
if [ "$(date +%s)" -gt "$ONLINE_DEADLINE" ]; then
|
||||
skip_loud "concierge $CONCIERGE_ID never reached online within ${CONCIERGE_ONLINE_SECS}s (last='${C_STATUS}'). \
|
||||
On the default local stack the concierge needs a model key (e.g. MINIMAX_API_KEY) to boot — without one it stays failed."
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
ok "Concierge online"
|
||||
|
||||
# ─── 3. Gate: the platform MCP create_workspace tool must actually be present ──
|
||||
log "Probing the concierge's MCP tool set for create_workspace..."
|
||||
HAS_TOOL=$(concierge_has_create_workspace_tool "$CONCIERGE_ID")
|
||||
if [ "$HAS_TOOL" != "yes" ]; then
|
||||
skip_loud "the concierge's platform MCP does NOT expose create_workspace — it is running on the ordinary \
|
||||
claude-code image (no /opt/molecule-mcp-server), not the platform-agent image. Provision the concierge on \
|
||||
Dockerfile.platform-agent to exercise this path locally. (This is the documented SELF-HOST CAVEAT, not a bug.)"
|
||||
fi
|
||||
ok "Concierge advertises create_workspace via its platform MCP"
|
||||
|
||||
# Pre-state: the worker must not already exist.
|
||||
PRE_EXISTING=$(find_worker_by_name)
|
||||
[ -n "$PRE_EXISTING" ] && fail "worker '$WORKER_NAME' already exists pre-test ($PRE_EXISTING) — cannot prove causality"
|
||||
ok "Pre-state confirmed: '$WORKER_NAME' does not exist yet"
|
||||
|
||||
# ─── 4. Drive the AGENT via A2A message/send ──────────────────────────────────
|
||||
log "Sending the concierge a natural-language create-workspace request..."
|
||||
AGENT_PROMPT="Please create a new workspace in this org right now using your platform tools. \
|
||||
Use the create_workspace tool with name exactly ${WORKER_NAME} (use that exact string, no quotes) and role engineer. \
|
||||
Do not ask me any clarifying questions — the name and role are final. \
|
||||
After the tool succeeds, reply with the new workspace id."
|
||||
export AGENT_PROMPT
|
||||
A2A_PAYLOAD=$(python3 -c "
|
||||
import json, os, uuid
|
||||
print(json.dumps({
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'message/send',
|
||||
'id': 'e2e-cncrg-mk-local-1',
|
||||
'params': {
|
||||
'message': {
|
||||
'role': 'user',
|
||||
'messageId': f'e2e-{uuid.uuid4().hex[:8]}',
|
||||
'parts': [{'kind': 'text', 'text': os.environ['AGENT_PROMPT']}],
|
||||
}
|
||||
}
|
||||
}))")
|
||||
|
||||
A2A_TMP=$(mktemp -t cncrg-mk-local-XXXXXX)
|
||||
set +e
|
||||
A2A_CODE=$(curl -sS --max-time "$AGENT_ACT_SECS" -X POST "$BASE/workspaces/$CONCIERGE_ID/a2a" \
|
||||
${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$A2A_PAYLOAD" -o "$A2A_TMP" -w '%{http_code}' 2>/dev/null)
|
||||
A2A_RC=$?
|
||||
set -e
|
||||
A2A_CODE=${A2A_CODE:-000}
|
||||
A2A_RESP=$(cat "$A2A_TMP" 2>/dev/null || echo "")
|
||||
rm -f "$A2A_TMP"
|
||||
if [ "$A2A_RC" != "0" ] || [ "$A2A_CODE" -lt 200 ] || [ "$A2A_CODE" -ge 300 ]; then
|
||||
fail "A2A POST /workspaces/$CONCIERGE_ID/a2a failed (curl_rc=$A2A_RC, http=$A2A_CODE): $(echo "$A2A_RESP" | head -c 400)"
|
||||
fi
|
||||
AGENT_TEXT=$(echo "$A2A_RESP" | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
parts = (d.get('result') or {}).get('parts', []) if isinstance(d, dict) else []
|
||||
print(parts[0].get('text','') if parts else '')" 2>/dev/null || echo "")
|
||||
log " concierge replied (first 300 chars): $(echo "$AGENT_TEXT" | head -c 300)"
|
||||
|
||||
# ─── 5. ASSERT the deterministic side effect: the worker now EXISTS ───────────
|
||||
log "Polling GET /workspaces for the worker the concierge was asked to create..."
|
||||
ACT_DEADLINE=$(( $(date +%s) + AGENT_ACT_SECS ))
|
||||
while true; do
|
||||
WORKER_ID=$(find_worker_by_name)
|
||||
[ -n "$WORKER_ID" ] && break
|
||||
if [ "$(date +%s)" -gt "$ACT_DEADLINE" ]; then
|
||||
if hit=$(a2a_completion_error_marker "$AGENT_TEXT"); then
|
||||
fail "TOOL FAILED: concierge surfaced an error-as-text reply (matched '$hit') and no workspace '$WORKER_NAME' was created. Reply: $(echo "$AGENT_TEXT" | head -c 400)"
|
||||
fi
|
||||
fail "AGENT DID NOT ACT: concierge replied but no workspace named '$WORKER_NAME' exists after ${AGENT_ACT_SECS}s — its LLM did not invoke create_workspace. Reply: $(echo "$AGENT_TEXT" | head -c 400)"
|
||||
fi
|
||||
sleep 6
|
||||
done
|
||||
ok "DETERMINISTIC SIDE EFFECT CONFIRMED: workspace '$WORKER_NAME' now EXISTS (id=$WORKER_ID)"
|
||||
|
||||
WORKER_KIND=$(ws_field "$WORKER_ID" kind)
|
||||
if [ -n "$WORKER_KIND" ] && [ "$WORKER_KIND" != "workspace" ]; then
|
||||
fail "created node '$WORKER_NAME' has kind='$WORKER_KIND' (want 'workspace')"
|
||||
fi
|
||||
ok "Created node is a real kind='workspace' row"
|
||||
|
||||
ok "═══ LOCAL CONCIERGE CREATES-A-WORKSPACE E2E PASSED ═══"
|
||||
log "Proven locally: a natural-language A2A request → the concierge's LLM invoked create_workspace via the platform MCP → real workspace '$WORKER_NAME' (id=$WORKER_ID). Teardown runs via EXIT trap."
|
||||
+570
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env bash
|
||||
# MANDATORY local Docker-provisioner lifecycle e2e.
|
||||
#
|
||||
# Why this exists: every other e2e exercises the SaaS/EC2 (control-plane)
|
||||
# provisioner. NOTHING mandatory exercises the LOCAL Docker provisioner
|
||||
# (MOLECULE_ENV=development, docker.sock) — the path self-hosters and dev runs
|
||||
# use. A config-volume bug where a restarted workspace couldn't find its
|
||||
# config.yaml (and wedged in 'failed' with "config volume is empty") went
|
||||
# undetected for exactly this reason. This test provisions a REAL workspace via
|
||||
# the LOCAL provisioner and asserts the full lifecycle, INCLUDING the
|
||||
# restart-survival assertion that would have caught that bug.
|
||||
#
|
||||
# Steps (each asserts loudly):
|
||||
# 1. Build + tag the stub runtime image to the provisioner's RegistryModeLocal
|
||||
# cache tag so runtime=claude-code resolves to the stub (cache-hit, no
|
||||
# 2.5GB build).
|
||||
# 2. POST /workspaces (runtime=claude-code) — capture id.
|
||||
# 3. Poll GET /workspaces/{id} until status==online (<=90s); assert a ws-<id>
|
||||
# container is running.
|
||||
# 4. RESTART-SURVIVAL: POST /workspaces/{id}/restart, poll until online AGAIN
|
||||
# (<=90s); assert the container is back and the workspace did NOT wedge in
|
||||
# failed / "config volume is empty". <-- the key assertion.
|
||||
# 5. PROXY REACH: POST an A2A message/send through the PLATFORM proxy
|
||||
# (/workspaces/{id}/a2a); assert 200 + the stub's canned reply (proves the
|
||||
# ws-<id>:8000 Docker-DNS rewrite path works end-to-end).
|
||||
# 6. Cleanup: delete the workspace (trap removes its container + volumes).
|
||||
#
|
||||
# Parameterizable: LIFECYCLE_RUNTIME_IMAGE selects which image the provisioner
|
||||
# resolves to. Default = the freshly-built stub. Point it at the real image
|
||||
# (e.g. molecule-local/workspace-template-claude-code:2ac9678422a5) for an
|
||||
# advisory lifecycle-only run (the proxy-reach step then asserts reachability,
|
||||
# not the canned text — a real LLM-less runtime can't produce "STUB OK").
|
||||
#
|
||||
# Run (stub, default — fast, no LLM):
|
||||
# BASE=http://localhost:8080 ADMIN_TOKEN=dev-local-admin-token \
|
||||
# bash tests/e2e/test_local_provision_lifecycle_e2e.sh
|
||||
#
|
||||
# Run (REAL MiniMax LLM round-trip — cheapest real model; asserts a real reply):
|
||||
# BASE=http://localhost:8080 ADMIN_TOKEN=dev-local-admin-token \
|
||||
# LIFECYCLE_LLM=minimax MINIMAX_API_KEY=<key> \
|
||||
# bash tests/e2e/test_local_provision_lifecycle_e2e.sh
|
||||
# (MINIMAX_API_KEY missing => loud skip exit 0; key is only ever sent in the
|
||||
# secret-write curl body, never echoed or written to disk.)
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/_lib.sh" # sets BASE default + admin-auth + cleanup helpers
|
||||
|
||||
# ---- config -----------------------------------------------------------------
|
||||
ADMIN_TOKEN="${ADMIN_TOKEN:-${MOLECULE_ADMIN_TOKEN:-}}"
|
||||
export ADMIN_TOKEN MOLECULE_ADMIN_TOKEN="${ADMIN_TOKEN}"
|
||||
|
||||
# Was ONLINE_TIMEOUT set by the caller? Remember before we default it so the
|
||||
# minimax mode (heavier real-template boot) can bump the default without
|
||||
# clobbering an explicit operator/CI override.
|
||||
ONLINE_TIMEOUT_EXPLICIT=0
|
||||
[ -n "${ONLINE_TIMEOUT:-}" ] && ONLINE_TIMEOUT_EXPLICIT=1
|
||||
ONLINE_TIMEOUT="${ONLINE_TIMEOUT:-90}" # seconds to wait for online
|
||||
A2A_TIMEOUT="${A2A_TIMEOUT:-30}"
|
||||
STUB_DIR="$(cd "$(dirname "$0")/stub-runtime" && pwd)"
|
||||
RUNTIME="claude-code"
|
||||
|
||||
# The provisioner's RegistryModeLocal resolves runtime=claude-code by checking
|
||||
# the local image store for molecule-local/workspace-template-claude-code:<sha12>
|
||||
# (the Gitea HEAD sha12 of the template repo's `main` branch — see
|
||||
# provisioner/localbuild.go EnsureLocalImage). If that tag is missing it
|
||||
# clones+builds the real 2.5GB template (slow + can OOM-kill in CI). We pre-tag
|
||||
# our chosen image to that EXACT cache tag so the cache-check (dockerHasTag)
|
||||
# hits and resolves to our image with no clone/build.
|
||||
#
|
||||
# The sha MOVES as the template repo advances, so we DISCOVER it at runtime from
|
||||
# the same Gitea branch API the provisioner uses (CACHE_SHA), and only fall back
|
||||
# to a pinned default (or an explicit CACHE_TAG override) when Gitea is
|
||||
# unreachable. This keeps the test correct without an annual sha bump.
|
||||
CACHE_REPO="molecule-local/workspace-template-${RUNTIME}"
|
||||
GITEA_BRANCH_API="${GITEA_BRANCH_API:-https://git.moleculesai.app/api/v1/repos/molecule-ai/molecule-ai-workspace-template-${RUNTIME}/branches/main}"
|
||||
# Model + credential choice — three coupled constraints from workspace-server:
|
||||
# * Create rejects a model NOT registered for the runtime
|
||||
# (UNREGISTERED_MODEL_FOR_RUNTIME, provider-registry SSOT).
|
||||
# * The SLASH form (anthropic/claude-opus-4-7) derives provider=platform =>
|
||||
# platform_managed billing, which ABORTS provisioning in a dev stack with
|
||||
# no CP proxy env (MISSING_PLATFORM_PROXY, #2162).
|
||||
# * The BARE form (claude-opus-4-7) derives provider=anthropic-api => BYOK,
|
||||
# which then FAILS CLOSED unless the workspace has a usable LLM credential
|
||||
# (MISSING_BYOK_CREDENTIAL). anthropic-api's auth_env is
|
||||
# [ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN] — so we pass a DUMMY
|
||||
# ANTHROPIC_API_KEY secret. The stub never makes an LLM call, so the dummy
|
||||
# value is fine; it only needs to exist so byok resolves with a usable cred.
|
||||
# This keeps the test self-contained (no platform-proxy env required) — exactly
|
||||
# the portable shape the CI required job needs.
|
||||
LIFECYCLE_MODEL="${LIFECYCLE_MODEL:-claude-opus-4-7}"
|
||||
LIFECYCLE_LLM_KEY="${LIFECYCLE_LLM_KEY:-ANTHROPIC_API_KEY}"
|
||||
LIFECYCLE_LLM_VALUE="${LIFECYCLE_LLM_VALUE:-sk-ant-e2e-stub-dummy-not-a-real-key}"
|
||||
LATEST_TAG="${CACHE_REPO}:latest"
|
||||
|
||||
# ---- LIFECYCLE_LLM: real-LLM round-trip mode -------------------------------
|
||||
# Default "" = the existing behaviour (stub or LLM-less real image).
|
||||
#
|
||||
# LIFECYCLE_LLM=minimax — provision the REAL claude-code template image with a
|
||||
# MiniMax BYOK credential and assert an ACTUAL model reply at the proxy-reach
|
||||
# step (Step 5), proving a genuine round-trip through the ws-<id>:8000 proxy.
|
||||
#
|
||||
# Why MiniMax: it's the cheapest LLM the platform offers (the staging canaries'
|
||||
# primary auth path post-2026-05-04). The claude-code adapter's `minimax`
|
||||
# provider (providers.yaml:258) reads MINIMAX_API_KEY at boot and points
|
||||
# ANTHROPIC_BASE_URL at api.minimax.io/anthropic — MiniMax's OWN API, NOT the
|
||||
# molecule LLM proxy — so a BYOK MiniMax workspace reaches the model DIRECTLY
|
||||
# and works on this local dev stack with no CP proxy env.
|
||||
#
|
||||
# The registered claude-code slug is the BARE id `MiniMax-M2.7` (derives
|
||||
# provider=minimax => byok). The colon form `minimax:MiniMax-M2.7` is
|
||||
# UNREGISTERED on claude-code (internal#718). auth_env for `minimax` accepts
|
||||
# MINIMAX_API_KEY, which the adapter projects into ANTHROPIC_AUTH_TOKEN.
|
||||
#
|
||||
# The real key MUST be supplied via the MINIMAX_API_KEY env var (never echoed
|
||||
# or written to disk by this script — it only travels in the secret-write curl
|
||||
# body, exactly like the dummy ANTHROPIC_API_KEY does today). Missing key =>
|
||||
# loud skip (exit 0), never a red fail (mirrors the serving-e2e pattern).
|
||||
LIFECYCLE_LLM="${LIFECYCLE_LLM:-}"
|
||||
if [ "$LIFECYCLE_LLM" = "minimax" ]; then
|
||||
if [ -z "${MINIMAX_API_KEY:-}" ]; then
|
||||
echo "SKIP: LIFECYCLE_LLM=minimax but MINIMAX_API_KEY is not set in the env."
|
||||
echo " Provide a real MiniMax key (the advisory CI job reads it from a"
|
||||
echo " CI secret) to run the real-LLM round-trip. Skipping (exit 0)."
|
||||
exit 0
|
||||
fi
|
||||
# Real claude-code template build (provisioner resolves+builds via
|
||||
# RegistryModeLocal — same path as the advisory lifecycle-real job).
|
||||
LIFECYCLE_PROVISIONER_BUILDS="1"
|
||||
# Registered BYOK MiniMax slug for claude-code (bare id => provider=minimax).
|
||||
LIFECYCLE_MODEL="MiniMax-M2.7"
|
||||
LIFECYCLE_LLM_KEY="MINIMAX_API_KEY"
|
||||
LIFECYCLE_LLM_VALUE="${MINIMAX_API_KEY}"
|
||||
# The real template boot is heavier than the stub; give it room (unless the
|
||||
# caller pinned ONLINE_TIMEOUT explicitly).
|
||||
[ "$ONLINE_TIMEOUT_EXPLICIT" -eq 0 ] && ONLINE_TIMEOUT=180
|
||||
fi
|
||||
|
||||
# Image the provisioner should actually run. Default: build the stub. Override
|
||||
# to a real image (a pre-built tag) for the advisory lifecycle-only run.
|
||||
LIFECYCLE_RUNTIME_IMAGE="${LIFECYCLE_RUNTIME_IMAGE:-__BUILD_STUB__}"
|
||||
|
||||
# LIFECYCLE_PROVISIONER_BUILDS=1: do NOT pre-tag any image — let the provisioner
|
||||
# resolve runtime=claude-code itself via RegistryModeLocal (clone + docker build
|
||||
# the real template). This exercises the GENUINE local image-resolution path end
|
||||
# to end. Used by the advisory CI job. Implies the real (LLM-less) runtime, so
|
||||
# the proxy-reach step asserts reachability, not a canned reply.
|
||||
LIFECYCLE_PROVISIONER_BUILDS="${LIFECYCLE_PROVISIONER_BUILDS:-0}"
|
||||
|
||||
# When NOT running the stub we cannot assert the canned "STUB OK" text (no LLM);
|
||||
# we assert reachability/registration instead.
|
||||
USING_STUB=1
|
||||
[ "$LIFECYCLE_RUNTIME_IMAGE" != "__BUILD_STUB__" ] && USING_STUB=0
|
||||
[ "$LIFECYCLE_PROVISIONER_BUILDS" = "1" ] && USING_STUB=0
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WSID=""
|
||||
# May be pre-pinned via env; otherwise resolved from the Gitea HEAD sha in Step 1.
|
||||
CACHE_TAG="${CACHE_TAG:-}"
|
||||
# Remember the tags/images we mutated so the trap can restore the cache tag to
|
||||
# the real image (so a stub run never leaves the real claude-code tag pointing
|
||||
# at the lightweight stub for the next developer/CI job).
|
||||
ORIG_CACHE_IMAGE_ID=""
|
||||
|
||||
check() {
|
||||
local desc="$1" expected="$2" actual="$3"
|
||||
if echo "$actual" | grep -qF -- "$expected"; then
|
||||
echo "PASS: $desc"; PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $desc"
|
||||
echo " expected to contain: $expected"
|
||||
echo " got: $(echo "$actual" | head -5)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo "FAIL: $1"; [ -n "${2:-}" ] && echo " $2"; FAIL=$((FAIL + 1)); }
|
||||
|
||||
admin_curl() {
|
||||
local _a=(); e2e_admin_auth_args _a
|
||||
curl -s "${_a[@]+"${_a[@]}"}" "$@"
|
||||
}
|
||||
|
||||
ws_field() { # ws_field <workspace-json> <field>
|
||||
echo "$1" | python3 -c "import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin); print(d.get('$2',''))
|
||||
except Exception:
|
||||
print('')"
|
||||
}
|
||||
|
||||
container_running() { # container_running <ws-id> -> echoes name if running
|
||||
local short="${1:0:12}"
|
||||
docker ps --filter "name=ws-${short}" --filter "status=running" --format '{{.Names}}' 2>/dev/null | head -1
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local rc=$?
|
||||
echo ""
|
||||
echo "--- cleanup ---"
|
||||
if [ -n "$WSID" ]; then
|
||||
# SCOPED teardown — only the workspace this test created. Never a blanket
|
||||
# sweep (other dev workspaces may be live on this shared daemon).
|
||||
e2e_delete_workspace "$WSID" "" >/dev/null 2>&1 || true
|
||||
local short="${WSID:0:12}"
|
||||
docker rm -f "ws-${short}" >/dev/null 2>&1 || true
|
||||
# Volume naming is split in the provisioner: configs + claude-sessions use the
|
||||
# 12-char short id (ConfigVolumeName/ClaudeSessionVolumeName), but the
|
||||
# /workspace volume uses the FULL UUID (buildWorkspaceMount: ws-<id>-workspace).
|
||||
# Remove BOTH forms so neither leaks.
|
||||
docker volume rm -f \
|
||||
"ws-${short}-configs" "ws-${short}-claude-sessions" \
|
||||
"ws-${short}-workspace" "ws-${WSID}-workspace" >/dev/null 2>&1 || true
|
||||
echo "cleaned workspace $WSID + ws-${short} container/volumes"
|
||||
fi
|
||||
# Restore the cache tag to whatever it pointed at before we retagged it, so a
|
||||
# stub run doesn't leave the real claude-code tag aliased to the stub.
|
||||
if [ -n "$ORIG_CACHE_IMAGE_ID" ]; then
|
||||
docker tag "$ORIG_CACHE_IMAGE_ID" "$CACHE_TAG" >/dev/null 2>&1 || true
|
||||
echo "restored $CACHE_TAG -> ${ORIG_CACHE_IMAGE_ID:0:19}"
|
||||
fi
|
||||
exit $rc
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "=== Local Docker-Provisioner Lifecycle E2E ==="
|
||||
echo "BASE=$BASE runtime=$RUNTIME using_stub=$USING_STUB llm=${LIFECYCLE_LLM:-none} model=$LIFECYCLE_MODEL cache_tag=${CACHE_TAG:-<resolve-in-step-1>}"
|
||||
echo ""
|
||||
|
||||
# Preflight: docker must be reachable and the platform must be up.
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "ERROR: docker daemon not reachable — this test provisions local containers."
|
||||
exit 2
|
||||
fi
|
||||
if ! curl -s -m 5 "$BASE/workspaces" >/dev/null 2>&1; then
|
||||
echo "ERROR: platform not reachable at $BASE"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Step 1 — build/tag the image the provisioner will resolve to.
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "--- Step 1: resolve runtime image to the chosen target ---"
|
||||
# Resolve the EXACT cache tag the provisioner will look up: <repo>:<gitea-HEAD-
|
||||
# sha12>. Discover the sha from the Gitea branch API (same source the provisioner
|
||||
# uses). An explicit CACHE_TAG env overrides discovery; if Gitea is unreachable
|
||||
# AND no override is set, bail loudly — silently tagging the wrong sha would let
|
||||
# the provisioner clone+build the real 2.5GB template (slow / OOM).
|
||||
if [ -n "${CACHE_TAG:-}" ]; then
|
||||
echo "Using operator-pinned CACHE_TAG=$CACHE_TAG"
|
||||
else
|
||||
CACHE_SHA=$(curl -s -m 10 "$GITEA_BRANCH_API" 2>/dev/null \
|
||||
| python3 -c "import sys,json
|
||||
try:
|
||||
print(json.load(sys.stdin)['commit']['id'][:12])
|
||||
except Exception:
|
||||
print('')" 2>/dev/null)
|
||||
if [ -z "$CACHE_SHA" ]; then
|
||||
echo "ERROR: could not resolve the template HEAD sha from $GITEA_BRANCH_API"
|
||||
echo " set CACHE_TAG=$CACHE_REPO:<sha12> explicitly (the tag the provisioner expects)."
|
||||
exit 2
|
||||
fi
|
||||
CACHE_TAG="${CACHE_REPO}:${CACHE_SHA}"
|
||||
echo "Resolved provisioner cache tag: $CACHE_TAG (gitea HEAD sha)"
|
||||
fi
|
||||
|
||||
# Record what the cache tag points at NOW (if anything) so cleanup can restore.
|
||||
ORIG_CACHE_IMAGE_ID="$(docker image inspect --format '{{.Id}}' "$CACHE_TAG" 2>/dev/null || true)"
|
||||
|
||||
if [ "$LIFECYCLE_PROVISIONER_BUILDS" = "1" ]; then
|
||||
# No pre-tag — the provisioner resolves + builds the real template itself via
|
||||
# RegistryModeLocal. Disarm the cache-tag restore (we never touched it).
|
||||
ORIG_CACHE_IMAGE_ID=""
|
||||
pass "provisioner-builds mode: leaving image resolution to RegistryModeLocal (real template build)"
|
||||
elif [ "$USING_STUB" -eq 1 ]; then
|
||||
echo "Building stub image from $STUB_DIR ..."
|
||||
if ! docker build --platform=linux/amd64 -t molecule-local/stub-runtime:latest "$STUB_DIR" >/tmp/stub_build.log 2>&1; then
|
||||
echo "FAIL: stub image build failed"; tail -20 /tmp/stub_build.log; exit 1
|
||||
fi
|
||||
pass "stub image built"
|
||||
TARGET_IMAGE="molecule-local/stub-runtime:latest"
|
||||
# Point BOTH the sha-pinned cache tag and :latest at the stub so the
|
||||
# provisioner's RegistryModeLocal cache-check (dockerHasTag) resolves to it
|
||||
# instead of cloning+building the template.
|
||||
docker tag "$TARGET_IMAGE" "$CACHE_TAG"
|
||||
docker tag "$TARGET_IMAGE" "$LATEST_TAG"
|
||||
pass "tagged $TARGET_IMAGE -> $CACHE_TAG (+ :latest)"
|
||||
else
|
||||
TARGET_IMAGE="$LIFECYCLE_RUNTIME_IMAGE"
|
||||
if ! docker image inspect "$TARGET_IMAGE" >/dev/null 2>&1; then
|
||||
echo "Real image $TARGET_IMAGE not present locally — pulling ..."
|
||||
docker pull "$TARGET_IMAGE" >/dev/null 2>&1 || { echo "FAIL: cannot obtain $TARGET_IMAGE"; exit 1; }
|
||||
fi
|
||||
pass "using real runtime image $TARGET_IMAGE"
|
||||
docker tag "$TARGET_IMAGE" "$CACHE_TAG"
|
||||
docker tag "$TARGET_IMAGE" "$LATEST_TAG"
|
||||
pass "tagged $TARGET_IMAGE -> $CACHE_TAG (+ :latest)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Step 2 — provision a workspace via the real create endpoint.
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "--- Step 2: provision workspace (POST /workspaces) ---"
|
||||
# Provision-time billing on this dev stack (no CP proxy env):
|
||||
# * A claude-code workspace with a BARE model id derives provider=anthropic-api
|
||||
# => BYOK, which FAILS CLOSED in prepare unless a usable LLM credential
|
||||
# exists (MISSING_BYOK_CREDENTIAL).
|
||||
# * The per-workspace secret-write guard blocks a vendor key while the
|
||||
# workspace still resolves platform-managed (the MODEL secret isn't stored
|
||||
# until AFTER payload.secrets are written at create time) — so we can't pass
|
||||
# the key in the create payload.
|
||||
# So: create WITHOUT secrets, flip the workspace to byok (explicit override wins
|
||||
# in BOTH the guard's resolver and the provision resolver), then write the dummy
|
||||
# vendor key — now permitted. We do NOT rely on Create's first provision to seed
|
||||
# the config volume (it aborts byok-no-cred BEFORE Start, leaving the volume
|
||||
# empty). Instead we SEED config.yaml directly into the named config volume and
|
||||
# then trigger ONE clean provision via /restart. Seeding the volume is also what
|
||||
# makes the restart-survival assertion meaningful: the restart path reuses the
|
||||
# volume rather than any template.
|
||||
CREATE_BODY=$(cat <<JSON
|
||||
{"name":"Lifecycle E2E Stub","tier":2,"runtime":"$RUNTIME","model":"$LIFECYCLE_MODEL"}
|
||||
JSON
|
||||
)
|
||||
RESP=$(admin_curl -X POST "$BASE/workspaces" -H "Content-Type: application/json" -d "$CREATE_BODY")
|
||||
WSID=$(ws_field "$RESP" "id")
|
||||
if [ -z "$WSID" ]; then
|
||||
fail "create returned no workspace id" "$RESP"
|
||||
echo "=== Results: $PASS passed, $((FAIL+1)) failed ==="
|
||||
exit 1
|
||||
fi
|
||||
pass "workspace created: $WSID"
|
||||
SHORT="${WSID:0:12}"
|
||||
CONFIG_VOL="ws-${SHORT}-configs"
|
||||
|
||||
# Mint a workspace bearer for the WorkspaceAuth-gated secret + /restart calls.
|
||||
WTOKEN=$(e2e_mint_workspace_token "$WSID" || true)
|
||||
if [ -z "$WTOKEN" ]; then
|
||||
fail "could not mint workspace token"
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="; exit 1
|
||||
fi
|
||||
|
||||
# Flip to byok BEFORE writing the vendor key (explicit override unblocks the
|
||||
# secret-write guard AND makes the provision resolver pick byok).
|
||||
BM=$(admin_curl -X PUT "$BASE/admin/workspaces/$WSID/llm-billing-mode" \
|
||||
-H "Content-Type: application/json" -d '{"mode":"byok"}')
|
||||
check "billing mode set to byok" "byok" "$BM"
|
||||
|
||||
# Write the dummy LLM credential (now allowed on a byok workspace). Inert — the
|
||||
# stub never calls an LLM; it only needs to exist so byok has a usable cred.
|
||||
SEC=$(curl -s -X POST "$BASE/workspaces/$WSID/secrets" \
|
||||
-H "Authorization: Bearer $WTOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"key\":\"$LIFECYCLE_LLM_KEY\",\"value\":\"$LIFECYCLE_LLM_VALUE\"}")
|
||||
echo " secret write: $(echo "$SEC" | head -c 120)"
|
||||
|
||||
# In minimax mode also write MODEL_PROVIDER=minimax as a secret env. The
|
||||
# claude-code adapter's _resolve_model_and_provider_from_env honours
|
||||
# MODEL_PROVIDER ONLY when it matches a registered provider name (else it's
|
||||
# treated as a legacy model-id), so a literal "minimax" routes the workspace to
|
||||
# the `minimax` provider entry — projecting MINIMAX_API_KEY → ANTHROPIC_AUTH_TOKEN
|
||||
# and setting ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic. workspace-
|
||||
# server injects MODEL/MOLECULE_MODEL from the picked slug but NO LONGER emits
|
||||
# MODEL_PROVIDER (applyRuntimeModelEnv, post-2026-05-19), so this secret-provided
|
||||
# value survives into the container env. Without it a BARE `MiniMax-M2.7` derives
|
||||
# no provider and falls through to the anthropic-api default (boot banner
|
||||
# "provider=anthropic-api", base_url unset → AuthenticationError on the first
|
||||
# call → the "Agent error" this mode exists to catch).
|
||||
if [ "$LIFECYCLE_LLM" = "minimax" ]; then
|
||||
SECP=$(curl -s -X POST "$BASE/workspaces/$WSID/secrets" \
|
||||
-H "Authorization: Bearer $WTOKEN" -H "Content-Type: application/json" \
|
||||
-d '{"key":"MODEL_PROVIDER","value":"minimax"}')
|
||||
echo " secret write (MODEL_PROVIDER): $(echo "$SECP" | head -c 120)"
|
||||
fi
|
||||
|
||||
# Seed config.yaml directly into the named config volume so the provision (and
|
||||
# every later restart) has a config source. Create's byok-no-cred abort never
|
||||
# wrote it, and this dev stack ships no claude-code template in the platform's
|
||||
# configsDir for the empty-volume auto-recover to fall back to. The provisioner
|
||||
# created the volume on its first (aborted) Start attempt; ensure it exists,
|
||||
# then drop a minimal valid config.yaml in via a throwaway alpine container.
|
||||
docker volume create "$CONFIG_VOL" >/dev/null 2>&1 || true
|
||||
# In minimax mode the seeded config MUST carry an explicit `provider: minimax`.
|
||||
# The claude-code adapter (and the molecule_runtime wheel's
|
||||
# _derive_provider_from_model) only auto-derive a provider from a `vendor:model`
|
||||
# or `vendor/model` slug — a BARE `MiniMax-M2.7` derives no provider and falls
|
||||
# through to the anthropic-api default (boot banner: "provider=anthropic-api",
|
||||
# ANTHROPIC_BASE_URL unset → the MiniMax key is never projected and the first
|
||||
# LLM call fails with AuthenticationError). Naming the provider explicitly makes
|
||||
# the adapter pick the `minimax` registry entry, project
|
||||
# MINIMAX_API_KEY → ANTHROPIC_AUTH_TOKEN, and set
|
||||
# ANTHROPIC_BASE_URL=https://api.minimax.io/anthropic — a real round-trip.
|
||||
LIFECYCLE_PROVIDER_LINE=""
|
||||
[ "$LIFECYCLE_LLM" = "minimax" ] && LIFECYCLE_PROVIDER_LINE="provider: minimax"
|
||||
CFG_YAML="name: ${WSID}
|
||||
description: lifecycle e2e
|
||||
version: 1.0.0
|
||||
tier: 2
|
||||
runtime: ${RUNTIME}
|
||||
model: ${LIFECYCLE_MODEL}
|
||||
runtime_config:
|
||||
model: ${LIFECYCLE_MODEL}
|
||||
${LIFECYCLE_PROVIDER_LINE}
|
||||
timeout: 0
|
||||
"
|
||||
if docker run --rm -v "${CONFIG_VOL}:/configs" alpine:3 sh -c "cat > /configs/config.yaml" <<EOF >/dev/null 2>&1
|
||||
${CFG_YAML}
|
||||
EOF
|
||||
then pass "seeded config.yaml into $CONFIG_VOL"; else fail "could not seed config.yaml into $CONFIG_VOL"; fi
|
||||
echo ""
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Step 3 — provision (via restart) and wait for online; assert container.
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "--- Step 3: provision + wait for first online (<=${ONLINE_TIMEOUT}s) ---"
|
||||
# Kick ONE clean provision now that byok + cred + config.yaml are all in place.
|
||||
curl -s -X POST "$BASE/workspaces/$WSID/restart" \
|
||||
-H "Authorization: Bearer $WTOKEN" -H "Content-Type: application/json" -d '{}' >/dev/null
|
||||
STATUS=""; LAST=""; failed_since=0
|
||||
for _ in $(seq 1 "$ONLINE_TIMEOUT"); do
|
||||
WS=$(admin_curl "$BASE/workspaces/$WSID")
|
||||
STATUS=$(ws_field "$WS" "status")
|
||||
LAST=$(ws_field "$WS" "last_sample_error")
|
||||
if [ "$STATUS" = "online" ]; then break; fi
|
||||
if [ "$STATUS" = "failed" ]; then
|
||||
failed_since=$((failed_since + 1))
|
||||
# A restart re-kicks provisioning; give the coalescing pipeline room to
|
||||
# converge. Only bail if it stays failed for 20s straight.
|
||||
if [ "$failed_since" -ge 20 ]; then
|
||||
fail "workspace STUCK in 'failed' during initial provision" "last_sample_error: $LAST"
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="; exit 1
|
||||
fi
|
||||
else
|
||||
failed_since=0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
check "workspace reached online (status=$STATUS)" "online" "$STATUS"
|
||||
RUN=$(container_running "$WSID")
|
||||
if [ -n "$RUN" ]; then pass "container running: $RUN"; else fail "no running ws-${WSID:0:12} container" "docker ps shows none"; fi
|
||||
echo ""
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Step 4 — RESTART-SURVIVAL (the assertion that would have caught the bug).
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "--- Step 4: restart-survival (POST /workspaces/$WSID/restart) ---"
|
||||
# Re-mint the workspace bearer: every (re)provision rotates the workspace token
|
||||
# (issueAndInjectToken -> RevokeAllForWorkspace + IssueToken), so the Step-2
|
||||
# token is now stale. /restart is WorkspaceAuth-gated, so mint a fresh one.
|
||||
WTOKEN=$(e2e_mint_workspace_token "$WSID" || true)
|
||||
if [ -z "$WTOKEN" ]; then
|
||||
fail "could not mint fresh workspace token for restart"
|
||||
else
|
||||
RR=$(curl -s -X POST "$BASE/workspaces/$WSID/restart" \
|
||||
-H "Authorization: Bearer $WTOKEN" -H "Content-Type: application/json" -d '{}')
|
||||
check "restart accepted (provisioning)" "provisioning" "$RR"
|
||||
|
||||
# Poll until online AGAIN. Restart reuses the EXISTING config volume (no
|
||||
# template/configFiles passed) — so this passes ONLY if the config volume
|
||||
# survived the stop and still has config.yaml. A regression (volume reaped /
|
||||
# emptied) surfaces as status=failed with the "config volume is empty" error.
|
||||
STATUS=""; LAST=""
|
||||
for _ in $(seq 1 "$ONLINE_TIMEOUT"); do
|
||||
WS=$(admin_curl "$BASE/workspaces/$WSID")
|
||||
STATUS=$(ws_field "$WS" "status")
|
||||
LAST=$(ws_field "$WS" "last_sample_error")
|
||||
case "$STATUS" in
|
||||
online) break ;;
|
||||
failed)
|
||||
fail "workspace wedged in 'failed' AFTER restart (the config-volume bug class)" "last_sample_error: $LAST"
|
||||
break ;;
|
||||
esac
|
||||
sleep 1
|
||||
done
|
||||
check "workspace back online after restart (status=$STATUS)" "online" "$STATUS"
|
||||
# Explicit negative on the exact bug signature.
|
||||
if echo "$LAST" | grep -qiF "config volume is empty"; then
|
||||
fail "restart hit 'config volume is empty' — restart-survival REGRESSION" "$LAST"
|
||||
else
|
||||
pass "no 'config volume is empty' error after restart"
|
||||
fi
|
||||
RUN=$(container_running "$WSID")
|
||||
if [ -n "$RUN" ]; then pass "container back after restart: $RUN"; else fail "container missing after restart"; fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Step 5 — proxy reach (ws-<id>:8000 Docker-DNS rewrite, end to end).
|
||||
# ----------------------------------------------------------------------------
|
||||
echo "--- Step 5: proxy reach (POST /workspaces/$WSID/a2a) ---"
|
||||
# In minimax mode we send a DETERMINISTIC known-answer prompt and assert the
|
||||
# model echoes the answer back — proving a real LLM round-trip, not just
|
||||
# reachability. Otherwise a plain "ping".
|
||||
if [ "$LIFECYCLE_LLM" = "minimax" ]; then
|
||||
A2A_PROMPT="Reply with exactly the single word PONG and nothing else."
|
||||
else
|
||||
A2A_PROMPT="ping"
|
||||
fi
|
||||
A2A_BODY=$(python3 -c "
|
||||
import json,sys
|
||||
print(json.dumps({'method':'message/send','params':{'message':{'role':'user','parts':[{'type':'text','text':sys.argv[1]}]}}}))
|
||||
" "$A2A_PROMPT")
|
||||
# Real LLM cold-start (first turn boots the claude-code SDK + dials MiniMax) is
|
||||
# slower than the stub; give the real-LLM call a longer ceiling.
|
||||
A2A_CEIL="$A2A_TIMEOUT"
|
||||
[ "$LIFECYCLE_LLM" = "minimax" ] && A2A_CEIL="${A2A_MINIMAX_TIMEOUT:-120}"
|
||||
A2A=$(curl -s --max-time "$A2A_CEIL" -X POST "$BASE/workspaces/$WSID/a2a" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$A2A_BODY")
|
||||
# Extract the assistant text part once (shared by the minimax assertion +
|
||||
# diagnostics). Tolerates result.parts[].text and result.message.parts[].text.
|
||||
a2a_text() {
|
||||
echo "$1" | python3 -c "import sys,json
|
||||
try:
|
||||
d=json.load(sys.stdin); r=d.get('result',d)
|
||||
m=r.get('message',r)
|
||||
parts=m.get('parts',[]) or r.get('parts',[])
|
||||
print(' '.join(p.get('text','') for p in parts if isinstance(p,dict)))
|
||||
except Exception:
|
||||
print('')"
|
||||
}
|
||||
if [ "$LIFECYCLE_LLM" = "minimax" ]; then
|
||||
# REAL round-trip assertion. The reply must be model-produced text — NOT a
|
||||
# proxy-level unreachable, NOT an LLM-less "Agent error", NOT an empty
|
||||
# completion. Then it must contain the known answer (PONG).
|
||||
check "proxy returned a result envelope" '"result"' "$A2A"
|
||||
AGENT_TEXT="$(a2a_text "$A2A")"
|
||||
echo " MiniMax reply: $(echo "$AGENT_TEXT" | head -c 200)"
|
||||
if echo "$A2A" | grep -qiE 'unreachable|workspace has no URL|restarting'; then
|
||||
fail "MiniMax runtime not reachable through proxy" "$A2A"
|
||||
elif echo "$AGENT_TEXT" | grep -qiF "message contained no text content"; then
|
||||
fail "MiniMax returned an EMPTY completion (no text part) — backend/key issue, not a real round-trip" "$AGENT_TEXT"
|
||||
elif echo "$AGENT_TEXT" | grep -qiE 'agent error|exception|invalid api key|insufficient_quota|exceeded your current quota'; then
|
||||
fail "MiniMax round-trip returned an error-shaped reply (no real completion)" "$AGENT_TEXT"
|
||||
elif echo "$AGENT_TEXT" | tr '[:lower:]' '[:upper:]' | grep -qF "PONG"; then
|
||||
pass "REAL MiniMax round-trip: model replied with the known answer (PONG)"
|
||||
else
|
||||
# Non-error, non-empty, but didn't contain PONG — still a real reply (the
|
||||
# model answered with its own words). Accept as a real round-trip but note it.
|
||||
if [ -n "$AGENT_TEXT" ]; then
|
||||
pass "REAL MiniMax round-trip: non-error model reply (did not contain PONG, but real text)"
|
||||
else
|
||||
fail "MiniMax round-trip produced no assertable text" "$A2A"
|
||||
fi
|
||||
fi
|
||||
elif [ "$USING_STUB" -eq 1 ]; then
|
||||
check "proxy returned a result envelope" '"result"' "$A2A"
|
||||
check "proxy reached stub (canned reply)" 'STUB OK' "$A2A"
|
||||
# Parse the envelope so whitespace/key-ordering doesn't break the assertion.
|
||||
ROLE=$(echo "$A2A" | python3 -c "import sys,json
|
||||
try:
|
||||
print(json.load(sys.stdin).get('result',{}).get('role',''))
|
||||
except Exception:
|
||||
print('')")
|
||||
check "reply has agent role" "agent" "$ROLE"
|
||||
else
|
||||
# Real LLM-less image: we can't get a canned text, but a reachable runtime
|
||||
# must answer with EITHER a result OR a structured JSON-RPC error — NOT a
|
||||
# proxy-level "workspace agent unreachable" / "no URL". Assert reachability.
|
||||
if echo "$A2A" | grep -qiE 'unreachable|workspace has no URL|restarting'; then
|
||||
fail "real runtime not reachable through proxy" "$A2A"
|
||||
else
|
||||
pass "real runtime reachable through proxy (got a JSON-RPC response)"
|
||||
echo " response: $(echo "$A2A" | head -c 200)"
|
||||
fi
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
exit "$FAIL"
|
||||
+459
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env bash
|
||||
# FUNCTIONAL real-LLM E2E: prove the org concierge (the platform agent) can
|
||||
# actually DO org-management work — send it a natural-language request and
|
||||
# assert it REALLY CREATES a workspace via its platform MCP (87 org-admin tools,
|
||||
# incl. create_workspace), NOT just that a REST API returned 200.
|
||||
#
|
||||
# This is the RFC docs/design/rfc-platform-agent.md §11.4 "Reach" check, made
|
||||
# into a gating CI test:
|
||||
#
|
||||
# "chat the platform agent → it list_workspaces then create_workspace via the
|
||||
# platform MCP and reports back via send_message_to_user."
|
||||
#
|
||||
# Unlike test_staging_concierge_e2e.sh (which drives the user_tasks REST+MCP
|
||||
# primitive directly — a pure DB/handler contract with NO LLM), THIS test drives
|
||||
# the AGENT: it sends an A2A message/send envelope (the user→concierge chat
|
||||
# path) and asserts the DETERMINISTIC SIDE EFFECT — a workspace with the exact
|
||||
# name we asked for now EXISTS in GET /workspaces — which can only happen if the
|
||||
# concierge's LLM actually invoked the create_workspace platform-MCP tool.
|
||||
#
|
||||
# WHAT MUST BE LIVE for this to pass GREEN (else it SKIPs LOUD, never false-red):
|
||||
# • The org's concierge must be installed as the kind='platform' root AND
|
||||
# provisioned on the DEDICATED platform-agent image (Dockerfile.platform-agent),
|
||||
# which ships /opt/molecule-mcp-server — the ONLY image where the platform MCP
|
||||
# (create_workspace) lights up. On SaaS staging the CP installs + provisions it
|
||||
# at org-provision time. (See platform_agent.go's SELF-HOST CAVEAT: the ordinary
|
||||
# claude-code image does NOT ship the platform MCP, so create_workspace is a
|
||||
# no-op there.) A parallel agent is wiring the platform-agent image into the
|
||||
# staging provision path; until that lands, this test SKIPs LOUD with a clear
|
||||
# "concierge not on platform-agent image" message rather than failing red.
|
||||
# • A working model for the concierge. On SaaS the concierge is platform_managed
|
||||
# (the CP-exported LLM proxy supplies the model) so no BYOK key is needed for
|
||||
# the concierge itself.
|
||||
#
|
||||
# Env contract (same as test_staging_concierge_e2e.sh / test_staging_full_saas.sh):
|
||||
# MOLECULE_CP_URL default: https://staging-api.moleculesai.app
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
||||
#
|
||||
# Optional env:
|
||||
# E2E_PROVISION_TIMEOUT_SECS default 900 (15 min cold tenant EC2 budget)
|
||||
# E2E_CONCIERGE_ONLINE_SECS default 900 (concierge boot-to-online budget)
|
||||
# E2E_AGENT_ACT_SECS default 420 (LLM think+tool-call budget after we
|
||||
# send the message — generous for nondeterminism)
|
||||
# E2E_KEEP_ORG 1 → skip teardown (debugging only)
|
||||
# E2E_RUN_ID slug suffix; CI: ${GITHUB_RUN_ID}-${RUN_ATTEMPT}
|
||||
# E2E_AWS_LEAK_CHECK auto (default) | required | off
|
||||
# E2E_AWS_TERMINATE_LEAKS 1 → terminate slug-tagged leaked EC2 on exit
|
||||
# E2E_REQUIRE_LIVE 1 → a SKIP for "no concierge on platform image"
|
||||
# becomes a hard FAIL (CI sets this so a silently-
|
||||
# missing platform-agent image can't false-green
|
||||
# the gate). Default 0 (local: skip-loud).
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 happy path (concierge created the workspace) OR honest skip-loud
|
||||
# 1 generic / assertion failure (agent didn't act, or tool failed)
|
||||
# 2 missing required env
|
||||
# 3 provisioning timed out
|
||||
# 4 teardown left orphan resources
|
||||
# 5 E2E_REQUIRE_LIVE=1 but the concierge could not be exercised (no
|
||||
# platform-agent image / never came online) — false-green guard
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=_lib.sh
|
||||
source "$(dirname "$0")/_lib.sh"
|
||||
# AWS-leak-check lib — same teardown leak assertion the full-SaaS harness uses.
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=lib/aws_leak_check.sh
|
||||
source "$(dirname "$0")/lib/aws_leak_check.sh"
|
||||
# Real-completion error-as-text scanner — used to detect the concierge
|
||||
# surfacing its tool/LLM error AS a reply ("Agent error …") so a broken agent
|
||||
# can't read as "asked but politely declined".
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=lib/completion_assert.sh
|
||||
source "$(dirname "$0")/lib/completion_assert.sh"
|
||||
|
||||
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
|
||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
|
||||
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
|
||||
CONCIERGE_ONLINE_SECS="${E2E_CONCIERGE_ONLINE_SECS:-900}"
|
||||
AGENT_ACT_SECS="${E2E_AGENT_ACT_SECS:-420}"
|
||||
REQUIRE_LIVE="${E2E_REQUIRE_LIVE:-0}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
|
||||
# Fixed e2e- prefix so sweep-stale-e2e-orgs.yml + lint_cleanup_traps.sh reap any
|
||||
# orphan org. (The lint requires a quoted SLUG=... with a literal e2e-/rt-e2e-
|
||||
# head.)
|
||||
SLUG="e2e-cncrg-mk-$(date +%Y%m%d)-${RUN_ID_SUFFIX}"
|
||||
SLUG=$(echo "$SLUG" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 32)
|
||||
|
||||
# The workspace name we will ask the concierge to create. The RUN_ID makes it
|
||||
# unique per run so a poll for it can never collide with a sibling run's name.
|
||||
WORKER_NAME="e2e-cncrg-worker-${RUN_ID_SUFFIX}"
|
||||
WORKER_NAME=$(echo "$WORKER_NAME" | tr -cd 'a-zA-Z0-9-' | head -c 48)
|
||||
# Exported so the find_worker_by_name python subshell (run in a pipe) reads it
|
||||
# via os.environ — a bare shell var would not survive into the subprocess env.
|
||||
export WORKER_NAME
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
# skip_loud <reason>: honest skip when the concierge can't be exercised. In CI
|
||||
# (E2E_REQUIRE_LIVE=1) this is a HARD FAIL (exit 5) so a missing platform-agent
|
||||
# image can't false-green the gate; locally it skips 0.
|
||||
skip_loud() {
|
||||
echo "[$(date +%H:%M:%S)] ⏭️ SKIP: $*" >&2
|
||||
if [ "$REQUIRE_LIVE" = "1" ]; then
|
||||
echo "[$(date +%H:%M:%S)] ❌ E2E_REQUIRE_LIVE=1 — a skip is a false-green guard breach here. Failing." >&2
|
||||
exit 5
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
CURL_COMMON=(-sS --max-time 30)
|
||||
TMPDIR_E2E=$(mktemp -d -t cncrg-mk-XXXXXX)
|
||||
|
||||
# ─── teardown trap (worker delete + org delete + leak check) ─────────────────
|
||||
CLEANUP_DONE=0
|
||||
WORKER_ID="" # set once the concierge creates it (for targeted delete)
|
||||
TENANT_URL="" # set after provisioning
|
||||
TENANT_TOKEN=""
|
||||
ORG_ID=""
|
||||
cleanup() {
|
||||
local entry_rc=$?
|
||||
[ "$CLEANUP_DONE" = "1" ] && return 0
|
||||
CLEANUP_DONE=1
|
||||
rm -rf "$TMPDIR_E2E" 2>/dev/null || true
|
||||
|
||||
# Best-effort targeted delete of the worker the concierge created, so the org
|
||||
# delete below isn't the only thing reaping it (defensive — org delete cascades
|
||||
# anyway). Only attempted if we resolved its id and have tenant creds.
|
||||
if [ -n "$WORKER_ID" ] && [ -n "$TENANT_URL" ] && [ -n "$TENANT_TOKEN" ]; then
|
||||
curl "${CURL_COMMON[@]}" -X DELETE "$TENANT_URL/workspaces/$WORKER_ID?confirm=true" \
|
||||
-H "Authorization: Bearer $TENANT_TOKEN" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "Origin: $TENANT_URL" \
|
||||
-H "X-Confirm-Name: $WORKER_NAME" >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
|
||||
log "E2E_KEEP_ORG=1 — skipping teardown. Manually delete $SLUG when done."
|
||||
return 0
|
||||
fi
|
||||
log "🧹 Tearing down org $SLUG..."
|
||||
if curl "${CURL_COMMON[@]}" --max-time 120 -X DELETE "$CP_URL/cp/admin/tenants/$SLUG" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1; then
|
||||
ok "Teardown request accepted"
|
||||
else
|
||||
log "Teardown returned non-2xx (may already be gone)"
|
||||
fi
|
||||
|
||||
# Eventual-consistency wait: org row gone / purged.
|
||||
local leak_count=1 elapsed=0
|
||||
while [ "$elapsed" -lt 60 ]; do
|
||||
leak_count=$(curl "${CURL_COMMON[@]}" "$CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print(sum(1 for o in d.get('orgs', []) if o.get('slug')=='$SLUG' and o.get('status') != 'purged'))" \
|
||||
2>/dev/null || echo 1)
|
||||
[ "$leak_count" = "0" ] && break
|
||||
sleep 5; elapsed=$((elapsed + 5))
|
||||
done
|
||||
if [ "$leak_count" != "0" ]; then
|
||||
echo "⚠️ LEAK: org $SLUG still present post-teardown after ${elapsed}s (count=$leak_count)" >&2
|
||||
exit 4
|
||||
fi
|
||||
local aws_leak_rc=0
|
||||
e2e_verify_no_ec2_leaks_for_slug "$SLUG" || aws_leak_rc=$?
|
||||
if [ "$aws_leak_rc" != "0" ]; then
|
||||
case "$aws_leak_rc" in 2) exit 2 ;; *) exit 4 ;; esac
|
||||
fi
|
||||
ok "Teardown clean — no orphan org or EC2 resources for $SLUG (${elapsed}s)"
|
||||
case "$entry_rc" in 0|1|2|3|4|5) ;; *) exit 1 ;; esac
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
admin_call() { # <method> <path> [curl args…]
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl "${CURL_COMMON[@]}" -X "$method" "$CP_URL$path" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" "$@"
|
||||
}
|
||||
|
||||
# tenant_call: Authorization (tenant admin token — also authenticates the
|
||||
# concierge, which holds no per-workspace token: validateDiscoveryCaller's admin
|
||||
# fallback) + X-Molecule-Org-Id (TenantGuard 404s without it) + Origin (edge WAF).
|
||||
tenant_call() { # <method> <path> [curl args…]
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl "${CURL_COMMON[@]}" -X "$method" "$TENANT_URL$path" \
|
||||
-H "Authorization: Bearer $TENANT_TOKEN" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "Origin: $TENANT_URL" "$@"
|
||||
}
|
||||
|
||||
# list_workspaces_json: echo the raw GET /workspaces JSON array (tenant-scoped).
|
||||
list_workspaces_json() { tenant_call GET /workspaces; }
|
||||
|
||||
# find_platform_root: echo the id of the kind='platform' parent_id-null root, or
|
||||
# "" if none. This IS the concierge — the org's front-door agent.
|
||||
find_platform_root() {
|
||||
list_workspaces_json | python3 -c "
|
||||
import sys, json
|
||||
try: rows = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
for w in rows if isinstance(rows, list) else []:
|
||||
if w.get('kind') == 'platform' and not w.get('parent_id'):
|
||||
print(w.get('id','')); break
|
||||
else:
|
||||
print('')"
|
||||
}
|
||||
|
||||
# workspace_field <id> <field>: echo a single field off GET /workspaces/:id.
|
||||
workspace_field() { # <id> <field>
|
||||
tenant_call GET "/workspaces/$1" | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
print(d.get('$2','') if isinstance(d, dict) else '')"
|
||||
}
|
||||
|
||||
# find_worker_by_name: echo the id of a workspace whose name == WORKER_NAME, or
|
||||
# "" if not present. THIS is the deterministic side effect we assert on.
|
||||
find_worker_by_name() {
|
||||
list_workspaces_json | python3 -c "
|
||||
import sys, json, os
|
||||
want = os.environ['WORKER_NAME']
|
||||
try: rows = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
for w in rows if isinstance(rows, list) else []:
|
||||
if w.get('name') == want:
|
||||
print(w.get('id','')); break
|
||||
else:
|
||||
print('')"
|
||||
}
|
||||
|
||||
# ─── 0. Preflight ────────────────────────────────────────────────────────────
|
||||
log "═══ Staging concierge CREATES-A-WORKSPACE (real-LLM) E2E ═══ CP=$CP_URL Slug=$SLUG"
|
||||
log " worker the concierge will be asked to create: name=$WORKER_NAME"
|
||||
curl "${CURL_COMMON[@]}" "$CP_URL/health" >/dev/null || fail "CP health check failed"
|
||||
ok "CP reachable"
|
||||
|
||||
# ─── 1. Create org (CP installs + provisions the concierge as platform root) ──
|
||||
log "1/6 Creating org $SLUG..."
|
||||
CREATE_RESP=$(admin_call POST /cp/admin/orgs \
|
||||
-d "{\"slug\":\"$SLUG\",\"name\":\"E2E $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}")
|
||||
echo "$CREATE_RESP" | python3 -m json.tool >/dev/null || fail "Org create non-JSON: $CREATE_RESP"
|
||||
ORG_ID=$(echo "$CREATE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))")
|
||||
[ -z "$ORG_ID" ] && fail "Org create response missing 'id': $CREATE_RESP"
|
||||
ok "Org created (id=$ORG_ID)"
|
||||
|
||||
# ─── 2. Wait for tenant provisioning ─────────────────────────────────────────
|
||||
log "2/6 Waiting for tenant provisioning (up to ${PROVISION_TIMEOUT_SECS}s)..."
|
||||
DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
|
||||
LAST_STATUS=""
|
||||
while true; do
|
||||
[ "$(date +%s)" -gt "$DEADLINE" ] && exit 3
|
||||
LIST_JSON=$(admin_call GET /cp/admin/orgs 2>/dev/null || echo '{"orgs":[]}')
|
||||
STATUS=$(echo "$LIST_JSON" | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
for o in d.get('orgs', []):
|
||||
if o.get('slug') == '$SLUG':
|
||||
print(o.get('instance_status', '')); sys.exit(0)
|
||||
print('')" 2>/dev/null || echo "")
|
||||
if [ "$STATUS" != "$LAST_STATUS" ]; then log " status → $STATUS"; LAST_STATUS="$STATUS"; fi
|
||||
case "$STATUS" in
|
||||
running) break ;;
|
||||
failed) fail "Tenant provisioning failed for $SLUG" ;;
|
||||
*) sleep 15 ;;
|
||||
esac
|
||||
done
|
||||
ok "Tenant provisioning complete"
|
||||
|
||||
# Derive tenant domain from CP hostname (prod vs staging).
|
||||
CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##')
|
||||
case "$CP_HOST" in
|
||||
api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;;
|
||||
staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;;
|
||||
*) DERIVED_DOMAIN="$CP_HOST" ;;
|
||||
esac
|
||||
TENANT_DOMAIN="${MOLECULE_TENANT_DOMAIN:-$DERIVED_DOMAIN}"
|
||||
TENANT_URL="https://$SLUG.$TENANT_DOMAIN"
|
||||
log " TENANT_URL=$TENANT_URL"
|
||||
|
||||
# ─── 3. Per-tenant admin token + TLS readiness ───────────────────────────────
|
||||
log "3/6 Fetching per-tenant admin token..."
|
||||
TENANT_TOKEN=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null || echo "")
|
||||
[ -z "$TENANT_TOKEN" ] && fail "Could not retrieve per-tenant admin token for $SLUG"
|
||||
ok "Tenant admin token retrieved (len=${#TENANT_TOKEN})"
|
||||
|
||||
log " Waiting for tenant TLS / DNS propagation..."
|
||||
TLS_DEADLINE=$(( $(date +%s) + 15 * 60 ))
|
||||
while true; do
|
||||
curl -sSfk --max-time 5 "$TENANT_URL/health" >/dev/null 2>&1 && break
|
||||
[ "$(date +%s)" -gt "$TLS_DEADLINE" ] && fail "Tenant /health never 2xx within 15m"
|
||||
sleep 5
|
||||
done
|
||||
ok "Tenant reachable at $TENANT_URL"
|
||||
|
||||
# ─── 4. Discover the concierge (kind='platform' root) + ensure it can act ─────
|
||||
log "4/6 Discovering the concierge (kind='platform' root)..."
|
||||
# The CP installs the platform agent at org-provision; allow a short settle for
|
||||
# the row + re-parent backfill to land.
|
||||
CONCIERGE_ID=""
|
||||
DISC_DEADLINE=$(( $(date +%s) + 180 ))
|
||||
while true; do
|
||||
CONCIERGE_ID=$(find_platform_root)
|
||||
[ -n "$CONCIERGE_ID" ] && break
|
||||
[ "$(date +%s)" -gt "$DISC_DEADLINE" ] && break
|
||||
sleep 10
|
||||
done
|
||||
if [ -z "$CONCIERGE_ID" ]; then
|
||||
skip_loud "no kind='platform' concierge root in this org — the platform agent was not installed at provision. \
|
||||
This needs the CP platform-agent install (RFC §3) live on staging. Until then there is no agent to drive."
|
||||
fi
|
||||
ok "Concierge (platform root) = $CONCIERGE_ID"
|
||||
|
||||
# The concierge must be ONLINE + routable for its LLM to receive the A2A message
|
||||
# and reach the platform MCP. Bounded poll — generous because a cold concierge
|
||||
# boots its container + loads the platform MCP server before it is reachable.
|
||||
log " Waiting for the concierge to be online (up to ${CONCIERGE_ONLINE_SECS}s)..."
|
||||
ONLINE_DEADLINE=$(( $(date +%s) + CONCIERGE_ONLINE_SECS ))
|
||||
C_STATUS=""; C_URL=""; LAST_C_STATUS=""
|
||||
while true; do
|
||||
C_STATUS=$(workspace_field "$CONCIERGE_ID" status)
|
||||
C_URL=$(workspace_field "$CONCIERGE_ID" url)
|
||||
if [ "$C_STATUS" != "$LAST_C_STATUS" ]; then log " concierge → ${C_STATUS:-<none>}"; LAST_C_STATUS="$C_STATUS"; fi
|
||||
if [ "$C_STATUS" = "online" ] && [ -n "$C_URL" ]; then break; fi
|
||||
if [ "$(date +%s)" -gt "$ONLINE_DEADLINE" ]; then
|
||||
LAST_ERR=$(workspace_field "$CONCIERGE_ID" last_sample_error)
|
||||
skip_loud "concierge $CONCIERGE_ID never reached online+routable within ${CONCIERGE_ONLINE_SECS}s \
|
||||
(last status='${C_STATUS}', url='${C_URL}', err='${LAST_ERR}'). On a tenant where the concierge is NOT \
|
||||
provisioned on the platform-agent image (no /opt/molecule-mcp-server, no model), it cannot run the \
|
||||
create_workspace tool — that is the parallel-agent image work this gate depends on."
|
||||
fi
|
||||
sleep 10
|
||||
done
|
||||
ok "Concierge online + routable (url assigned)"
|
||||
|
||||
# Pre-state: the worker MUST NOT exist yet (so its later appearance is causally
|
||||
# the concierge's doing, not a pre-existing row).
|
||||
PRE_EXISTING=$(find_worker_by_name)
|
||||
[ -n "$PRE_EXISTING" ] && fail "worker '$WORKER_NAME' already exists pre-test ($PRE_EXISTING) — name collision, cannot prove causality"
|
||||
ok "Pre-state confirmed: '$WORKER_NAME' does not exist yet"
|
||||
|
||||
# ─── 5. Drive the AGENT: A2A message/send → it must create the workspace ──────
|
||||
log "5/6 Sending the concierge a natural-language create-workspace request..."
|
||||
# Imperative + explicit to defuse LLM nondeterminism: name the tool, the exact
|
||||
# workspace NAME and ROLE, and tell it not to ask a clarifying question. The
|
||||
# message/send envelope is the canvas user→agent chat path (handlers/a2a_proxy.go),
|
||||
# identical to the shape test_a2a_e2e.sh / test_staging_full_saas.sh use.
|
||||
AGENT_PROMPT="Please create a new workspace in this org right now using your platform tools. \
|
||||
Use the create_workspace tool with name exactly \"${WORKER_NAME}\" and role \"engineer\". \
|
||||
Do not ask me any clarifying questions — the name and role are final. \
|
||||
After the tool succeeds, reply with the new workspace id."
|
||||
A2A_PAYLOAD=$(WORKER_NAME="$WORKER_NAME" AGENT_PROMPT="$AGENT_PROMPT" python3 -c "
|
||||
import json, os, uuid
|
||||
print(json.dumps({
|
||||
'jsonrpc': '2.0',
|
||||
'method': 'message/send',
|
||||
'id': 'e2e-cncrg-mk-1',
|
||||
'params': {
|
||||
'message': {
|
||||
'role': 'user',
|
||||
'messageId': f'e2e-{uuid.uuid4().hex[:8]}',
|
||||
'parts': [{'kind': 'text', 'text': os.environ['AGENT_PROMPT']}],
|
||||
}
|
||||
}
|
||||
}))")
|
||||
|
||||
# Cold concierge: first turn opens TLS to the LLM, loads the platform MCP, runs
|
||||
# a tool call. Give it a wide per-call window AND retry on edge cold-start 5xx.
|
||||
A2A_TMP="$TMPDIR_E2E/a2a_out"
|
||||
AGENT_TEXT=""
|
||||
A2A_OK=0
|
||||
for A2A_ATTEMPT in $(seq 1 8); do
|
||||
: >"$A2A_TMP"
|
||||
set +e
|
||||
A2A_CODE=$(tenant_call POST "/workspaces/$CONCIERGE_ID/a2a" \
|
||||
--max-time "$AGENT_ACT_SECS" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$A2A_PAYLOAD" \
|
||||
-o "$A2A_TMP" -w '%{http_code}' 2>/dev/null)
|
||||
A2A_RC=$?
|
||||
set -e
|
||||
A2A_CODE=${A2A_CODE:-000}
|
||||
A2A_RESP=$(cat "$A2A_TMP" 2>/dev/null || echo "")
|
||||
if [ "$A2A_RC" = "0" ] && [ "$A2A_CODE" -ge 200 ] && [ "$A2A_CODE" -lt 300 ]; then
|
||||
A2A_OK=1
|
||||
break
|
||||
fi
|
||||
if echo "$A2A_CODE" | grep -Eq '^(502|503|504)$'; then
|
||||
log " A2A cold-start attempt $A2A_ATTEMPT/8 returned $A2A_CODE — retrying"
|
||||
[ "$A2A_ATTEMPT" -lt 8 ] && { sleep 15; continue; }
|
||||
fi
|
||||
break
|
||||
done
|
||||
if [ "$A2A_OK" != "1" ]; then
|
||||
# A non-2xx A2A POST is an INFRA/transport failure (agent unreachable), not an
|
||||
# "agent declined" — distinct from the assertion below.
|
||||
fail "A2A POST /workspaces/$CONCIERGE_ID/a2a failed (curl_rc=$A2A_RC, http=$A2A_CODE) after $A2A_ATTEMPT attempt(s): $(echo "$A2A_RESP" | head -c 400)"
|
||||
fi
|
||||
AGENT_TEXT=$(echo "$A2A_RESP" | python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(sys.stdin)
|
||||
except Exception: print(''); sys.exit(0)
|
||||
parts = (d.get('result') or {}).get('parts', []) if isinstance(d, dict) else []
|
||||
print(parts[0].get('text','') if parts else '')" 2>/dev/null || echo "")
|
||||
log " concierge replied (first 300 chars): $(echo "$AGENT_TEXT" | head -c 300)"
|
||||
|
||||
# ─── 6. ASSERT the deterministic side effect: the worker now EXISTS ───────────
|
||||
log "6/6 Polling GET /workspaces for the worker the concierge was asked to create..."
|
||||
# The create is the side effect; the LLM may take a few turns / a moment to flush
|
||||
# the tool call. Poll the NAME (deterministic) — tolerant of when exactly the row
|
||||
# lands, intolerant of it never landing.
|
||||
ACT_DEADLINE=$(( $(date +%s) + AGENT_ACT_SECS ))
|
||||
while true; do
|
||||
WORKER_ID=$(find_worker_by_name)
|
||||
[ -n "$WORKER_ID" ] && break
|
||||
if [ "$(date +%s)" -gt "$ACT_DEADLINE" ]; then
|
||||
# The agent answered but the workspace never appeared → the LLM did NOT call
|
||||
# create_workspace (or the tool failed). Distinguish the two for the operator.
|
||||
if hit=$(a2a_completion_error_marker "$AGENT_TEXT"); then
|
||||
fail "TOOL FAILED: concierge surfaced an error-as-text reply (matched '$hit') and no workspace '$WORKER_NAME' was created. \
|
||||
The platform MCP create_workspace tool errored. Reply: $(echo "$AGENT_TEXT" | head -c 400)"
|
||||
fi
|
||||
fail "AGENT DID NOT ACT: concierge replied but no workspace named '$WORKER_NAME' exists in GET /workspaces after ${AGENT_ACT_SECS}s. \
|
||||
The concierge's LLM did not invoke the create_workspace platform-MCP tool. \
|
||||
Reply: $(echo "$AGENT_TEXT" | head -c 400)"
|
||||
fi
|
||||
sleep 8
|
||||
done
|
||||
ok "DETERMINISTIC SIDE EFFECT CONFIRMED: workspace '$WORKER_NAME' now EXISTS (id=$WORKER_ID)"
|
||||
|
||||
# Confirm it is a real workspace row (kind='workspace') parented under the org —
|
||||
# i.e. a genuine create, not a no-op echo. parent_id may be the concierge (the
|
||||
# concierge creates children under itself by convention) or another node; we
|
||||
# assert only that it's a non-platform workspace, which is what create_workspace
|
||||
# yields.
|
||||
WORKER_KIND=$(workspace_field "$WORKER_ID" kind)
|
||||
if [ -n "$WORKER_KIND" ] && [ "$WORKER_KIND" != "workspace" ]; then
|
||||
fail "created node '$WORKER_NAME' has kind='$WORKER_KIND' (want 'workspace') — not a real worker create"
|
||||
fi
|
||||
ok "Created node is a real kind='workspace' row"
|
||||
|
||||
# Soft confirmation: the concierge SHOULD report back. Non-fatal (the side
|
||||
# effect above is the hard proof) — but a reply that is itself an error is a
|
||||
# yellow flag worth logging even though the row landed.
|
||||
if [ -n "$AGENT_TEXT" ]; then
|
||||
if a2a_completion_error_marker "$AGENT_TEXT" >/dev/null; then
|
||||
log " ⚠️ concierge reply looks like an error-as-text even though the workspace was created — investigate the tool result surfacing."
|
||||
else
|
||||
ok "Concierge replied confirming the action (non-error)"
|
||||
fi
|
||||
else
|
||||
log " (concierge returned no text part — the row landing is the proof; reply is optional)"
|
||||
fi
|
||||
|
||||
ok "═══ STAGING CONCIERGE CREATES-A-WORKSPACE E2E PASSED ═══"
|
||||
log "Proven: a natural-language A2A request → the concierge's LLM invoked create_workspace via the platform MCP → real org mutation (workspace '$WORKER_NAME' id=$WORKER_ID). Teardown runs via EXIT trap."
|
||||
Executable
+376
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env bash
|
||||
# Real-staging E2E for the concierge user_tasks primitive (Feature 3 of the
|
||||
# concierge / platform-agent set). Exercises the FULL agent→user "ask" contract
|
||||
# both surfaces expose, END-TO-END against a real EC2-backed staging tenant:
|
||||
#
|
||||
# REST (per-workspace, tenant-admin-token authenticated):
|
||||
# POST /workspaces/:id/user-tasks create an ask
|
||||
# GET /workspaces/:id/user-tasks this workspace's asks
|
||||
# GET /user-tasks/pending (AdminAuth) org-wide pending asks
|
||||
# PATCH /workspaces/:id/user-tasks/:taskId edit (scoped by ws id)
|
||||
# DELETE /workspaces/:id/user-tasks/:taskId remove (scoped by ws id)
|
||||
# POST /workspaces/:id/user-tasks/:taskId/resolve done|dismissed
|
||||
#
|
||||
# MCP a2a-bridge tools (POST /workspaces/:id/mcp, JSON-RPC tools/call):
|
||||
# request_user_action(title, detail?) list_user_tasks()
|
||||
# update_user_task(user_task_id, …) delete_user_task(user_task_id)
|
||||
#
|
||||
# Cross-workspace authz: workspace B cannot PATCH/DELETE workspace A's task
|
||||
# (the user_tasks handler scopes every mutation by the URL :id, so a B-path
|
||||
# call against an A-owned task 404s — the same scoping the local
|
||||
# test_user_tasks_e2e.sh pins, here proven over the real tenant ws-server).
|
||||
#
|
||||
# Why a real-staging sibling to the LOCAL test_user_tasks_e2e.sh: the local one
|
||||
# runs against a dev workspace-server with external/in-memory workspaces. This
|
||||
# one provisions a REAL throwaway org + tenant (same CP-admin scaffolding as
|
||||
# test_staging_full_saas.sh) and drives the user_tasks surfaces through the live
|
||||
# tenant auth chain (TenantGuard + WorkspaceAuth + Cloudflare edge) — the exact
|
||||
# path a canvas concierge agent hits in production. It REUSES the staging
|
||||
# harness's env contract, org-provision/teardown shape, _lib.sh helpers, and the
|
||||
# AWS-leak-check lib, so the org lifecycle scaffolding is shared, not duplicated.
|
||||
#
|
||||
# NOTE: user_tasks is a pure DB/handler primitive — no LLM container is needed.
|
||||
# We DO NOT wait for any workspace to boot online (no MINIMAX/ANTHROPIC key
|
||||
# required), which keeps this test fast and decoupled from EC2 cold-boot flake.
|
||||
# Workspaces are created in 'external' mode so the tenant ws-server registers
|
||||
# the row without provisioning an EC2 (no leak beyond the org teardown).
|
||||
#
|
||||
# Required env (same contract as test_staging_full_saas.sh):
|
||||
# MOLECULE_CP_URL default: https://staging-api.moleculesai.app
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
|
||||
#
|
||||
# Optional env:
|
||||
# E2E_PROVISION_TIMEOUT_SECS default 900 (15 min cold tenant EC2 budget)
|
||||
# E2E_KEEP_ORG 1 → skip teardown (debugging only)
|
||||
# E2E_RUN_ID slug suffix; CI: ${GITHUB_RUN_ID}-${RUN_ATTEMPT}
|
||||
# E2E_AWS_LEAK_CHECK auto (default) | required | off
|
||||
# E2E_AWS_TERMINATE_LEAKS 1 → terminate slug-tagged leaked EC2 on exit
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 happy path
|
||||
# 1 generic / assertion failure
|
||||
# 2 missing required env
|
||||
# 3 provisioning timed out
|
||||
# 4 teardown left orphan resources
|
||||
set -euo pipefail
|
||||
|
||||
# _lib.sh gives us sanitize/admin-auth conventions shared across the suite.
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=_lib.sh
|
||||
source "$(dirname "$0")/_lib.sh"
|
||||
# AWS-leak-check lib — same teardown leak assertion the full-SaaS harness uses.
|
||||
# shellcheck disable=SC1091
|
||||
# shellcheck source=lib/aws_leak_check.sh
|
||||
source "$(dirname "$0")/lib/aws_leak_check.sh"
|
||||
|
||||
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
|
||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
|
||||
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
|
||||
# Fixed e2e- prefix so sweep-stale-e2e-orgs.yml + lint_cleanup_traps.sh reap any
|
||||
# orphan. (The lint requires a quoted SLUG=... with a literal e2e-/rt-e2e- head.)
|
||||
SLUG="e2e-cncrg-$(date +%Y%m%d)-${RUN_ID_SUFFIX}"
|
||||
SLUG=$(echo "$SLUG" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 32)
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
check() { # <desc> <expected-substr> <actual>
|
||||
if echo "$3" | grep -qF -- "$2"; then echo " PASS: $1"; PASS=$((PASS + 1));
|
||||
else echo " FAIL: $1"; echo " expected to contain: $2"; echo " got: $(echo "$3" | head -c 300)"; FAIL=$((FAIL + 1)); fi
|
||||
}
|
||||
check_not() { # <desc> <unexpected-substr> <actual>
|
||||
if echo "$3" | grep -qF -- "$2"; then echo " FAIL: $1 (should NOT contain: $2)"; FAIL=$((FAIL + 1));
|
||||
else echo " PASS: $1"; PASS=$((PASS + 1)); fi
|
||||
}
|
||||
check_code() { # <desc> <expected> <actual>
|
||||
if [ "$3" = "$2" ]; then echo " PASS: $1 (HTTP $3)"; PASS=$((PASS + 1));
|
||||
else echo " FAIL: $1 (expected HTTP $2, got HTTP $3)"; FAIL=$((FAIL + 1)); fi
|
||||
}
|
||||
|
||||
CURL_COMMON=(-sS --max-time 30)
|
||||
TMPDIR_E2E=$(mktemp -d -t cncrg-staging-XXXXXX)
|
||||
|
||||
# ─── teardown trap (org delete + leak check) ─────────────────────────────────
|
||||
CLEANUP_DONE=0
|
||||
cleanup_org() {
|
||||
local entry_rc=$?
|
||||
[ "$CLEANUP_DONE" = "1" ] && return 0
|
||||
CLEANUP_DONE=1
|
||||
rm -rf "$TMPDIR_E2E" 2>/dev/null || true
|
||||
|
||||
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
|
||||
log "E2E_KEEP_ORG=1 — skipping teardown. Manually delete $SLUG when done."
|
||||
return 0
|
||||
fi
|
||||
log "🧹 Tearing down org $SLUG..."
|
||||
if curl "${CURL_COMMON[@]}" --max-time 120 -X DELETE "$CP_URL/cp/admin/tenants/$SLUG" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
|
||||
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1; then
|
||||
ok "Teardown request accepted"
|
||||
else
|
||||
log "Teardown returned non-2xx (may already be gone)"
|
||||
fi
|
||||
|
||||
# Eventual-consistency wait: org row gone / purged.
|
||||
local leak_count=1 elapsed=0
|
||||
while [ "$elapsed" -lt 60 ]; do
|
||||
leak_count=$(curl "${CURL_COMMON[@]}" "$CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
|
||||
| python3 -c "import json,sys; d=json.load(sys.stdin); print(sum(1 for o in d.get('orgs', []) if o.get('slug')=='$SLUG' and o.get('status') != 'purged'))" \
|
||||
2>/dev/null || echo 1)
|
||||
[ "$leak_count" = "0" ] && break
|
||||
sleep 5; elapsed=$((elapsed + 5))
|
||||
done
|
||||
if [ "$leak_count" != "0" ]; then
|
||||
echo "⚠️ LEAK: org $SLUG still present post-teardown after ${elapsed}s (count=$leak_count)" >&2
|
||||
exit 4
|
||||
fi
|
||||
local aws_leak_rc=0
|
||||
e2e_verify_no_ec2_leaks_for_slug "$SLUG" || aws_leak_rc=$?
|
||||
if [ "$aws_leak_rc" != "0" ]; then
|
||||
case "$aws_leak_rc" in 2) exit 2 ;; *) exit 4 ;; esac
|
||||
fi
|
||||
ok "Teardown clean — no orphan org or EC2 resources for $SLUG (${elapsed}s)"
|
||||
case "$entry_rc" in 0|1|2|3|4) ;; *) exit 1 ;; esac
|
||||
}
|
||||
trap cleanup_org EXIT INT TERM
|
||||
|
||||
admin_call() { # <method> <path> [curl args…]
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl "${CURL_COMMON[@]}" -X "$method" "$CP_URL$path" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" "$@"
|
||||
}
|
||||
|
||||
# ─── 0. Preflight ────────────────────────────────────────────────────────────
|
||||
log "═══ Staging concierge user_tasks E2E ═══ CP=$CP_URL Slug=$SLUG"
|
||||
curl "${CURL_COMMON[@]}" "$CP_URL/health" >/dev/null || fail "CP health check failed"
|
||||
ok "CP reachable"
|
||||
|
||||
# ─── 1. Create org ───────────────────────────────────────────────────────────
|
||||
log "1/6 Creating org $SLUG..."
|
||||
CREATE_RESP=$(admin_call POST /cp/admin/orgs \
|
||||
-d "{\"slug\":\"$SLUG\",\"name\":\"E2E $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}")
|
||||
echo "$CREATE_RESP" | python3 -m json.tool >/dev/null || fail "Org create non-JSON: $CREATE_RESP"
|
||||
ORG_ID=$(echo "$CREATE_RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('id',''))")
|
||||
[ -z "$ORG_ID" ] && fail "Org create response missing 'id': $CREATE_RESP"
|
||||
ok "Org created (id=$ORG_ID)"
|
||||
|
||||
# ─── 2. Wait for tenant provisioning ─────────────────────────────────────────
|
||||
log "2/6 Waiting for tenant provisioning (up to ${PROVISION_TIMEOUT_SECS}s)..."
|
||||
DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
|
||||
LAST_STATUS=""
|
||||
while true; do
|
||||
[ "$(date +%s)" -gt "$DEADLINE" ] && exit 3
|
||||
LIST_JSON=$(admin_call GET /cp/admin/orgs 2>/dev/null || echo '{"orgs":[]}')
|
||||
STATUS=$(echo "$LIST_JSON" | python3 -c "
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
for o in d.get('orgs', []):
|
||||
if o.get('slug') == '$SLUG':
|
||||
print(o.get('instance_status', '')); sys.exit(0)
|
||||
print('')" 2>/dev/null || echo "")
|
||||
if [ "$STATUS" != "$LAST_STATUS" ]; then log " status → $STATUS"; LAST_STATUS="$STATUS"; fi
|
||||
case "$STATUS" in
|
||||
running) break ;;
|
||||
failed) fail "Tenant provisioning failed for $SLUG" ;;
|
||||
*) sleep 15 ;;
|
||||
esac
|
||||
done
|
||||
ok "Tenant provisioning complete"
|
||||
|
||||
# Derive tenant domain from CP hostname (prod vs staging).
|
||||
CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##')
|
||||
case "$CP_HOST" in
|
||||
api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;;
|
||||
staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;;
|
||||
*) DERIVED_DOMAIN="$CP_HOST" ;;
|
||||
esac
|
||||
TENANT_DOMAIN="${MOLECULE_TENANT_DOMAIN:-$DERIVED_DOMAIN}"
|
||||
TENANT_URL="https://$SLUG.$TENANT_DOMAIN"
|
||||
log " TENANT_URL=$TENANT_URL"
|
||||
|
||||
# ─── 3. Per-tenant admin token + TLS readiness ───────────────────────────────
|
||||
log "3/6 Fetching per-tenant admin token..."
|
||||
TENANT_TOKEN=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null || echo "")
|
||||
[ -z "$TENANT_TOKEN" ] && fail "Could not retrieve per-tenant admin token for $SLUG"
|
||||
ok "Tenant admin token retrieved (len=${#TENANT_TOKEN})"
|
||||
|
||||
log " Waiting for tenant TLS / DNS propagation..."
|
||||
TLS_DEADLINE=$(( $(date +%s) + 15 * 60 ))
|
||||
while true; do
|
||||
curl -sSfk --max-time 5 "$TENANT_URL/health" >/dev/null 2>&1 && break
|
||||
[ "$(date +%s)" -gt "$TLS_DEADLINE" ] && fail "Tenant /health never 2xx within 15m"
|
||||
sleep 5
|
||||
done
|
||||
ok "Tenant reachable at $TENANT_URL"
|
||||
|
||||
# tenant_call: Authorization (tenant admin token, valid for every workspace) +
|
||||
# X-Molecule-Org-Id (TenantGuard 404s without it) + Origin (Cloudflare edge).
|
||||
tenant_call() { # <method> <path> [curl args…]
|
||||
local method="$1" path="$2"; shift 2
|
||||
curl "${CURL_COMMON[@]}" -X "$method" "$TENANT_URL$path" \
|
||||
-H "Authorization: Bearer $TENANT_TOKEN" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "Origin: $TENANT_URL" "$@"
|
||||
}
|
||||
|
||||
# Create an external workspace (row only — no EC2). Echoes its id.
|
||||
create_external_ws() { # <name>
|
||||
local name="$1" resp
|
||||
resp=$(tenant_call POST /workspaces -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$name\",\"tier\":1,\"runtime\":\"external\",\"external\":true}")
|
||||
echo "$resp" | python3 -c "import sys,re
|
||||
b=sys.stdin.read()
|
||||
m=re.search(r'\"id\"\s*:\s*\"([^\"]+)\"', b)
|
||||
print(m.group(1) if m else '')"
|
||||
}
|
||||
|
||||
# MCP JSON-RPC tools/call against /workspaces/:id/mcp. Echoes the result text
|
||||
# (result.content[].text). Persists HTTP code to a file (runs in $()).
|
||||
MCP_CODE_FILE="$TMPDIR_E2E/mcp_code"
|
||||
mcp_call() { # <wsid> <tool> <args-json>
|
||||
local wsid="$1" tool="$2" args="$3" out code
|
||||
out="$TMPDIR_E2E/mcp_out"
|
||||
set +e
|
||||
code=$(tenant_call POST "/workspaces/$wsid/mcp" -H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$tool\",\"arguments\":$args}}" \
|
||||
-o "$out" -w "%{http_code}" 2>/dev/null)
|
||||
set -e
|
||||
printf '%s' "$code" > "$MCP_CODE_FILE"
|
||||
python3 -c "
|
||||
import sys, json
|
||||
try: d = json.load(open('$out'))
|
||||
except Exception: print(''); sys.exit(0)
|
||||
res = d.get('result') if isinstance(d, dict) else None
|
||||
print(''.join(c.get('text','') for c in res.get('content', [])) if isinstance(res, dict) else '')"
|
||||
}
|
||||
mcp_http_code() { cat "$MCP_CODE_FILE" 2>/dev/null || echo ''; }
|
||||
|
||||
# ─── 4. Provision two workspaces (A raises asks, B probes cross-ws authz) ─────
|
||||
log "4/6 Creating two tenant workspaces (external rows — no EC2)..."
|
||||
WS_A=$(create_external_ws "Concierge-UT-A-$$")
|
||||
[ -n "$WS_A" ] || fail "ws-A create returned no id"
|
||||
WS_B=$(create_external_ws "Concierge-UT-B-$$")
|
||||
[ -n "$WS_B" ] || fail "ws-B create returned no id"
|
||||
ok "ws-A=$WS_A ws-B=$WS_B"
|
||||
|
||||
# ─── 5. user_tasks REST + MCP + authz ────────────────────────────────────────
|
||||
log "5/6 user_tasks contract (REST + MCP + cross-ws authz)..."
|
||||
|
||||
# 5.1 REST create → 201, status pending
|
||||
R=$(tenant_call POST "/workspaces/$WS_A/user-tasks" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Review the Q3 draft","detail":"Need your sign-off before send"}' \
|
||||
-o "$TMPDIR_E2E/c.json" -w "%{http_code}" 2>/dev/null || echo "000")
|
||||
BODY=$(cat "$TMPDIR_E2E/c.json" 2>/dev/null || echo "")
|
||||
check_code "REST create user-task" "201" "$R"
|
||||
check "create returns status pending" '"status":"pending"' "$BODY"
|
||||
TASK_ID=$(echo "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin).get('user_task_id',''))" 2>/dev/null || echo "")
|
||||
[ -n "$TASK_ID" ] || fail "no user_task_id returned: $BODY"
|
||||
log " TASK_ID=$TASK_ID"
|
||||
|
||||
# 5.2 REST read (this workspace + admin org-wide pending)
|
||||
R=$(tenant_call GET "/workspaces/$WS_A/user-tasks")
|
||||
check "GET ws-A user-tasks contains the task" "$TASK_ID" "$R"
|
||||
check "GET ws-A user-tasks shows title" 'Review the Q3 draft' "$R"
|
||||
R=$(tenant_call GET "/user-tasks/pending")
|
||||
check "GET /user-tasks/pending (admin) contains the task" "$TASK_ID" "$R"
|
||||
check "pending entry carries workspace_name" "Concierge-UT-A-$$" "$R"
|
||||
|
||||
# 5.3 REST PATCH title/detail → 200, applied
|
||||
R=$(tenant_call PATCH "/workspaces/$WS_A/user-tasks/$TASK_ID" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Review the Q3 draft (URGENT)","detail":"Sign-off needed by EOD"}' \
|
||||
-o /dev/null -w "%{http_code}" 2>/dev/null || echo "000")
|
||||
check_code "REST PATCH user-task" "200" "$R"
|
||||
R=$(tenant_call GET "/workspaces/$WS_A/user-tasks")
|
||||
check "PATCH applied new title" '(URGENT)' "$R"
|
||||
check "PATCH applied new detail" 'Sign-off needed by EOD' "$R"
|
||||
|
||||
# 5.4 REST resolve done → 200, gone from pending
|
||||
R=$(tenant_call POST "/workspaces/$WS_A/user-tasks/$TASK_ID/resolve" -H "Content-Type: application/json" \
|
||||
-d '{"status":"done","resolved_by":"cto"}' -o "$TMPDIR_E2E/r.json" -w "%{http_code}" 2>/dev/null || echo "000")
|
||||
BODY=$(cat "$TMPDIR_E2E/r.json" 2>/dev/null || echo "")
|
||||
check_code "REST resolve done" "200" "$R"
|
||||
check "resolve echoes status done" '"status":"done"' "$BODY"
|
||||
R=$(tenant_call GET "/user-tasks/pending")
|
||||
check_not "resolved task no longer pending (admin feed)" "$TASK_ID" "$R"
|
||||
|
||||
# 5.5 MCP request_user_action → new pending task surfaces on the admin feed
|
||||
TEXT=$(mcp_call "$WS_A" "request_user_action" '{"title":"Provide the staging API key","detail":"Blocked on it for the deploy"}')
|
||||
check_code "MCP request_user_action HTTP" "200" "$(mcp_http_code)"
|
||||
check "MCP request_user_action success text" 'Asked the user' "$TEXT"
|
||||
R=$(tenant_call GET "/user-tasks/pending")
|
||||
check "MCP-created ask appears in pending feed" 'Provide the staging API key' "$R"
|
||||
MCP_TASK_ID=$(echo "$R" | python3 -c "
|
||||
import sys, json
|
||||
for t in json.load(sys.stdin):
|
||||
if t.get('title') == 'Provide the staging API key':
|
||||
print(t.get('id','')); break" 2>/dev/null || echo "")
|
||||
log " MCP_TASK_ID=$MCP_TASK_ID"
|
||||
|
||||
# 5.6 MCP list_user_tasks returns ws-A's task(s)
|
||||
TEXT=$(mcp_call "$WS_A" "list_user_tasks" '{}')
|
||||
check_code "MCP list_user_tasks HTTP" "200" "$(mcp_http_code)"
|
||||
check "list_user_tasks contains the MCP task" 'Provide the staging API key' "$TEXT"
|
||||
check "list_user_tasks shows it pending" '"status":"pending"' "$TEXT"
|
||||
|
||||
# 5.7 MCP update_user_task changes it
|
||||
if [ -n "$MCP_TASK_ID" ]; then
|
||||
TEXT=$(mcp_call "$WS_A" "update_user_task" "{\"user_task_id\":\"$MCP_TASK_ID\",\"title\":\"Provide the PROD API key\"}")
|
||||
check_code "MCP update_user_task HTTP" "200" "$(mcp_http_code)"
|
||||
check "MCP update_user_task success text" 'User task updated' "$TEXT"
|
||||
TEXT=$(mcp_call "$WS_A" "list_user_tasks" '{}')
|
||||
check "update applied (new title)" 'Provide the PROD API key' "$TEXT"
|
||||
check_not "update applied (old title gone)" 'staging API key' "$TEXT"
|
||||
|
||||
# 5.8 MCP delete_user_task → gone from list
|
||||
TEXT=$(mcp_call "$WS_A" "delete_user_task" "{\"user_task_id\":\"$MCP_TASK_ID\"}")
|
||||
check_code "MCP delete_user_task HTTP" "200" "$(mcp_http_code)"
|
||||
check "MCP delete_user_task success text" 'User task deleted' "$TEXT"
|
||||
TEXT=$(mcp_call "$WS_A" "list_user_tasks" '{}')
|
||||
check_not "deleted task gone from list" 'Provide the PROD API key' "$TEXT"
|
||||
else
|
||||
echo " FAIL: could not resolve MCP_TASK_ID — MCP update/delete steps skipped"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# 5.9 Cross-workspace authz: ws-B cannot mutate ws-A's task (scoped by URL :id)
|
||||
SCOPE_ID=$(tenant_call POST "/workspaces/$WS_A/user-tasks" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Scope probe task"}' | python3 -c "import sys,json; print(json.load(sys.stdin).get('user_task_id',''))" 2>/dev/null || echo "")
|
||||
[ -n "$SCOPE_ID" ] || fail "scope-probe task create failed"
|
||||
log " SCOPE_ID=$SCOPE_ID (owned by ws-A)"
|
||||
# ws-B PATCHes ws-A's task → 404 (workspace_id scope).
|
||||
R=$(tenant_call PATCH "/workspaces/$WS_B/user-tasks/$SCOPE_ID" -H "Content-Type: application/json" \
|
||||
-d '{"title":"hijack"}' -o /dev/null -w "%{http_code}" 2>/dev/null || echo "000")
|
||||
check_code "ws-B PATCH of ws-A's task scoped out" "404" "$R"
|
||||
# ws-B DELETEs ws-A's task → 404.
|
||||
R=$(tenant_call DELETE "/workspaces/$WS_B/user-tasks/$SCOPE_ID" -o /dev/null -w "%{http_code}" 2>/dev/null || echo "000")
|
||||
check_code "ws-B DELETE of ws-A's task scoped out" "404" "$R"
|
||||
# Task survived unchanged on ws-A.
|
||||
R=$(tenant_call GET "/workspaces/$WS_A/user-tasks")
|
||||
check "ws-A's task survived cross-ws attempts" "$SCOPE_ID" "$R"
|
||||
check_not "ws-A's task title was NOT hijacked" 'hijack' "$R"
|
||||
# ws-B's own list must NOT see ws-A's task at all.
|
||||
R=$(tenant_call GET "/workspaces/$WS_B/user-tasks")
|
||||
check_not "ws-B list excludes ws-A's task (read isolation)" "$SCOPE_ID" "$R"
|
||||
|
||||
# 5.10 Validation contracts
|
||||
R=$(tenant_call POST "/workspaces/$WS_A/user-tasks" -H "Content-Type: application/json" \
|
||||
-d '{"detail":"no title here"}' -o /dev/null -w "%{http_code}" 2>/dev/null || echo "000")
|
||||
check_code "create without title → 400" "400" "$R"
|
||||
R=$(tenant_call POST "/workspaces/$WS_A/user-tasks/$SCOPE_ID/resolve" -H "Content-Type: application/json" \
|
||||
-d '{"status":"banana"}' -o /dev/null -w "%{http_code}" 2>/dev/null || echo "000")
|
||||
check_code "resolve with invalid status → 400" "400" "$R"
|
||||
R=$(tenant_call PATCH "/workspaces/$WS_A/user-tasks/$SCOPE_ID" -H "Content-Type: application/json" \
|
||||
-d '{"status":"banana"}' -o /dev/null -w "%{http_code}" 2>/dev/null || echo "000")
|
||||
check_code "PATCH with invalid status → 400" "400" "$R"
|
||||
|
||||
# ─── 6. Results ──────────────────────────────────────────────────────────────
|
||||
log "6/6 Results: $PASS passed, $FAIL failed (teardown runs via EXIT trap)"
|
||||
[ "$FAIL" -eq 0 ] || fail "$FAIL user_tasks assertion(s) failed"
|
||||
ok "═══ STAGING CONCIERGE user_tasks E2E PASSED ($PASS checks) ═══"
|
||||
Executable
+351
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env bash
|
||||
# E2E tests for the user_tasks platform ability — agent → user action
|
||||
# requests ("asks"). Exercises the FULL contract both surfaces expose:
|
||||
#
|
||||
# REST (WorkspaceAuth unless noted):
|
||||
# POST /workspaces/:id/user-tasks create an ask
|
||||
# GET /workspaces/:id/user-tasks this workspace's asks
|
||||
# GET /user-tasks/pending (AdminAuth) org-wide pending asks
|
||||
# PATCH /workspaces/:id/user-tasks/:taskId edit (scoped by ws id)
|
||||
# DELETE /workspaces/:id/user-tasks/:taskId remove (scoped by ws id)
|
||||
# POST /workspaces/:id/user-tasks/:taskId/resolve done|dismissed
|
||||
#
|
||||
# MCP a2a-bridge tools (POST /workspaces/:id/mcp, JSON-RPC tools/call):
|
||||
# request_user_action(title, detail?) list_user_tasks()
|
||||
# update_user_task(user_task_id, …) delete_user_task(user_task_id)
|
||||
#
|
||||
# The MCP arm is what proves the agent→user ability END-TO-END: it drives
|
||||
# the literal `tools/call` envelope through the real WorkspaceAuth chain
|
||||
# (the exact call a canvas agent makes), then asserts the new task surfaces
|
||||
# on the admin-gated concierge feed (/user-tasks/pending).
|
||||
#
|
||||
# Requires: platform running on $BASE (default http://localhost:8080).
|
||||
# Env contract (same as its siblings in this dir):
|
||||
# BASE platform base URL (default http://localhost:8080)
|
||||
# ADMIN_TOKEN / platform admin bearer; MOLECULE_ADMIN_TOKEN wins.
|
||||
# MOLECULE_ADMIN_TOKEN Sent on AdminAuth routes (create/delete ws,
|
||||
# /user-tasks/pending). Fail-open dev platform with
|
||||
# no admin token still works (helpers send nothing).
|
||||
set -euo pipefail
|
||||
|
||||
source "$(dirname "$0")/_lib.sh" # sets BASE default + admin-auth helpers
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
check() {
|
||||
local desc="$1"
|
||||
local expected="$2"
|
||||
local actual="$3"
|
||||
if echo "$actual" | grep -qF -- "$expected"; then
|
||||
echo "PASS: $desc"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $desc"
|
||||
echo " expected to contain: $expected"
|
||||
echo " got: $(echo "$actual" | head -5)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
check_not() {
|
||||
local desc="$1"
|
||||
local unexpected="$2"
|
||||
local actual="$3"
|
||||
if echo "$actual" | grep -qF -- "$unexpected"; then
|
||||
echo "FAIL: $desc"
|
||||
echo " should NOT contain: $unexpected"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo "PASS: $desc"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Assert an exact HTTP status. $1 desc, $2 expected code, $3 actual code.
|
||||
check_code() {
|
||||
local desc="$1"
|
||||
local expected="$2"
|
||||
local actual="$3"
|
||||
if [ "$actual" = "$expected" ]; then
|
||||
echo "PASS: $desc (HTTP $actual)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: $desc"
|
||||
echo " expected HTTP $expected, got HTTP $actual"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Admin bearer for AdminAuth routes (create/delete workspace, pending feed).
|
||||
ADMIN_AUTH=()
|
||||
e2e_admin_auth_args ADMIN_AUTH
|
||||
acurl() { curl -s ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"} "$@"; }
|
||||
|
||||
# The local create-workspace response embeds a claude_code_channel_snippet
|
||||
# whose raw newlines/escapes make the body un-loadable by strict json.load
|
||||
# (the same reason _extract_token.py can emit empty here). So pull id +
|
||||
# auth_token with tolerant regexes that don't parse the whole envelope.
|
||||
extract_field_regex() { # <field> ; reads body on stdin
|
||||
local field="$1"
|
||||
python3 -c "
|
||||
import sys, re
|
||||
body = sys.stdin.read()
|
||||
m = re.search(r'\"$field\"\s*:\s*\"([^\"]+)\"', body)
|
||||
print(m.group(1) if m else '')
|
||||
"
|
||||
}
|
||||
extract_ws_id() { extract_field_regex "id"; }
|
||||
extract_ws_token() { extract_field_regex "auth_token"; }
|
||||
|
||||
# Create an external workspace; echo "<id>\t<token>". Caller registers ids
|
||||
# in CREATED_WSIDS for the scoped teardown.
|
||||
create_workspace() { # <name>
|
||||
local name="$1" resp wid tok
|
||||
resp=$(acurl -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$name\",\"tier\":1,\"runtime\":\"external\",\"external\":true}")
|
||||
wid=$(printf '%s' "$resp" | extract_ws_id)
|
||||
tok=$(printf '%s' "$resp" | extract_ws_token)
|
||||
if [ -z "$wid" ]; then
|
||||
echo "FATAL: create workspace '$name' returned no id: $(printf '%s' "$resp" | head -c 200)" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -z "$tok" ]; then
|
||||
# External create did not echo a token — mint one via the admin endpoint.
|
||||
tok=$(e2e_mint_workspace_token "$wid" 2>/dev/null || echo "")
|
||||
fi
|
||||
if [ -z "$tok" ]; then
|
||||
echo "FATAL: no workspace bearer for '$name' ($wid)" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\t%s\n' "$wid" "$tok"
|
||||
}
|
||||
|
||||
# Issue a JSON-RPC tools/call to a workspace MCP endpoint. Echoes the raw
|
||||
# HTTP body on stdout and persists the HTTP status to $MCP_CODE_FILE (mcp_call
|
||||
# runs in a command substitution, so a plain var would be lost in the
|
||||
# subshell — read the code back via mcp_http_code after the call).
|
||||
# <wsid> <bearer> <tool> <args-json>
|
||||
MCP_CODE_FILE="$(mktemp -t ut_mcp_code.XXXXXX)"
|
||||
MCP_BODY_FILE="$(mktemp -t ut_mcp_body.XXXXXX)"
|
||||
mcp_call() {
|
||||
local wsid="$1" bearer="$2" tool="$3" args="$4" code
|
||||
set +e
|
||||
code=$(curl -sS -X POST "$BASE/workspaces/$wsid/mcp" \
|
||||
-H "Authorization: Bearer $bearer" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"$tool\",\"arguments\":$args}}" \
|
||||
-o "$MCP_BODY_FILE" -w "%{http_code}" 2>/dev/null)
|
||||
set -e
|
||||
printf '%s' "$code" > "$MCP_CODE_FILE"
|
||||
cat "$MCP_BODY_FILE" 2>/dev/null || echo ''
|
||||
}
|
||||
mcp_http_code() { cat "$MCP_CODE_FILE" 2>/dev/null || echo ''; }
|
||||
|
||||
# Extract the `result.content[].text` from an MCP tools/call response.
|
||||
mcp_result_text() { # reads body on stdin
|
||||
python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(''); sys.exit(0)
|
||||
res = d.get('result') if isinstance(d, dict) else None
|
||||
if not isinstance(res, dict):
|
||||
print(''); sys.exit(0)
|
||||
print(''.join(c.get('text','') for c in res.get('content', []) if c.get('type') == 'text'))
|
||||
"
|
||||
}
|
||||
|
||||
# ─── Scoped teardown ───────────────────────────────────────────────────
|
||||
# Deletes ONLY the workspaces THIS run created (CREATED_WSIDS). Deleting a
|
||||
# workspace cascades its user_tasks rows, so no separate task cleanup is
|
||||
# needed. NEVER a blanket sweep — a local stack can be shared with other
|
||||
# concurrent E2E runs.
|
||||
CREATED_WSIDS=()
|
||||
teardown() {
|
||||
local rc=$?
|
||||
set +e
|
||||
echo ""
|
||||
echo "[teardown] deleting ${#CREATED_WSIDS[@]} workspace(s) this run created (scoped)"
|
||||
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
|
||||
[ -n "$wid" ] || continue
|
||||
e2e_delete_workspace "$wid" "" ${ADMIN_AUTH[@]+"${ADMIN_AUTH[@]}"}
|
||||
done
|
||||
exit $rc
|
||||
}
|
||||
trap teardown EXIT INT TERM
|
||||
|
||||
echo "=== user_tasks E2E (REST + MCP) ==="
|
||||
echo ""
|
||||
|
||||
# ─── Setup: two sibling workspaces (A raises asks; B probes scoping) ────
|
||||
IFS=$'\t' read -r WS_A A_TOK < <(create_workspace "UserTasks-A-$$") || true
|
||||
[ -n "${WS_A:-}" ] || { echo "FATAL: ws-A setup failed"; exit 1; }
|
||||
CREATED_WSIDS+=("$WS_A")
|
||||
IFS=$'\t' read -r WS_B B_TOK < <(create_workspace "UserTasks-B-$$") || true
|
||||
[ -n "${WS_B:-}" ] || { echo "FATAL: ws-B setup failed"; exit 1; }
|
||||
CREATED_WSIDS+=("$WS_B")
|
||||
echo "ws-A=$WS_A ws-B=$WS_B"
|
||||
echo ""
|
||||
|
||||
# ─── 1. Create (REST) on ws-A → 201, status pending ────────────────────
|
||||
echo "--- 1. Create (REST) ---"
|
||||
R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/workspaces/$WS_A/user-tasks" \
|
||||
-H "Authorization: Bearer $A_TOK" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Review the Q3 draft","detail":"Need your sign-off before send"}')
|
||||
CODE=$(printf '%s' "$R" | tail -n1)
|
||||
BODY=$(printf '%s' "$R" | sed '$d')
|
||||
check_code "POST create user-task" "201" "$CODE"
|
||||
check "create returns status pending" '"status":"pending"' "$BODY"
|
||||
TASK_ID=$(printf '%s' "$BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['user_task_id'])")
|
||||
echo " TASK_ID=$TASK_ID"
|
||||
[ -n "$TASK_ID" ] || { echo "FATAL: no user_task_id returned"; }
|
||||
|
||||
# ─── 2. Read (REST workspace + admin pending) ──────────────────────────
|
||||
echo ""
|
||||
echo "--- 2. Read ---"
|
||||
R=$(curl -s "$BASE/workspaces/$WS_A/user-tasks" -H "Authorization: Bearer $A_TOK")
|
||||
check "GET ws-A user-tasks contains the task id" "$TASK_ID" "$R"
|
||||
check "GET ws-A user-tasks shows title" 'Review the Q3 draft' "$R"
|
||||
R=$(acurl "$BASE/user-tasks/pending")
|
||||
check "GET /user-tasks/pending (admin) contains the task" "$TASK_ID" "$R"
|
||||
check "pending entry carries workspace_name" "UserTasks-A-$$" "$R"
|
||||
|
||||
# ─── 3. Update (REST) PATCH title/detail → 200, change applied ─────────
|
||||
echo ""
|
||||
echo "--- 3. Update (REST PATCH) ---"
|
||||
R=$(curl -s -w "\n%{http_code}" -X PATCH "$BASE/workspaces/$WS_A/user-tasks/$TASK_ID" \
|
||||
-H "Authorization: Bearer $A_TOK" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Review the Q3 draft (URGENT)","detail":"Sign-off needed by EOD"}')
|
||||
CODE=$(printf '%s' "$R" | tail -n1)
|
||||
check_code "PATCH update user-task" "200" "$CODE"
|
||||
R=$(curl -s "$BASE/workspaces/$WS_A/user-tasks" -H "Authorization: Bearer $A_TOK")
|
||||
check "PATCH applied new title" '(URGENT)' "$R"
|
||||
check "PATCH applied new detail" 'Sign-off needed by EOD' "$R"
|
||||
|
||||
# ─── 4. Resolve (REST) done → 200, gone from pending ───────────────────
|
||||
echo ""
|
||||
echo "--- 4. Resolve (REST done) ---"
|
||||
R=$(curl -s -w "\n%{http_code}" -X POST "$BASE/workspaces/$WS_A/user-tasks/$TASK_ID/resolve" \
|
||||
-H "Authorization: Bearer $A_TOK" -H "Content-Type: application/json" \
|
||||
-d '{"status":"done","resolved_by":"cto"}')
|
||||
CODE=$(printf '%s' "$R" | tail -n1)
|
||||
BODY=$(printf '%s' "$R" | sed '$d')
|
||||
check_code "POST resolve done" "200" "$CODE"
|
||||
check "resolve echoes status done" '"status":"done"' "$BODY"
|
||||
R=$(acurl "$BASE/user-tasks/pending")
|
||||
check_not "resolved task no longer pending (admin feed)" "$TASK_ID" "$R"
|
||||
|
||||
# ─── 5. Create via MCP tool request_user_action → new pending task ─────
|
||||
# This is the agent→user ability proven end-to-end: the literal tools/call
|
||||
# the canvas agent makes, surfacing on the admin concierge feed.
|
||||
echo ""
|
||||
echo "--- 5. Create via MCP (request_user_action) ---"
|
||||
BODY=$(mcp_call "$WS_A" "$A_TOK" "request_user_action" '{"title":"Provide the staging API key","detail":"Blocked on it for the deploy"}')
|
||||
check_code "MCP request_user_action HTTP" "200" "$(mcp_http_code)"
|
||||
TEXT=$(printf '%s' "$BODY" | mcp_result_text)
|
||||
check "MCP request_user_action success text" 'Asked the user' "$TEXT"
|
||||
# A NEW pending task must appear on the admin feed.
|
||||
R=$(acurl "$BASE/user-tasks/pending")
|
||||
check "MCP-created ask appears in pending feed" 'Provide the staging API key' "$R"
|
||||
MCP_TASK_ID=$(printf '%s' "$R" | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
for t in d:
|
||||
if t.get('title') == 'Provide the staging API key':
|
||||
print(t['id']); break
|
||||
")
|
||||
echo " MCP_TASK_ID=$MCP_TASK_ID"
|
||||
[ -n "$MCP_TASK_ID" ] || echo " (note: could not resolve MCP_TASK_ID — later MCP steps assert by title)"
|
||||
|
||||
# ─── 6. list_user_tasks (MCP) returns ws-A's task(s) ───────────────────
|
||||
echo ""
|
||||
echo "--- 6. list_user_tasks (MCP) ---"
|
||||
BODY=$(mcp_call "$WS_A" "$A_TOK" "list_user_tasks" '{}')
|
||||
check_code "MCP list_user_tasks HTTP" "200" "$(mcp_http_code)"
|
||||
TEXT=$(printf '%s' "$BODY" | mcp_result_text)
|
||||
check "list_user_tasks contains the MCP task" 'Provide the staging API key' "$TEXT"
|
||||
check "list_user_tasks shows it pending" '"status":"pending"' "$TEXT"
|
||||
|
||||
# ─── 7. update_user_task (MCP) changes it → verify ─────────────────────
|
||||
echo ""
|
||||
echo "--- 7. update_user_task (MCP) ---"
|
||||
BODY=$(mcp_call "$WS_A" "$A_TOK" "update_user_task" \
|
||||
"{\"user_task_id\":\"$MCP_TASK_ID\",\"title\":\"Provide the PROD API key\"}")
|
||||
check_code "MCP update_user_task HTTP" "200" "$(mcp_http_code)"
|
||||
TEXT=$(printf '%s' "$BODY" | mcp_result_text)
|
||||
check "MCP update_user_task success text" 'User task updated' "$TEXT"
|
||||
BODY=$(mcp_call "$WS_A" "$A_TOK" "list_user_tasks" '{}')
|
||||
TEXT=$(printf '%s' "$BODY" | mcp_result_text)
|
||||
check "update applied (new title visible)" 'Provide the PROD API key' "$TEXT"
|
||||
check_not "update applied (old title gone)" 'staging API key' "$TEXT"
|
||||
|
||||
# ─── 8. delete_user_task (MCP) → gone from list ────────────────────────
|
||||
echo ""
|
||||
echo "--- 8. delete_user_task (MCP) ---"
|
||||
BODY=$(mcp_call "$WS_A" "$A_TOK" "delete_user_task" "{\"user_task_id\":\"$MCP_TASK_ID\"}")
|
||||
check_code "MCP delete_user_task HTTP" "200" "$(mcp_http_code)"
|
||||
TEXT=$(printf '%s' "$BODY" | mcp_result_text)
|
||||
check "MCP delete_user_task success text" 'User task deleted' "$TEXT"
|
||||
BODY=$(mcp_call "$WS_A" "$A_TOK" "list_user_tasks" '{}')
|
||||
TEXT=$(printf '%s' "$BODY" | mcp_result_text)
|
||||
check_not "deleted task gone from list" 'Provide the PROD API key' "$TEXT"
|
||||
|
||||
# ─── 9. Scoping / authz ────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "--- 9. Scoping / authz ---"
|
||||
# A fresh ws-A task to attempt cross-workspace mutation against.
|
||||
SCOPE_ID=$(curl -s -X POST "$BASE/workspaces/$WS_A/user-tasks" \
|
||||
-H "Authorization: Bearer $A_TOK" -H "Content-Type: application/json" \
|
||||
-d '{"title":"Scope probe task"}' | python3 -c "import sys,json; print(json.load(sys.stdin)['user_task_id'])")
|
||||
echo " SCOPE_ID=$SCOPE_ID (owned by ws-A)"
|
||||
# ws-B PATCHes ws-A's task → 404 (workspace_id scope).
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$WS_B/user-tasks/$SCOPE_ID" \
|
||||
-H "Authorization: Bearer $B_TOK" -H "Content-Type: application/json" -d '{"title":"hijack"}')
|
||||
check_code "ws-B PATCH of ws-A's task is scoped out" "404" "$CODE"
|
||||
# ws-B DELETEs ws-A's task → 404.
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X DELETE "$BASE/workspaces/$WS_B/user-tasks/$SCOPE_ID" \
|
||||
-H "Authorization: Bearer $B_TOK")
|
||||
check_code "ws-B DELETE of ws-A's task is scoped out" "404" "$CODE"
|
||||
# Task survived the cross-workspace attempts (still on ws-A, unchanged).
|
||||
R=$(curl -s "$BASE/workspaces/$WS_A/user-tasks" -H "Authorization: Bearer $A_TOK")
|
||||
check "ws-A's task survived cross-ws attempts" "$SCOPE_ID" "$R"
|
||||
check_not "ws-A's task title was NOT hijacked" 'hijack' "$R"
|
||||
# /user-tasks/pending is AdminAuth — a workspace bearer must be rejected.
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/user-tasks/pending" -H "Authorization: Bearer $A_TOK")
|
||||
if [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then
|
||||
echo "PASS: /user-tasks/pending rejects a workspace token (HTTP $CODE)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: /user-tasks/pending should reject a workspace token, got HTTP $CODE"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
# …and reject no auth at all.
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" "$BASE/user-tasks/pending")
|
||||
if [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; then
|
||||
echo "PASS: /user-tasks/pending rejects an unauthenticated caller (HTTP $CODE)"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo "FAIL: /user-tasks/pending should reject no auth, got HTTP $CODE"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
|
||||
# ─── 10. Validation ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "--- 10. Validation ---"
|
||||
# Missing title → 400.
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$WS_A/user-tasks" \
|
||||
-H "Authorization: Bearer $A_TOK" -H "Content-Type: application/json" -d '{"detail":"no title here"}')
|
||||
check_code "create without title → 400" "400" "$CODE"
|
||||
# Resolve with an invalid status → 400.
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$WS_A/user-tasks/$SCOPE_ID/resolve" \
|
||||
-H "Authorization: Bearer $A_TOK" -H "Content-Type: application/json" -d '{"status":"banana"}')
|
||||
check_code "resolve with invalid status → 400" "400" "$CODE"
|
||||
# PATCH with an invalid status → 400.
|
||||
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$WS_A/user-tasks/$SCOPE_ID" \
|
||||
-H "Authorization: Bearer $A_TOK" -H "Content-Type: application/json" -d '{"status":"banana"}')
|
||||
check_code "PATCH with invalid status → 400" "400" "$CODE"
|
||||
|
||||
echo ""
|
||||
echo "=== Results: $PASS passed, $FAIL failed ==="
|
||||
exit $FAIL
|
||||
@@ -433,6 +433,17 @@ def signal_4_branch_divergence(
|
||||
|
||||
# ── Signal 6: CI required-checks awareness ───────────────────────────────────
|
||||
|
||||
# Governance checks that are ALWAYS required for every PR, regardless of
|
||||
# branch-protection configuration. These are the uniform-gate checks that
|
||||
# must pass before any PR can merge (SOP tier removal makes them mandatory
|
||||
# for all PRs, not just tier:medium/tier:high).
|
||||
GOVERNANCE_REQUIRED_CONTEXTS = [
|
||||
"qa-review / approved (pull_request)",
|
||||
"security-review / approved (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
]
|
||||
|
||||
|
||||
def signal_6_ci(pr_number: int, repo: str, branch: str | None = None, pr_data: dict | None = None) -> dict:
|
||||
"""
|
||||
Query combined CI status for PR head commit.
|
||||
@@ -470,6 +481,9 @@ def signal_6_ci(pr_number: int, repo: str, branch: str | None = None, pr_data: d
|
||||
required_checks.append(check["context"])
|
||||
except GiteaError:
|
||||
pass # No protection or no read access
|
||||
# Uniform gate: governance checks are ALWAYS required, even if branch
|
||||
# protection does not enumerate them. Deduplicate against BP list.
|
||||
required_checks = list(dict.fromkeys(required_checks + GOVERNANCE_REQUIRED_CONTEXTS))
|
||||
|
||||
failing_required = []
|
||||
passing_required = []
|
||||
|
||||
@@ -354,3 +354,133 @@ def test_signal_4_branch_api_error_returns_na(monkeypatch):
|
||||
|
||||
assert result["verdict"] == "N/A"
|
||||
assert "error" in result
|
||||
|
||||
|
||||
# ── Signal 6: CI required checks ────────────────────────────────────────────
|
||||
|
||||
|
||||
def _signal_6_api_get(required_checks, statuses):
|
||||
"""Return a fake_api_get closure for signal_6 tests."""
|
||||
def fake_api_get(path):
|
||||
if path == "/repos/molecule-ai/molecule-core/pulls/200":
|
||||
return {"base": {"sha": "base000", "ref": "main"}, "head": {"sha": "pr222"}}
|
||||
if path == "/repos/molecule-ai/molecule-core/commits/pr222/status":
|
||||
return {"state": "failure", "statuses": statuses}
|
||||
if path == "/repos/molecule-ai/molecule-core/branches/main/protection":
|
||||
return {"required_status_checks": {"checks": [{"context": c} for c in required_checks]}}
|
||||
raise AssertionError(f"unexpected api_get: {path}")
|
||||
return fake_api_get
|
||||
|
||||
|
||||
def test_signal_6_missing_required_context_returns_ci_pending(monkeypatch):
|
||||
"""A required check that is ABSENT from the status list is treated as missing,
|
||||
which is fail-closed → CI_PENDING (never ready-by-absence)."""
|
||||
mod = load_gate_check()
|
||||
monkeypatch.setattr(
|
||||
mod, "api_get",
|
||||
_signal_6_api_get(
|
||||
required_checks=["qa-review / approved (pull_request)", "security-review / approved (pull_request)"],
|
||||
statuses=[
|
||||
{"context": "qa-review / approved (pull_request)", "status": "success"},
|
||||
# security-review is completely missing
|
||||
],
|
||||
),
|
||||
)
|
||||
result = mod.signal_6_ci(200, "molecule-ai/molecule-core")
|
||||
assert result["verdict"] == "CI_PENDING"
|
||||
assert "security-review / approved (pull_request)" in result["pending_required"]
|
||||
|
||||
|
||||
def test_signal_6_pending_required_context_returns_ci_pending(monkeypatch):
|
||||
"""A required check with status 'pending' blocks the gate with CI_PENDING."""
|
||||
mod = load_gate_check()
|
||||
monkeypatch.setattr(
|
||||
mod, "api_get",
|
||||
_signal_6_api_get(
|
||||
required_checks=[
|
||||
"qa-review / approved (pull_request)",
|
||||
"security-review / approved (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
],
|
||||
statuses=[
|
||||
{"context": "qa-review / approved (pull_request)", "status": "success"},
|
||||
{"context": "security-review / approved (pull_request)", "status": "pending"},
|
||||
{"context": "sop-checklist / all-items-acked (pull_request)", "status": "success"},
|
||||
],
|
||||
),
|
||||
)
|
||||
result = mod.signal_6_ci(200, "molecule-ai/molecule-core")
|
||||
assert result["verdict"] == "CI_PENDING"
|
||||
assert "security-review / approved (pull_request)" in result["pending_required"]
|
||||
|
||||
|
||||
def test_signal_6_failing_required_context_returns_ci_fail(monkeypatch):
|
||||
"""A required check with status 'failure' blocks the gate with CI_FAIL."""
|
||||
mod = load_gate_check()
|
||||
monkeypatch.setattr(
|
||||
mod, "api_get",
|
||||
_signal_6_api_get(
|
||||
required_checks=[
|
||||
"qa-review / approved (pull_request)",
|
||||
"security-review / approved (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
"CI / all-required (pull_request)",
|
||||
],
|
||||
statuses=[
|
||||
{"context": "qa-review / approved (pull_request)", "status": "failure"},
|
||||
{"context": "security-review / approved (pull_request)", "status": "success"},
|
||||
{"context": "sop-checklist / all-items-acked (pull_request)", "status": "success"},
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
],
|
||||
),
|
||||
)
|
||||
result = mod.signal_6_ci(200, "molecule-ai/molecule-core")
|
||||
assert result["verdict"] == "CI_FAIL"
|
||||
assert "qa-review / approved (pull_request)" in result["failing_required"]
|
||||
|
||||
|
||||
def test_signal_6_all_required_green_returns_clear(monkeypatch):
|
||||
"""When every required check is success/neutral, the gate is CLEAR."""
|
||||
mod = load_gate_check()
|
||||
monkeypatch.setattr(
|
||||
mod, "api_get",
|
||||
_signal_6_api_get(
|
||||
required_checks=[
|
||||
"qa-review / approved (pull_request)",
|
||||
"security-review / approved (pull_request)",
|
||||
"sop-checklist / all-items-acked (pull_request)",
|
||||
"CI / all-required (pull_request)",
|
||||
],
|
||||
statuses=[
|
||||
{"context": "qa-review / approved (pull_request)", "status": "success"},
|
||||
{"context": "security-review / approved (pull_request)", "status": "success"},
|
||||
{"context": "sop-checklist / all-items-acked (pull_request)", "status": "success"},
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
],
|
||||
),
|
||||
)
|
||||
result = mod.signal_6_ci(200, "molecule-ai/molecule-core")
|
||||
assert result["verdict"] == "CLEAR"
|
||||
assert result["pending_required"] == []
|
||||
assert result["failing_required"] == []
|
||||
|
||||
|
||||
def test_signal_6_governance_checks_always_required_even_when_bp_empty(monkeypatch):
|
||||
"""Uniform gate: qa/security/sop are REQUIRED even if branch protection
|
||||
does not enumerate them. A PR with only CI/all-required green but missing
|
||||
governance contexts must be CI_PENDING (fail-closed)."""
|
||||
mod = load_gate_check()
|
||||
monkeypatch.setattr(
|
||||
mod, "api_get",
|
||||
_signal_6_api_get(
|
||||
required_checks=[], # BP lists nothing
|
||||
statuses=[
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
],
|
||||
),
|
||||
)
|
||||
result = mod.signal_6_ci(200, "molecule-ai/molecule-core")
|
||||
assert result["verdict"] == "CI_PENDING"
|
||||
assert "qa-review / approved (pull_request)" in result["pending_required"]
|
||||
assert "security-review / approved (pull_request)" in result["pending_required"]
|
||||
assert "sop-checklist / all-items-acked (pull_request)" in result["pending_required"]
|
||||
|
||||
@@ -119,6 +119,18 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Self-hosted platform-agent seed. With no control plane present to install
|
||||
// the org's concierge (SaaS leaves it to the CP at org-provision time), the
|
||||
// tenant server seeds it itself when MOLECULE_SEED_PLATFORM_AGENT is set —
|
||||
// the self-hosted docker-compose sets it, while CI harnesses + SaaS tenants
|
||||
// leave it unset (so e2e empty-DB assertions and the CP path are unaffected).
|
||||
// Idempotent + best-effort — never fatal.
|
||||
if v := os.Getenv("MOLECULE_SEED_PLATFORM_AGENT"); v == "true" || v == "1" {
|
||||
if err := handlers.EnsureSelfHostedPlatformAgent(context.Background(), db.DB); err != nil {
|
||||
log.Printf("boot: platform-agent self-seed failed (non-fatal): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Redis
|
||||
redisURL := envOr("REDIS_URL", "redis://localhost:6379")
|
||||
if err := db.InitRedis(redisURL); err != nil {
|
||||
@@ -237,6 +249,25 @@ func main() {
|
||||
wh.SetCPProvisioner(cpProv)
|
||||
}
|
||||
|
||||
// Self-hosted platform-agent boot-provision (Change 1). The line-128 seed
|
||||
// only creates the concierge DB ROW; on a fresh self-host that leaves it
|
||||
// with no container (status='failed'/'online' but nothing running). Now that
|
||||
// the local Docker provisioner (prov) and WorkspaceHandler (RestartByID)
|
||||
// exist, kick off a best-effort provision so a self-hosted concierge comes
|
||||
// online automatically once LLM creds exist.
|
||||
//
|
||||
// Guarded to self-host ONLY: same MOLECULE_SEED_PLATFORM_AGENT flag as the
|
||||
// seed AND prov != nil (local Docker active ⇒ MOLECULE_ORG_ID unset). The
|
||||
// SaaS path (cpProv != nil ⇒ prov == nil) never triggers — the CP owns
|
||||
// concierge provisioning there. Best-effort + non-fatal + runs once: on a
|
||||
// fresh self-host with no creds the provision fails and the agent stays
|
||||
// 'failed' until BYOK is configured via Settings; RestartByID is itself
|
||||
// debounced so this can't loop. Runs in a goroutine inside the helper so a
|
||||
// slow image pull never delays the HTTP server.
|
||||
if v := os.Getenv("MOLECULE_SEED_PLATFORM_AGENT"); (v == "true" || v == "1") && prov != nil {
|
||||
handlers.MaybeProvisionPlatformAgentOnBoot(context.Background(), db.DB, prov, wh.RestartByID)
|
||||
}
|
||||
|
||||
// Memory v2 plugin (RFC #2728): build the dependency bundle once
|
||||
// here so all three handlers (MCPHandler, AdminMemoriesHandler,
|
||||
// WorkspaceHandler) get the same plugin/resolver pair. memBundle
|
||||
|
||||
@@ -12,12 +12,63 @@
|
||||
"host": "api.moleculesai.app",
|
||||
"basePath": "/",
|
||||
"paths": {
|
||||
"/org/identity": {
|
||||
"get": {
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"org"
|
||||
],
|
||||
"summary": "Get the org's display name",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.OrgIdentityResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/user-tasks/pending": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"user-tasks"
|
||||
],
|
||||
"summary": "List pending user tasks across all workspaces",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/handlers.PendingUserTask"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/schedules": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -57,8 +108,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -115,8 +165,7 @@
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -166,8 +215,7 @@
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
@@ -237,8 +285,7 @@
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -287,8 +334,7 @@
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": [],
|
||||
"OrgSlugAuth": []
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
@@ -335,6 +381,293 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/user-tasks": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"user-tasks"
|
||||
],
|
||||
"summary": "List a workspace's own user tasks",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/handlers.UserTask"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"user-tasks"
|
||||
],
|
||||
"summary": "Raise a user task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Task fields",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.CreateUserTaskRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Created",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.CreateUserTaskResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/user-tasks/{taskId}": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"user-tasks"
|
||||
],
|
||||
"summary": "Delete a workspace's own user task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User task ID",
|
||||
"name": "taskId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.UserTaskMutationResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"user-tasks"
|
||||
],
|
||||
"summary": "Update a workspace's own user task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User task ID",
|
||||
"name": "taskId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Partial task fields (only provided keys are updated)",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.UpdateUserTaskRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.UserTaskMutationResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/workspaces/{id}/user-tasks/{taskId}/resolve": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth \u0026\u0026 OrgSlugAuth": []
|
||||
}
|
||||
],
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"user-tasks"
|
||||
],
|
||||
"summary": "Resolve a user task",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Workspace ID",
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User task ID",
|
||||
"name": "taskId",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"description": "Resolution",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ResolveUserTaskRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ResolveUserTaskResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/handlers.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -376,6 +709,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.CreateUserTaskRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"title"
|
||||
],
|
||||
"properties": {
|
||||
"detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.CreateUserTaskResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_task_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -404,6 +762,73 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.OrgIdentityResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "Name is the org's display name (MOLECULE_ORG_NAME, \"\" when unset).",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.PendingUserTask": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"workspace_name": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ResolveUserTaskRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"properties": {
|
||||
"resolved_by": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"done",
|
||||
"dismissed"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.ResolveUserTaskResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_task_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.RunNowResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -496,6 +921,67 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.UpdateUserTaskRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"done",
|
||||
"dismissed"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.UserTask": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"detail": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"resolved_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"resolved_by": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"pending",
|
||||
"done",
|
||||
"dismissed"
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"handlers.UserTaskMutationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_task_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"securityDefinitions": {
|
||||
|
||||
@@ -25,6 +25,22 @@ definitions:
|
||||
status:
|
||||
type: string
|
||||
type: object
|
||||
handlers.CreateUserTaskRequest:
|
||||
properties:
|
||||
detail:
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
required:
|
||||
- title
|
||||
type: object
|
||||
handlers.CreateUserTaskResponse:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
user_task_id:
|
||||
type: string
|
||||
type: object
|
||||
handlers.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
@@ -43,6 +59,50 @@ definitions:
|
||||
timestamp:
|
||||
type: string
|
||||
type: object
|
||||
handlers.OrgIdentityResponse:
|
||||
properties:
|
||||
name:
|
||||
description: Name is the org's display name (MOLECULE_ORG_NAME, "" when unset).
|
||||
type: string
|
||||
type: object
|
||||
handlers.PendingUserTask:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
detail:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- pending
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
workspace_id:
|
||||
type: string
|
||||
workspace_name:
|
||||
type: string
|
||||
type: object
|
||||
handlers.ResolveUserTaskRequest:
|
||||
properties:
|
||||
resolved_by:
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- done
|
||||
- dismissed
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
type: object
|
||||
handlers.ResolveUserTaskResponse:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
user_task_id:
|
||||
type: string
|
||||
type: object
|
||||
handlers.RunNowResponse:
|
||||
properties:
|
||||
prompt:
|
||||
@@ -105,6 +165,47 @@ definitions:
|
||||
timezone:
|
||||
type: string
|
||||
type: object
|
||||
handlers.UpdateUserTaskRequest:
|
||||
properties:
|
||||
detail:
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- pending
|
||||
- done
|
||||
- dismissed
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
handlers.UserTask:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
detail:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
resolved_at:
|
||||
type: string
|
||||
resolved_by:
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- pending
|
||||
- done
|
||||
- dismissed
|
||||
type: string
|
||||
title:
|
||||
type: string
|
||||
type: object
|
||||
handlers.UserTaskMutationResponse:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
user_task_id:
|
||||
type: string
|
||||
type: object
|
||||
host: api.moleculesai.app
|
||||
info:
|
||||
contact: {}
|
||||
@@ -115,6 +216,38 @@ info:
|
||||
title: Molecule AI Workspace Server API
|
||||
version: "1.0"
|
||||
paths:
|
||||
/org/identity:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.OrgIdentityResponse'
|
||||
summary: Get the org's display name
|
||||
tags:
|
||||
- org
|
||||
/user-tasks/pending:
|
||||
get:
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/handlers.PendingUserTask'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: List pending user tasks across all workspaces
|
||||
tags:
|
||||
- user-tasks
|
||||
/workspaces/{id}/schedules:
|
||||
get:
|
||||
parameters:
|
||||
@@ -137,8 +270,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: List schedules for a workspace
|
||||
tags:
|
||||
- schedules
|
||||
@@ -173,8 +305,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Create a schedule
|
||||
tags:
|
||||
- schedules
|
||||
@@ -207,8 +338,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Delete a schedule
|
||||
tags:
|
||||
- schedules
|
||||
@@ -252,8 +382,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Update a schedule
|
||||
tags:
|
||||
- schedules
|
||||
@@ -284,8 +413,7 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Get past runs of a schedule
|
||||
tags:
|
||||
- schedules
|
||||
@@ -318,11 +446,193 @@ paths:
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
OrgSlugAuth: []
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Fire a schedule manually
|
||||
tags:
|
||||
- schedules
|
||||
/workspaces/{id}/user-tasks:
|
||||
get:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/handlers.UserTask'
|
||||
type: array
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: List a workspace's own user tasks
|
||||
tags:
|
||||
- user-tasks
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: Task fields
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.CreateUserTaskRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Created
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.CreateUserTaskResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Raise a user task
|
||||
tags:
|
||||
- user-tasks
|
||||
/workspaces/{id}/user-tasks/{taskId}:
|
||||
delete:
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: User task ID
|
||||
in: path
|
||||
name: taskId
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.UserTaskMutationResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Delete a workspace's own user task
|
||||
tags:
|
||||
- user-tasks
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: User task ID
|
||||
in: path
|
||||
name: taskId
|
||||
required: true
|
||||
type: string
|
||||
- description: Partial task fields (only provided keys are updated)
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.UpdateUserTaskRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.UserTaskMutationResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Update a workspace's own user task
|
||||
tags:
|
||||
- user-tasks
|
||||
/workspaces/{id}/user-tasks/{taskId}/resolve:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
parameters:
|
||||
- description: Workspace ID
|
||||
in: path
|
||||
name: id
|
||||
required: true
|
||||
type: string
|
||||
- description: User task ID
|
||||
in: path
|
||||
name: taskId
|
||||
required: true
|
||||
type: string
|
||||
- description: Resolution
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ResolveUserTaskRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ResolveUserTaskResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"404":
|
||||
description: Not Found
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/handlers.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth && OrgSlugAuth: []
|
||||
summary: Resolve a user task
|
||||
tags:
|
||||
- user-tasks
|
||||
schemes:
|
||||
- https
|
||||
securityDefinitions:
|
||||
|
||||
@@ -80,6 +80,10 @@ const (
|
||||
EventApprovalRequested EventType = "APPROVAL_REQUESTED"
|
||||
EventApprovalEscalated EventType = "APPROVAL_ESCALATED"
|
||||
|
||||
// User tasks (agent → user asks).
|
||||
EventUserTaskRequested EventType = "USER_TASK_REQUESTED"
|
||||
EventUserTaskResolved EventType = "USER_TASK_RESOLVED"
|
||||
|
||||
// Auth / credentials.
|
||||
EventExternalCredentialsRotated EventType = "EXTERNAL_CREDENTIALS_ROTATED"
|
||||
)
|
||||
@@ -112,6 +116,8 @@ var AllEventTypes = []EventType{
|
||||
EventDelegationStatus,
|
||||
EventExternalCredentialsRotated,
|
||||
EventTaskUpdated,
|
||||
EventUserTaskRequested,
|
||||
EventUserTaskResolved,
|
||||
EventWorkspaceAwaitingAgent,
|
||||
EventWorkspaceDegraded,
|
||||
EventWorkspaceHeartbeat,
|
||||
|
||||
@@ -41,6 +41,8 @@ func TestAllEventTypes_IsSnapshot(t *testing.T) {
|
||||
"DELEGATION_STATUS",
|
||||
"EXTERNAL_CREDENTIALS_ROTATED",
|
||||
"TASK_UPDATED",
|
||||
"USER_TASK_REQUESTED",
|
||||
"USER_TASK_RESOLVED",
|
||||
"WORKSPACE_AWAITING_AGENT",
|
||||
"WORKSPACE_DEGRADED",
|
||||
"WORKSPACE_HEARTBEAT",
|
||||
|
||||
@@ -154,16 +154,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
// #319: encrypt sensitive fields (bot_token, webhook_secret) before
|
||||
// persisting so a DB read/backup leak can't recover the credentials.
|
||||
// Validation above ran against plaintext; storage is ciphertext.
|
||||
if err := channels.EncryptSensitiveFields(body.Config); err != nil {
|
||||
log.Printf("Channels: encrypt config failed for workspace %s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// #319: encrypt sensitive fields (bot_token, webhook_secret) before
|
||||
// persisting so a DB read/backup leak can't recover the credentials.
|
||||
// persisting. Exactly one call here; duplicate removed in this PR.
|
||||
// Validation above ran against plaintext; storage is ciphertext.
|
||||
if err := channels.EncryptSensitiveFields(body.Config); err != nil {
|
||||
log.Printf("Channels: encrypt config failed for workspace %s: %v", workspaceID, err)
|
||||
|
||||
@@ -5,16 +5,21 @@ import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"database/sql/driver"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sqlmock "github.com/DATA-DOG/go-sqlmock"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/channels"
|
||||
channels_crypto "git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/crypto"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -166,6 +171,42 @@ func TestChannelHandler_List_InvalidJSON_FallsBack(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHandler_List_RowsErr_LogsError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "workspace_id", "channel_type", "channel_config", "enabled",
|
||||
"allowed_users", "last_message_at", "message_count", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"ch-1", "ws-1", "telegram",
|
||||
[]byte(`{"bot_token":"123:ABCDEFGHIJ","chat_id":"-100"}`),
|
||||
true, []byte(`["user-1"]`), nil, 5, nil, nil,
|
||||
).RowError(1, errors.New("storage engine fault"))
|
||||
mock.ExpectQuery("SELECT .* FROM workspace_channels WHERE workspace_id").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("GET", "/workspaces/ws-1/channels", nil)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
|
||||
handler.List(c)
|
||||
|
||||
// rows.Err() is non-fatal — the handler logs and still returns the row
|
||||
// that was successfully scanned before the iteration error.
|
||||
if w.Code != 200 {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var result []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &result)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 channel despite rows.Err, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Create ====================
|
||||
|
||||
func TestChannelHandler_Create_Success(t *testing.T) {
|
||||
@@ -203,6 +244,66 @@ func TestChannelHandler_Create_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// encryptedConfigArg matches INSERT args where bot_token has the ec1: prefix.
|
||||
type encryptedConfigArg struct{}
|
||||
|
||||
func (a encryptedConfigArg) Match(v driver.Value) bool {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var cfg map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(s), &cfg); err != nil {
|
||||
return false
|
||||
}
|
||||
token, ok := cfg["bot_token"].(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// #319: bot_token must be encrypted (ciphertextPrefix "ec1:")
|
||||
// before persistence, NOT stored plaintext.
|
||||
return strings.HasPrefix(token, "ec1:")
|
||||
}
|
||||
|
||||
func TestChannelHandler_Create_EncryptsSensitiveFields(t *testing.T) {
|
||||
// Enable encryption for this test so EncryptSensitiveFields actually transforms.
|
||||
os.Setenv("SECRETS_ENCRYPTION_KEY", base64.StdEncoding.EncodeToString(make([]byte, 32)))
|
||||
channels_crypto.ResetForTesting()
|
||||
channels_crypto.Init()
|
||||
defer func() {
|
||||
os.Unsetenv("SECRETS_ENCRYPTION_KEY")
|
||||
channels_crypto.ResetForTesting()
|
||||
}()
|
||||
|
||||
mock := setupTestDB(t)
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
mock.ExpectQuery("INSERT INTO workspace_channels").
|
||||
WithArgs("ws-1", "telegram", encryptedConfigArg{}, true, sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-ch-id"))
|
||||
// Reload query
|
||||
mock.ExpectQuery("SELECT .* FROM workspace_channels").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "workspace_id", "channel_type", "channel_config", "enabled", "allowed_users"}))
|
||||
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"channel_type": "telegram",
|
||||
"config": map[string]interface{}{"bot_token": "123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "chat_id": "-100"},
|
||||
"allowed_users": []string{"user-1"},
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request, _ = http.NewRequest("POST", "/workspaces/ws-1/channels", bytes.NewReader(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != 201 {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelHandler_Create_MissingType(t *testing.T) {
|
||||
handler := NewChannelHandler(newTestChannelManager())
|
||||
|
||||
|
||||
@@ -2,15 +2,18 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/middleware"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/orgtoken"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/provisioner"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/registry"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/wsauth"
|
||||
@@ -450,41 +453,58 @@ func validateDiscoveryCaller(ctx context.Context, c *gin.Context, workspaceID st
|
||||
// NEXT_PUBLIC_ADMIN_TOKEN (see scripts/dev-start.sh), so the Details
|
||||
// tab loads peers with a real credential rather than via fail-open.
|
||||
|
||||
// Try session cookie auth first (SaaS canvas path).
|
||||
// verifiedCPSession returns (valid, presented):
|
||||
// - (false, false) = no cookie, fall through to bearer
|
||||
// - (true, true) = valid session, allow
|
||||
// - (false, true) = cookie presented but invalid, 401
|
||||
if cookieHeader := c.GetHeader("Cookie"); cookieHeader != "" {
|
||||
if ok, presented := middleware.VerifiedCPSession(cookieHeader); presented {
|
||||
if ok {
|
||||
return nil // session verified, allow
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return errors.New("invalid session")
|
||||
// Precedence MUST match middleware.WorkspaceAuth: try the bearer token
|
||||
// first (admin → org → per-workspace), and only fall back to a verified
|
||||
// CP-session cookie when no bearer is presented. Keeping the two auth
|
||||
// surfaces in the same order means a credential that passes one passes
|
||||
// the other — divergent precedence is how an admin/org bearer ended up
|
||||
// 401'ing on one surface but not the other.
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok != "" {
|
||||
// Admin-token fallback — lets the canvas operator (dashboard /
|
||||
// concierge Settings config tabs) read a workspace's peers with the
|
||||
// single admin credential, mirroring middleware.WorkspaceAuth.
|
||||
// Without this the operator's admin bearer fell through to the
|
||||
// per-workspace ValidateToken below and 401'd for any workspace it
|
||||
// doesn't personally hold a token for — e.g. the platform agent
|
||||
// surfaced in the concierge config tabs.
|
||||
if adminSecret := os.Getenv("ADMIN_TOKEN"); adminSecret != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(tok), []byte(adminSecret)) == 1 {
|
||||
return nil
|
||||
}
|
||||
// Org-scoped API token — grants access to every workspace in the org
|
||||
// (same product spec as WorkspaceAuth). Checked before the
|
||||
// per-workspace token so an org-key presenter doesn't hit the
|
||||
// narrower failure path.
|
||||
if _, _, _, err := orgtoken.Validate(ctx, db.DB, tok); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, orgtoken.ErrInvalidToken) {
|
||||
log.Printf("wsauth: discovery orgtoken.Validate(%s): datastore lookup failed (returning 503): %v", workspaceID, err)
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"error": "platform datastore unavailable — retry shortly",
|
||||
"code": "platform_unavailable",
|
||||
})
|
||||
return err
|
||||
}
|
||||
if err := wsauth.ValidateToken(ctx, db.DB, workspaceID, tok); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid workspace auth token"})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
tok := wsauth.BearerTokenFromHeader(c.GetHeader("Authorization"))
|
||||
if tok == "" {
|
||||
// Canvas hits this endpoint via session cookie, not bearer token.
|
||||
// verifiedCPSession returns (valid, presented):
|
||||
// - (false, false) = no cookie, 401
|
||||
// - (true, true) = valid session, allow
|
||||
// - (false, true) = cookie presented but invalid, 401
|
||||
if ok, presented := middleware.VerifiedCPSession(c.GetHeader("Cookie")); presented {
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return errors.New("invalid session")
|
||||
// No bearer: SaaS-canvas path authenticates via a CP-session cookie.
|
||||
// VerifiedCPSession returns (valid, presented):
|
||||
// - (false, false) = no cookie, 401 (missing auth)
|
||||
// - (true, true) = valid session, allow
|
||||
// - (false, true) = cookie presented but invalid, 401
|
||||
if ok, presented := middleware.VerifiedCPSession(c.GetHeader("Cookie")); presented {
|
||||
if ok {
|
||||
return nil
|
||||
}
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
|
||||
return errors.New("missing token")
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||
return errors.New("invalid session")
|
||||
}
|
||||
if err := wsauth.ValidateToken(ctx, db.DB, workspaceID, tok); err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid workspace auth token"})
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "missing workspace auth token"})
|
||||
return errors.New("missing token")
|
||||
}
|
||||
|
||||
@@ -277,6 +277,52 @@ func TestPeers_RootWorkspace_NoPeers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateDiscoveryCaller must accept the org ADMIN_TOKEN (the canvas
|
||||
// operator's credential) even when the workspace has its OWN live token — so
|
||||
// the concierge config tabs (Details → peers) load for the platform agent,
|
||||
// which the operator doesn't personally hold a per-workspace token for.
|
||||
// Regression guard for the 401 the discovery routes returned before the
|
||||
// admin/org-token fallback was added.
|
||||
func TestPeers_AdminToken_Allowed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewDiscoveryHandler()
|
||||
|
||||
const adminTok = "test-admin-token"
|
||||
t.Setenv("ADMIN_TOKEN", adminTok)
|
||||
|
||||
// A live token EXISTS for the workspace (grandfather path NOT taken), so a
|
||||
// valid credential is required. The operator presents ADMIN_TOKEN, not the
|
||||
// workspace's own per-workspace token.
|
||||
mock.ExpectQuery("SELECT COUNT.+workspace_auth_tokens").
|
||||
WithArgs("ws-platform").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
// After the admin-token fallback allows, Peers runs its lookups (org root).
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-platform").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}).AddRow(nil))
|
||||
peerCols := []string{"id", "name", "role", "tier", "status", "agent_card", "url", "parent_id", "active_tasks"}
|
||||
mock.ExpectQuery("SELECT w.id, w.name.*WHERE w.parent_id = \\$1 AND w.id != \\$2").
|
||||
WithArgs("ws-platform", "ws-platform").
|
||||
WillReturnRows(sqlmock.NewRows(peerCols))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-platform"}}
|
||||
c.Request = httptest.NewRequest("GET", "/registry/ws-platform/peers", nil)
|
||||
c.Request.Header.Set("Authorization", "Bearer "+adminTok)
|
||||
|
||||
handler.Peers(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("admin token should be accepted; expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Peers — ?q= filter (#1038) ====================
|
||||
|
||||
// peersFilterFixture mocks the 4 SQL reads (parent_id lookup + siblings +
|
||||
|
||||
@@ -244,13 +244,13 @@ func TestWorkspaceList_WithData(t *testing.T) {
|
||||
"last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
rows := sqlmock.NewRows(columns).
|
||||
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte(`{"name":"agent1"}`), "http://localhost:8001",
|
||||
nil, 3, 1, 0.02, "", 7200, "processing", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
|
||||
nil, 3, 1, 0.02, "", 7200, "processing", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`), "workspace").
|
||||
AddRow("ws-2", "Agent Two", "", 2, "degraded", []byte("null"), "",
|
||||
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true, []byte(`{}`))
|
||||
nil, 0, 1, 0.6, "timeout", 100, "", "claude-code", "", 50.0, 60.0, true, nil, int64(0), false, true, []byte(`{}`), "workspace")
|
||||
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WillReturnRows(rows)
|
||||
|
||||
@@ -533,13 +533,13 @@ func TestWorkspaceList(t *testing.T) {
|
||||
"last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
rows := sqlmock.NewRows(columns).
|
||||
AddRow("ws-1", "Agent One", "worker", 1, "online", []byte("null"), "http://localhost:8001",
|
||||
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`)).
|
||||
nil, 0, 1, 0.0, "", 100, "", "claude-code", "", 10.0, 20.0, false, nil, int64(0), false, true, []byte(`{}`), "workspace").
|
||||
AddRow("ws-2", "Agent Two", "manager", 2, "provisioning", []byte("null"), "",
|
||||
nil, 0, 1, 0.0, "", 0, "", "claude-code", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`))
|
||||
nil, 0, 1, 0.0, "", 0, "", "claude-code", "", 50.0, 60.0, false, nil, int64(0), false, true, []byte(`{}`), "workspace")
|
||||
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WillReturnRows(rows)
|
||||
@@ -1253,14 +1253,14 @@ func TestWorkspaceGet_CurrentTask(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("dddddddd-0004-0000-0000-000000000000").
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow(
|
||||
"dddddddd-0004-0000-0000-000000000000", "Task Worker", "worker", 1, "online", []byte("null"), "http://localhost:9000",
|
||||
nil, 2, 1, 0.0, "", 300, "Analyzing document", "claude-code", "", 10.0, 20.0, false,
|
||||
nil, int64(0), false, true, []byte(`{}`),
|
||||
nil, int64(0), false, true, []byte(`{}`), "workspace",
|
||||
))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -14,13 +14,17 @@
|
||||
//
|
||||
// Why this is NOT a sqlmock test
|
||||
// ------------------------------
|
||||
// The invariant "a platform agent must be the org root (parent_id IS NULL),
|
||||
// which structurally also means at most one platform agent per org" is enforced
|
||||
// by the workspaces_platform_root_check CHECK constraint in migration
|
||||
// 20260606000000_workspaces_kind. sqlmock cannot execute DDL or evaluate a CHECK
|
||||
// constraint, so only a real Postgres can prove the constraint actually rejects
|
||||
// a non-root platform agent and accepts a root one. The Register handler's
|
||||
// isPlatformRootViolation()/409 path depends on this constraint firing.
|
||||
// Two DB-level invariants back the platform agent:
|
||||
// - "a platform agent must be the org root (parent_id IS NULL)" — the
|
||||
// workspaces_platform_root_check CHECK in migration 20260606000000.
|
||||
// - "at most one platform agent per org" — the partial unique index
|
||||
// uniq_workspaces_one_platform_root in migration 20260607000000. The CHECK
|
||||
// does NOT bound the count (it permits multiple parentless platform rows);
|
||||
// the unique index does. This closes a privilege-escalation path (a rogue
|
||||
// second org root getting the org-admin token at provision time).
|
||||
// sqlmock cannot execute DDL or evaluate these, so only a real Postgres can
|
||||
// prove they fire. The Register handler's isPlatformRootViolation()/409 path
|
||||
// depends on both constraints.
|
||||
|
||||
package handlers
|
||||
|
||||
@@ -120,3 +124,64 @@ func TestIntegration_PlatformKind_RootAllowed_NonRootRejected(t *testing.T) {
|
||||
t.Fatalf("unknown kind wanted workspaces_kind_check rejection, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_PlatformKind_SecondRootRejected proves the privilege-escalation
|
||||
// fix at the DB level: the workspaces_platform_root_check CHECK alone permits
|
||||
// MULTIPLE parentless platform rows; the partial unique index
|
||||
// uniq_workspaces_one_platform_root (migration 20260607000000) forbids a SECOND
|
||||
// platform root. Without it, an ordinary in-VPC workspace could register a fresh
|
||||
// UUID as kind='platform' and mint itself a second org root that then gets the
|
||||
// org-admin token at provision time. This is what the per-row CHECK could not
|
||||
// stop — only a real Postgres with the unique index proves it.
|
||||
func TestIntegration_PlatformKind_SecondRootRejected(t *testing.T) {
|
||||
conn := integrationDB_PlatformKind(t)
|
||||
ctx := context.Background()
|
||||
|
||||
prefix := fmt.Sprintf("itest-2root-%s", uuid.New().String()[:8])
|
||||
cleanup := func() {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`DELETE FROM workspaces WHERE name LIKE $1`, prefix+"%"); err != nil {
|
||||
t.Logf("cleanup (non-fatal): %v", err)
|
||||
}
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
cleanup()
|
||||
|
||||
// NOTE: the shared integration DB is single-org by construction, but a stray
|
||||
// platform row from another suite would make the FIRST insert below collide
|
||||
// instead of the second. Guard by asserting we start from zero platform rows
|
||||
// for our prefix and using a savepoint-free, prefix-scoped check.
|
||||
first := uuid.New().String()
|
||||
second := uuid.New().String()
|
||||
|
||||
// First parentless platform root: allowed.
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, kind, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 'platform', 0, 'claude-code', 'online', NULL)
|
||||
`, first, prefix+"-first"); err != nil {
|
||||
// If this fails on the unique index, another platform root already exists
|
||||
// in the shared DB — skip rather than false-fail this isolation-sensitive case.
|
||||
if strings.Contains(err.Error(), "uniq_workspaces_one_platform_root") {
|
||||
t.Skipf("shared integration DB already has a platform root; cannot isolate: %v", err)
|
||||
}
|
||||
t.Fatalf("first platform root insert: unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Second parentless platform root: the per-row CHECK is satisfied
|
||||
// (parent_id IS NULL), so ONLY the unique index can reject it.
|
||||
_, err := conn.ExecContext(ctx, `
|
||||
INSERT INTO workspaces (id, name, kind, tier, runtime, status, parent_id)
|
||||
VALUES ($1, $2, 'platform', 0, 'claude-code', 'online', NULL)
|
||||
`, second, prefix+"-second")
|
||||
if err == nil {
|
||||
t.Fatalf("second platform root accepted — uniq_workspaces_one_platform_root did not fire (privilege-escalation guard missing)")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "uniq_workspaces_one_platform_root") {
|
||||
t.Fatalf("second platform root rejection wanted uniq_workspaces_one_platform_root, got: %v", err)
|
||||
}
|
||||
|
||||
// And isPlatformRootViolation maps it to the friendly 409 surface.
|
||||
if !isPlatformRootViolation(err) {
|
||||
t.Fatalf("isPlatformRootViolation should classify the unique-index violation as a platform-root 409, got false for: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,30 @@ type BillingModeResolution struct {
|
||||
ProviderSelection *string `json:"provider_selection"`
|
||||
}
|
||||
|
||||
// defaultClosedBillingMode is the mode the resolver falls back to when it
|
||||
// cannot DERIVE a provider (no model, unknown runtime, unregistered/ambiguous
|
||||
// model, registry-load failure, or the pre-provision empty-id path).
|
||||
//
|
||||
// Historically this was an UNCONDITIONAL platform_managed ("unset → platform
|
||||
// default", CTO 2026-05-27). That is correct on SaaS: an undecided workspace
|
||||
// bills the platform proxy. But on a SELF-HOSTED stack there IS no Molecule
|
||||
// proxy and no credit ledger (PlatformManagedProxyConfigured() == false), so a
|
||||
// platform_managed default is unreachable — the provision path would inject no
|
||||
// usable credential and fail closed (MISSING_PLATFORM_PROXY). On self-host the
|
||||
// honest default is byok: the tenant must bring their own provider key, and the
|
||||
// resolved mode should say so rather than advertise an impossible mode.
|
||||
//
|
||||
// Strictly gated on the no-proxy condition: when a proxy IS configured (SaaS),
|
||||
// this returns platform_managed exactly as before — SaaS behavior is unchanged.
|
||||
// This only changes the FALLBACK; an explicit operator override and a
|
||||
// successfully-derived provider are decided before this is ever consulted.
|
||||
func defaultClosedBillingMode() string {
|
||||
if PlatformManagedProxyConfigured() {
|
||||
return LLMBillingModePlatformManaged
|
||||
}
|
||||
return LLMBillingModeBYOK
|
||||
}
|
||||
|
||||
// isKnownBillingMode is the enum-recognizer for the resolver's default-closed
|
||||
// branch. Returning false for an unknown string forces the resolver to fall
|
||||
// through to the next layer (or the constant fallback) — NEVER to honor a
|
||||
@@ -212,7 +236,7 @@ func ResolveLLMBillingModeDerived(ctx context.Context, workspaceID, runtime, mod
|
||||
// the no-id path historically does no DB work and the strip gate only runs
|
||||
// post-create, so keep it a pure default to preserve that contract.)
|
||||
if workspaceID == "" {
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
res.ResolvedMode = defaultClosedBillingMode()
|
||||
res.Source = BillingModeSourceDerivedDefault
|
||||
return res, nil
|
||||
}
|
||||
@@ -235,8 +259,8 @@ func ResolveLLMBillingModeDerived(ctx context.Context, workspaceID, runtime, mod
|
||||
manifest, mErr := providerRegistry()
|
||||
if mErr != nil || manifest == nil {
|
||||
// Registry unavailable (malformed embedded YAML — a build-time defect the
|
||||
// gates catch). Default closed.
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
// gates catch). Default closed (byok on self-host where no proxy exists).
|
||||
res.ResolvedMode = defaultClosedBillingMode()
|
||||
res.Source = BillingModeSourceDerivedDefault
|
||||
return res, mErr
|
||||
}
|
||||
@@ -246,8 +270,10 @@ func ResolveLLMBillingModeDerived(ctx context.Context, workspaceID, runtime, mod
|
||||
// NOT an error to the caller: an unregistered model is a legitimate
|
||||
// "we can't say it's BYOK, so bill the platform default" outcome, and the
|
||||
// only-registered gate at the create/config API is where an unregistered
|
||||
// model is rejected loudly. Here we just fail closed for safety.
|
||||
res.ResolvedMode = LLMBillingModePlatformManaged
|
||||
// model is rejected loudly. Here we just fail closed for safety. On a
|
||||
// self-hosted stack (no proxy configured) the safe default is byok, since
|
||||
// platform_managed is unreachable there.
|
||||
res.ResolvedMode = defaultClosedBillingMode()
|
||||
res.Source = BillingModeSourceDerivedDefault
|
||||
sel := model
|
||||
if sel != "" {
|
||||
|
||||
@@ -36,7 +36,18 @@ func expectOverrideQuery(m sqlmock.Sqlmock, wsID, value string) {
|
||||
WillReturnRows(rows)
|
||||
}
|
||||
|
||||
// withProxyConfigured sets the Molecule LLM proxy env (base URL + usage token)
|
||||
// for the duration of a test so PlatformManagedProxyConfigured() is true — i.e.
|
||||
// the SaaS context, where the default-closed billing mode is platform_managed.
|
||||
// Self-host (no proxy env) is covered separately by the *_SelfHost tests.
|
||||
func withProxyConfigured(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://proxy.example/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tok-test")
|
||||
}
|
||||
|
||||
func TestResolveLLMBillingModeDerived_BehaviorDelta(t *testing.T) {
|
||||
withProxyConfigured(t) // SaaS context: default-closed → platform_managed.
|
||||
ctx := context.Background()
|
||||
const wsID = "33333333-3333-3333-3333-333333333333"
|
||||
|
||||
@@ -193,6 +204,9 @@ func TestResolveLLMBillingModeDerived_BehaviorDelta(t *testing.T) {
|
||||
// error reading the override column defaults closed to platform_managed and
|
||||
// propagates the error — never silently flips a workspace off platform creds.
|
||||
func TestResolveLLMBillingModeDerived_OverrideDBError_DefaultClosed(t *testing.T) {
|
||||
// A transient DB error MUST default to platform_managed regardless of proxy
|
||||
// config (it propagates an error; it is not the no-proxy decision path).
|
||||
withProxyConfigured(t)
|
||||
ctx := context.Background()
|
||||
const wsID = "44444444-4444-4444-4444-444444444444"
|
||||
|
||||
@@ -217,6 +231,7 @@ func TestResolveLLMBillingModeDerived_OverrideDBError_DefaultClosed(t *testing.T
|
||||
// pre-provision context (no workspace id, no override read) defaults to
|
||||
// platform_managed without a DB query.
|
||||
func TestResolveLLMBillingModeDerived_EmptyWorkspaceID_PlatformDefault(t *testing.T) {
|
||||
withProxyConfigured(t) // SaaS context.
|
||||
ctx := context.Background()
|
||||
mock := setupTestDB(t) // no query expected
|
||||
res, err := ResolveLLMBillingModeDerived(ctx, "", "claude-code", "kimi-for-coding", nil)
|
||||
@@ -230,3 +245,90 @@ func TestResolveLLMBillingModeDerived_EmptyWorkspaceID_PlatformDefault(t *testin
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveLLMBillingModeDerived_SelfHost_DefaultsBYOK asserts the
|
||||
// environment-aware default: on a SELF-HOSTED stack (no Molecule LLM proxy env
|
||||
// configured) the default-closed branches resolve to byok instead of
|
||||
// platform_managed (which is unreachable there). It covers all three derive-
|
||||
// failure fallbacks: unset model, unregistered model, and the empty-workspace
|
||||
// pre-provision path. A successfully-DERIVED provider and an explicit override
|
||||
// are NOT affected by the no-proxy default (decided before the fallback).
|
||||
func TestResolveLLMBillingModeDerived_SelfHost_DefaultsBYOK(t *testing.T) {
|
||||
// Ensure no proxy env leaks in from the host.
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "")
|
||||
t.Setenv("OPENAI_BASE_URL", "")
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
ctx := context.Background()
|
||||
const wsID = "55555555-5555-5555-5555-555555555555"
|
||||
|
||||
t.Run("unset_model_defaults_byok_on_selfhost", func(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
expectOverrideQuery(mock, wsID, "") // NULL override
|
||||
res, err := ResolveLLMBillingModeDerived(ctx, wsID, "claude-code", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Errorf("self-host unset model: got %q want byok", res.ResolvedMode)
|
||||
}
|
||||
if res.Source != BillingModeSourceDerivedDefault {
|
||||
t.Errorf("source: got %q want %q", res.Source, BillingModeSourceDerivedDefault)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unregistered_model_defaults_byok_on_selfhost", func(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
expectOverrideQuery(mock, wsID, "")
|
||||
res, err := ResolveLLMBillingModeDerived(ctx, wsID, "claude-code", "totally-made-up-model-xyz", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Errorf("self-host unregistered model: got %q want byok", res.ResolvedMode)
|
||||
}
|
||||
if res.Source != BillingModeSourceDerivedDefault {
|
||||
t.Errorf("source: got %q want %q", res.Source, BillingModeSourceDerivedDefault)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty_workspace_id_defaults_byok_on_selfhost", func(t *testing.T) {
|
||||
mock := setupTestDB(t) // no query expected (pre-provision path)
|
||||
res, err := ResolveLLMBillingModeDerived(ctx, "", "claude-code", "kimi-for-coding", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModeBYOK {
|
||||
t.Errorf("self-host empty workspace id: got %q want byok", res.ResolvedMode)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit_platform_override_still_wins_on_selfhost", func(t *testing.T) {
|
||||
// An operator override is honored even on self-host (escape hatch); the
|
||||
// no-proxy default only governs the derive-failure fallback.
|
||||
mock := setupTestDB(t)
|
||||
expectOverrideQuery(mock, wsID, LLMBillingModePlatformManaged)
|
||||
res, err := ResolveLLMBillingModeDerived(ctx, wsID, "claude-code", "", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if res.ResolvedMode != LLMBillingModePlatformManaged {
|
||||
t.Errorf("explicit override must win: got %q want platform_managed", res.ResolvedMode)
|
||||
}
|
||||
if res.Source != BillingModeSourceWorkspaceOverride {
|
||||
t.Errorf("source: got %q want %q", res.Source, BillingModeSourceWorkspaceOverride)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +145,7 @@ func TestPutWorkspaceLLMBillingMode_SetByok(t *testing.T) {
|
||||
|
||||
func TestPutWorkspaceLLMBillingMode_ExplicitNullClearsOverride(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BILLING_MODE", LLMBillingModePlatformManaged)
|
||||
withProxyConfigured(t) // SaaS context: cleared override → derived_default → platform_managed.
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectExec(`UPDATE workspaces SET llm_billing_mode = NULL WHERE id = \$1`).
|
||||
WithArgs(testWSID).
|
||||
|
||||
@@ -173,6 +173,7 @@ func TestApplyPlatformManagedLLMEnv_ReadProvisionParity(t *testing.T) {
|
||||
// This mirrors the agents-team genuinely-platform case. Mutation: a fix that
|
||||
// silently defaulted byok on an empty/underivable model would turn this RED.
|
||||
func TestApplyPlatformManagedLLMEnv_DefaultPreservation(t *testing.T) {
|
||||
withProxyConfigured(t) // SaaS context: no-model default stays platform_managed.
|
||||
ctx := context.Background()
|
||||
const wsID = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ func expectLegacyShimQueries(m sqlmock.Sqlmock, wsID, runtime, model string) {
|
||||
}
|
||||
|
||||
func TestResolveLLMBillingMode_LegacyShimDerives(t *testing.T) {
|
||||
withProxyConfigured(t) // SaaS context: default-closed → platform_managed.
|
||||
ctx := context.Background()
|
||||
const wsID = "11111111-1111-1111-1111-111111111111"
|
||||
|
||||
@@ -163,6 +164,7 @@ func TestResolveLLMBillingMode_LegacyShimDerives(t *testing.T) {
|
||||
// (no workspace id) defaults closed with no DB read (org rung retired, so the
|
||||
// old "org_only" behavior is gone — it's now the platform default).
|
||||
func TestResolveLLMBillingMode_EmptyWorkspaceID_PlatformDefault(t *testing.T) {
|
||||
withProxyConfigured(t) // SaaS context.
|
||||
ctx := context.Background()
|
||||
mock := setupTestDB(t) // no DB read expected
|
||||
res, err := ResolveLLMBillingMode(ctx, "", LLMBillingModeBYOK)
|
||||
@@ -182,6 +184,7 @@ func TestResolveLLMBillingMode_EmptyWorkspaceID_PlatformDefault(t *testing.T) {
|
||||
// values. The strip gate downstream relies on this so it can switch on
|
||||
// res.ResolvedMode without a separate is-valid check on every call site.
|
||||
func TestResolveLLMBillingMode_ResolvedModeIsAlwaysValid(t *testing.T) {
|
||||
withProxyConfigured(t) // SaaS context: default-closed → platform_managed.
|
||||
ctx := context.Background()
|
||||
const wsID = "22222222-2222-2222-2222-222222222222"
|
||||
|
||||
|
||||
@@ -99,6 +99,15 @@ func NewMCPHandler(database *sql.DB, broadcaster *events.Broadcaster) *MCPHandle
|
||||
return &MCPHandler{database: database, broadcaster: broadcaster}
|
||||
}
|
||||
|
||||
// userTaskStore builds the SSOT user-task store over the handler's DB pool +
|
||||
// broadcaster — the same store the REST user_tasks handlers route through, so
|
||||
// the MCP bridge and HTTP share one persistence + validation + broadcast path
|
||||
// (see user_task_store.go). Mirrors how toolSendMessageToUser constructs an
|
||||
// AgentMessageWriter.
|
||||
func (h *MCPHandler) userTaskStore() *UserTaskStore {
|
||||
return NewUserTaskStore(h.database, h.broadcaster)
|
||||
}
|
||||
|
||||
func (h *MCPHandler) proxyA2ARequest(ctx context.Context, workspaceID string, body []byte, callerID string, logActivity bool) (int, []byte, error) {
|
||||
if h.a2aProxy != nil {
|
||||
return h.a2aProxy(ctx, workspaceID, body, callerID, logActivity)
|
||||
@@ -274,6 +283,57 @@ var mcpAllTools = []mcpTool{
|
||||
"required": []string{"message"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "request_user_action",
|
||||
Description: "Ask the human user to do something only they can do (e.g. review a draft, provide an API key, confirm a decision). Creates a tracked task in the user's concierge Tasks list. Unlike send_message_to_user (a passing chat message), this is an ask the user explicitly marks done or dismissed.",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"title": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "The ask, one line (e.g. 'Review the launch draft')",
|
||||
},
|
||||
"detail": map[string]interface{}{
|
||||
"type": "string",
|
||||
"description": "Optional longer context for the ask",
|
||||
},
|
||||
},
|
||||
"required": []string{"title"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list_user_tasks",
|
||||
Description: "List the action-requests (user tasks) THIS workspace has raised for the user, with their status (pending/done/dismissed). Use to check whether the user has handled your asks.",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "update_user_task",
|
||||
Description: "Update one of your own user tasks — change its title, detail, or status. Only tasks this workspace raised can be updated.",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"user_task_id": map[string]interface{}{"type": "string", "description": "The task id (from request_user_action / list_user_tasks)"},
|
||||
"title": map[string]interface{}{"type": "string", "description": "New title (optional)"},
|
||||
"detail": map[string]interface{}{"type": "string", "description": "New detail (optional)"},
|
||||
"status": map[string]interface{}{"type": "string", "enum": []string{"pending", "done", "dismissed"}, "description": "New status (optional)"},
|
||||
},
|
||||
"required": []string{"user_task_id"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "delete_user_task",
|
||||
Description: "Delete one of your own user tasks (e.g. it is no longer relevant). Only tasks this workspace raised can be deleted.",
|
||||
InputSchema: map[string]interface{}{
|
||||
"type": "object",
|
||||
"properties": map[string]interface{}{
|
||||
"user_task_id": map[string]interface{}{"type": "string", "description": "The task id to delete"},
|
||||
},
|
||||
"required": []string{"user_task_id"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "commit_memory",
|
||||
Description: "Save important information to persistent memory. Scope LOCAL (this workspace only) and TEAM (parent + siblings) are supported. GLOBAL scope is not available via the MCP bridge.",
|
||||
@@ -554,6 +614,14 @@ func (h *MCPHandler) dispatch(ctx context.Context, workspaceID, toolName string,
|
||||
return h.toolCheckTaskStatus(ctx, workspaceID, args)
|
||||
case "send_message_to_user":
|
||||
return h.toolSendMessageToUser(ctx, workspaceID, args)
|
||||
case "request_user_action":
|
||||
return h.toolRequestUserAction(ctx, workspaceID, args)
|
||||
case "list_user_tasks":
|
||||
return h.toolListUserTasks(ctx, workspaceID)
|
||||
case "update_user_task":
|
||||
return h.toolUpdateUserTask(ctx, workspaceID, args)
|
||||
case "delete_user_task":
|
||||
return h.toolDeleteUserTask(ctx, workspaceID, args)
|
||||
case "commit_memory":
|
||||
return h.toolCommitMemory(ctx, workspaceID, args)
|
||||
case "recall_memory":
|
||||
|
||||
@@ -428,6 +428,111 @@ func (h *MCPHandler) toolSendMessageToUser(ctx context.Context, workspaceID stri
|
||||
return "Message sent.", nil
|
||||
}
|
||||
|
||||
// toolRequestUserAction implements request_user_action — the agent raises a
|
||||
// tracked ask for the human user (it appears in the concierge Tasks list).
|
||||
// Mirrors the user_tasks REST Create handler. Unlike send_message_to_user it
|
||||
// is not gated behind MOLECULE_MCP_ALLOW_SEND_MESSAGE — raising an ask is
|
||||
// always allowed.
|
||||
func (h *MCPHandler) toolRequestUserAction(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
|
||||
title, _ := args["title"].(string)
|
||||
if title == "" {
|
||||
return "", fmt.Errorf("title is required")
|
||||
}
|
||||
detail, _ := args["detail"].(string)
|
||||
|
||||
// SSOT for user-task persistence + validation + broadcast — see
|
||||
// user_task_store.go. Pre-consolidation this hand-wrote the same INSERT
|
||||
// and USER_TASK_REQUESTED broadcast the REST Create handler did.
|
||||
if _, err := h.userTaskStore().Create(ctx, workspaceID, title, detail); err != nil {
|
||||
return "", fmt.Errorf("failed to create user task: %w", err)
|
||||
}
|
||||
|
||||
return "Asked the user: " + title, nil
|
||||
}
|
||||
|
||||
// toolListUserTasks implements list_user_tasks — the asks THIS workspace
|
||||
// raised, with status. Returns a JSON array string.
|
||||
func (h *MCPHandler) toolListUserTasks(ctx context.Context, workspaceID string) (string, error) {
|
||||
rows, err := h.userTaskStore().List(ctx, workspaceID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to list user tasks: %w", err)
|
||||
}
|
||||
|
||||
// The MCP surface returns a slimmer shape than the REST list (no
|
||||
// resolved_at / resolved_by). Project the store rows down so the
|
||||
// existing tool output stays stable.
|
||||
type ut struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Detail *string `json:"detail"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
tasks := make([]ut, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
tasks = append(tasks, ut{
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
Detail: r.Detail,
|
||||
Status: r.Status,
|
||||
CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
out, err := json.Marshal(tasks)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to encode user tasks: %w", err)
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// toolUpdateUserTask implements update_user_task — edit a task this workspace
|
||||
// raised (title / detail / status). Scoped by workspace_id.
|
||||
func (h *MCPHandler) toolUpdateUserTask(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
|
||||
taskID, _ := args["user_task_id"].(string)
|
||||
if taskID == "" {
|
||||
return "", fmt.Errorf("user_task_id is required")
|
||||
}
|
||||
var title, detail, status *string
|
||||
if v, ok := args["title"].(string); ok && v != "" {
|
||||
title = &v
|
||||
}
|
||||
if v, ok := args["detail"].(string); ok && v != "" {
|
||||
detail = &v
|
||||
}
|
||||
if v, ok := args["status"].(string); ok && v != "" {
|
||||
status = &v
|
||||
}
|
||||
|
||||
// SSOT for the COALESCE update + status-enum validation — see
|
||||
// user_task_store.go.
|
||||
if err := h.userTaskStore().Update(ctx, workspaceID, taskID, title, detail, status); err != nil {
|
||||
if errors.Is(err, ErrInvalidUserTaskStatus) {
|
||||
return "", fmt.Errorf("status must be 'pending', 'done' or 'dismissed'")
|
||||
}
|
||||
if errors.Is(err, ErrUserTaskNotFound) {
|
||||
return "", fmt.Errorf("user task not found")
|
||||
}
|
||||
return "", fmt.Errorf("failed to update user task: %w", err)
|
||||
}
|
||||
return "User task updated.", nil
|
||||
}
|
||||
|
||||
// toolDeleteUserTask implements delete_user_task — remove a task this
|
||||
// workspace raised. Scoped by workspace_id.
|
||||
func (h *MCPHandler) toolDeleteUserTask(ctx context.Context, workspaceID string, args map[string]interface{}) (string, error) {
|
||||
taskID, _ := args["user_task_id"].(string)
|
||||
if taskID == "" {
|
||||
return "", fmt.Errorf("user_task_id is required")
|
||||
}
|
||||
if err := h.userTaskStore().Delete(ctx, workspaceID, taskID); err != nil {
|
||||
if errors.Is(err, ErrUserTaskNotFound) {
|
||||
return "", fmt.Errorf("user task not found")
|
||||
}
|
||||
return "", fmt.Errorf("failed to delete user task: %w", err)
|
||||
}
|
||||
return "User task deleted.", nil
|
||||
}
|
||||
|
||||
func parseAgentMessageAttachments(raw interface{}) ([]AgentMessageAttachment, error) {
|
||||
if raw == nil {
|
||||
return nil, nil
|
||||
|
||||
@@ -94,8 +94,8 @@ func resolveProvisionConcurrency() int {
|
||||
// overlapping mess under the nested render (see screenshot in PR
|
||||
// #1981 thread).
|
||||
const (
|
||||
childDefaultWidth = 240.0
|
||||
childDefaultHeight = 130.0
|
||||
childDefaultWidth = 300.0
|
||||
childDefaultHeight = 176.0
|
||||
childGutter = 14.0
|
||||
parentHeaderPadding = 130.0
|
||||
parentSidePadding = 16.0
|
||||
|
||||
@@ -27,12 +27,395 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/models"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// conciergeSystemPrompt is the identity seeded into the platform agent's
|
||||
// /configs/system-prompt.md. It makes the concierge BE the Org Concierge —
|
||||
// the org root (kind='platform'), the user's universal A2A peer and default
|
||||
// chat target — instead of booting as a generic claude-code coding assistant.
|
||||
//
|
||||
// Grounded in the RFC (docs/design/rfc-platform-agent.md §1-2): it IS the org,
|
||||
// orchestrates the org via the platform MCP (the 87-tool org-admin surface) +
|
||||
// a2a delegation, and routes destructive ops through human approval. The prompt
|
||||
// is identity-only and works LOCALLY regardless of whether the platform MCP
|
||||
// binary is present — the org-admin tools simply aren't available until the
|
||||
// agent runs on the dedicated platform-agent image.
|
||||
//
|
||||
// %s is the concierge's display name (defaultPlatformAgentName()).
|
||||
const conciergeSystemPromptTmpl = `# You are %s — the Org Concierge
|
||||
|
||||
You are the organization's **platform agent**: the single org-root agent
|
||||
(kind=platform) that sits above every workspace. You are the user's one front
|
||||
door to the whole organization — their universal peer and default chat target.
|
||||
You are NOT a generic coding assistant; you are an **org orchestrator**.
|
||||
|
||||
## What you are
|
||||
|
||||
- **You are the org.** Every team and workspace in this organization lives under
|
||||
you in the agent hierarchy. When the user talks to the org, they talk to you.
|
||||
- **You orchestrate; you don't do the work yourself.** Break a request down and
|
||||
delegate it to the right workspace(s). Spin up new workspaces/agents when the
|
||||
org doesn't yet have the right team.
|
||||
- **You manage the org through tools, not guesswork.** You hold the
|
||||
platform-management MCP (the org-admin surface: list/create/delete workspaces,
|
||||
assign agents, set secrets, manage channels/schedules, delegate, chat with any
|
||||
agent). Always inspect real state with these tools before acting — never assume
|
||||
the org's shape from memory.
|
||||
|
||||
## How you work
|
||||
|
||||
1. **Recall first.** At the start of a conversation, recall prior context so you
|
||||
continue org work coherently across restarts.
|
||||
2. **Understand the ask, then act.** For "spin up an SEO team that publishes
|
||||
weekly", that means: create the workspaces, assign the agents, wire the
|
||||
schedule — using the platform MCP — not a paragraph of instructions for the
|
||||
user to run by hand.
|
||||
3. **Delegate via A2A.** Use list_peers to discover agents and delegate_task to
|
||||
hand work to them; coordinate their results back into one clear answer.
|
||||
4. **Report back clearly.** Synthesize what the org did into a concise summary
|
||||
for the user; use send_message_to_user for progress on long-running work.
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Destructive operations are human-approved.** Deleting a workspace,
|
||||
deprovisioning, writing secrets, or minting org tokens go through the approvals
|
||||
subsystem — the platform returns a pending approval and the user decides. Never
|
||||
try to route around the gate.
|
||||
- **Stay inside this org.** You can reach every workspace in your organization
|
||||
and only this organization; tenant isolation is enforced server-side.
|
||||
- **Be honest about capability.** If the org-admin tools aren't available in this
|
||||
environment (e.g. a local/dev image without the platform MCP), say so plainly
|
||||
and fall back to A2A delegation + advising the user — do not fabricate results.
|
||||
|
||||
You have full org-management authority. Use it deliberately, on the user's
|
||||
behalf, and keep them in the loop.
|
||||
`
|
||||
|
||||
// conciergeMCPServersBlock is the YAML appended to the concierge's config.yaml
|
||||
// so the runtime loads the org-admin platform MCP alongside the always-on a2a
|
||||
// server. The Phase-2 extra-MCP merge (claude_sdk_executor.py
|
||||
// _apply_extra_mcp_servers) reads this `mcp_servers:` list. The platform MCP
|
||||
// authenticates purely from the container env (MOLECULE_API_KEY /
|
||||
// MOLECULE_API_URL / MOLECULE_ORG_ID — wired by conciergePlatformMCPEnv), so no
|
||||
// per-server env block is needed here.
|
||||
//
|
||||
// SELF-HOST CAVEAT: the local stack provisions the concierge on the ordinary
|
||||
// `claude-code` image, which does NOT ship /opt/molecule-mcp-server. The
|
||||
// dedicated `platform-agent` image (Dockerfile.platform-agent) does. The
|
||||
// executor's _apply_extra_mcp_servers skips an entry whose command/script is
|
||||
// absent, so declaring this block can never crash the agent or wedge the SDK
|
||||
// init locally — the identity (system prompt) works everywhere; the org-admin
|
||||
// MCP tools only light up on the platform-agent image.
|
||||
const conciergeMCPServersBlock = `mcp_servers:
|
||||
- name: platform
|
||||
command: node
|
||||
args:
|
||||
- /opt/molecule-mcp-server/dist/index.js
|
||||
`
|
||||
|
||||
// SelfHostedPlatformAgentID is the deterministic platform-agent id used when no
|
||||
// control plane is present to derive a per-org id (self-hosted / local). There
|
||||
// is one platform agent per self-hosted tenant, so a fixed namespaced uuidv5 is
|
||||
// sufficient and stable across restarts.
|
||||
var SelfHostedPlatformAgentID = uuid.NewSHA1(uuid.NameSpaceURL, []byte("molecule:self-hosted:platform-agent")).String()
|
||||
|
||||
// defaultPlatformAgentName returns the display name for the org's platform
|
||||
// agent (the concierge). When the tenant server is told its org's name via the
|
||||
// MOLECULE_ORG_NAME env (the self-hosted docker-compose sets it; SaaS passes an
|
||||
// explicit name in the CP install payload instead), the concierge is named
|
||||
// "<org name> Agent" — e.g. org "Molecule AI" → "Molecule AI Agent". With no
|
||||
// org name configured it falls back to the legacy "Org Concierge".
|
||||
func defaultPlatformAgentName() string {
|
||||
if orgName := os.Getenv("MOLECULE_ORG_NAME"); orgName != "" {
|
||||
return fmt.Sprintf("%s Agent", orgName)
|
||||
}
|
||||
return "Org Concierge"
|
||||
}
|
||||
|
||||
// conciergeIdentityFiles returns the overlay config files that turn an ordinary
|
||||
// claude-code workspace into the Org Concierge: the system-prompt.md identity
|
||||
// and a config.yaml that declares the platform MCP. These are written on top of
|
||||
// the workspace template at provision time (provisioner writes ConfigFiles AFTER
|
||||
// CopyTemplateToContainer), so they survive restarts — every provision re-seeds
|
||||
// the identity from the single source here.
|
||||
//
|
||||
// baseConfigYAML is the config.yaml the concierge would otherwise boot with
|
||||
// (the template's, the freshly-generated one, or — on auto-restart — the live
|
||||
// container's). We append the mcp_servers block only when it is not already
|
||||
// present, so re-applying is idempotent and never duplicates the block. When
|
||||
// baseConfigYAML is empty (we couldn't read a base) we overlay only the system
|
||||
// prompt and leave config.yaml to the template — the identity still lands; the
|
||||
// MCP simply isn't declared that cycle (the next provision with a readable base
|
||||
// adds it).
|
||||
func conciergeIdentityFiles(name string, baseConfigYAML []byte) map[string][]byte {
|
||||
files := map[string][]byte{
|
||||
"system-prompt.md": []byte(fmt.Sprintf(conciergeSystemPromptTmpl, name)),
|
||||
}
|
||||
if len(baseConfigYAML) > 0 && !strings.Contains(string(baseConfigYAML), "\nmcp_servers:") &&
|
||||
!strings.HasPrefix(string(baseConfigYAML), "mcp_servers:") {
|
||||
files["config.yaml"] = appendYAMLBlock(baseConfigYAML, conciergeMCPServersBlock)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// conciergePlatformMCPEnv injects the env the platform MCP child reads at spawn
|
||||
// (RFC §5.5/§5.6). The org-admin token is ADMIN_TOKEN on self-host; the platform
|
||||
// URL is the in-cluster PLATFORM_URL (e.g. http://platform:8080). Existing
|
||||
// values in env win, so an operator/CP override is never clobbered. No-op for a
|
||||
// non-platform workspace. Best-effort: when ADMIN_TOKEN is unset (pure-local dev
|
||||
// with AdminAuth fail-open) the key is simply absent and the MCP — which only
|
||||
// runs on the platform-agent image anyway — is unauthenticated locally.
|
||||
func conciergePlatformMCPEnv(env map[string]string) {
|
||||
setIfAbsent := func(k, v string) {
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
if _, ok := env[k]; !ok {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
setIfAbsent("MOLECULE_API_KEY", os.Getenv("ADMIN_TOKEN"))
|
||||
// MOLECULE_API_URL: prefer an explicit env, else the in-cluster platform URL.
|
||||
apiURL := os.Getenv("MOLECULE_API_URL")
|
||||
if apiURL == "" {
|
||||
apiURL = os.Getenv("PLATFORM_URL")
|
||||
}
|
||||
setIfAbsent("MOLECULE_API_URL", apiURL)
|
||||
setIfAbsent("MOLECULE_ORG_ID", os.Getenv("MOLECULE_ORG_ID"))
|
||||
}
|
||||
|
||||
// applyConciergeProvisionConfig is the provision-time hook that makes the
|
||||
// platform agent boot as the concierge. Called from prepareProvisionContext for
|
||||
// EVERY provision of a kind='platform' workspace (create, restart, auto-recover)
|
||||
// so the identity + platform-MCP declaration are re-seeded each cycle and never
|
||||
// drift. It is a no-op for ordinary workspaces.
|
||||
//
|
||||
// It (1) injects the platform-MCP env into envVars and (2) merges the concierge
|
||||
// overlay files (system-prompt.md + a config.yaml carrying mcp_servers) into the
|
||||
// returned configFiles map, which the provisioner writes on top of the template.
|
||||
//
|
||||
// Returns the (possibly newly-allocated) configFiles map so the caller can
|
||||
// rebind it — configFiles is nil on the auto-restart path, where this is the
|
||||
// thing that introduces the overlay.
|
||||
func (h *WorkspaceHandler) applyConciergeProvisionConfig(
|
||||
ctx context.Context,
|
||||
workspaceID, templatePath string,
|
||||
configFiles map[string][]byte,
|
||||
envVars map[string]string,
|
||||
name string,
|
||||
) map[string][]byte {
|
||||
var kind string
|
||||
if err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT COALESCE(kind, 'workspace') FROM workspaces WHERE id = $1`, workspaceID).Scan(&kind); err != nil {
|
||||
// Non-fatal: a missing row / probe error just means "treat as ordinary".
|
||||
return configFiles
|
||||
}
|
||||
if kind != models.KindPlatform {
|
||||
return configFiles
|
||||
}
|
||||
|
||||
// 1. Platform-MCP env (org-admin token + platform URL + org id).
|
||||
conciergePlatformMCPEnv(envVars)
|
||||
|
||||
// 2. Resolve the base config.yaml to append mcp_servers onto, in priority
|
||||
// order: the in-memory configFiles (fresh provision), the template dir
|
||||
// (apply-template provision), then the live container (auto-restart,
|
||||
// configFiles == nil + templatePath == ""). Any miss falls through.
|
||||
var base []byte
|
||||
if configFiles != nil {
|
||||
base = configFiles["config.yaml"]
|
||||
}
|
||||
if len(base) == 0 && templatePath != "" {
|
||||
if b, err := os.ReadFile(filepath.Join(templatePath, "config.yaml")); err == nil {
|
||||
base = b
|
||||
}
|
||||
}
|
||||
if len(base) == 0 && h.provisioner != nil {
|
||||
if b, err := h.provisioner.ExecRead(ctx, configDirName(workspaceID), "/configs/config.yaml"); err == nil {
|
||||
base = b
|
||||
}
|
||||
}
|
||||
|
||||
overlay := conciergeIdentityFiles(name, base)
|
||||
if configFiles == nil {
|
||||
configFiles = map[string][]byte{}
|
||||
}
|
||||
for k, v := range overlay {
|
||||
configFiles[k] = v
|
||||
}
|
||||
log.Printf("Provisioner: applied concierge identity overlay for platform agent %s (system-prompt + %d config file(s))", workspaceID, len(overlay))
|
||||
return configFiles
|
||||
}
|
||||
|
||||
// EnsureSelfHostedPlatformAgent installs the org's platform agent (the concierge,
|
||||
// the org root) on a tenant that has no control plane to do it — i.e. self-hosted
|
||||
// or local. In SaaS the CP calls InstallPlatformAgent at org-provision time; this
|
||||
// is the no-CP equivalent. Idempotent: returns early if a kind='platform' root
|
||||
// already exists (a prior boot, or a CP install in a hybrid setup). The CALLER
|
||||
// gates this on the MOLECULE_SEED_PLATFORM_AGENT flag (set by the self-hosted
|
||||
// docker-compose) so CI harnesses and SaaS tenants are unaffected.
|
||||
func EnsureSelfHostedPlatformAgent(ctx context.Context, database *sql.DB) error {
|
||||
var existing string
|
||||
err := database.QueryRowContext(ctx,
|
||||
`SELECT id FROM workspaces WHERE kind = 'platform' AND parent_id IS NULL LIMIT 1`).Scan(&existing)
|
||||
if err == nil {
|
||||
return nil // platform agent already present — nothing to do
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("check existing platform agent: %w", err)
|
||||
}
|
||||
log.Printf("boot: no platform agent present — self-seeding %s (self-hosted)", SelfHostedPlatformAgentID)
|
||||
return installPlatformAgent(ctx, database, SelfHostedPlatformAgentID, defaultPlatformAgentName())
|
||||
}
|
||||
|
||||
// OrgIdentityResponse is the body of GET /org/identity.
|
||||
type OrgIdentityResponse struct {
|
||||
// Name is the org's display name (MOLECULE_ORG_NAME, "" when unset).
|
||||
Name string `json:"name"`
|
||||
// Slug is the org's URL slug (MOLECULE_ORG_SLUG, "" when unset). Empty on
|
||||
// a self-hosted stack where no control plane assigns a slug.
|
||||
Slug string `json:"slug"`
|
||||
// OrgID is the org's UUID (MOLECULE_ORG_ID, "" when unset). Empty on a
|
||||
// self-hosted stack where no control plane assigns an org id.
|
||||
OrgID string `json:"org_id"`
|
||||
// PlatformManagedAvailable reports whether a Molecule LLM proxy is wired
|
||||
// into this workspace-server process — i.e. whether platform_managed billing
|
||||
// can actually work. True on SaaS (the CP provisioner exports the proxy base
|
||||
// URL + usage token), false on a self-hosted stack (no hosted proxy / no
|
||||
// credit ledger). The canvas reads this pre-login to decide whether to offer
|
||||
// the "Platform (proxy)" billing option or hide it and default to BYOK.
|
||||
PlatformManagedAvailable bool `json:"platform_managed_available"`
|
||||
}
|
||||
|
||||
// OrgIdentity handles GET /org/identity (open / CORS-friendly, no auth).
|
||||
//
|
||||
// Returns the org's display name from the MOLECULE_ORG_NAME env (empty string
|
||||
// when unset), its slug (MOLECULE_ORG_SLUG) and id (MOLECULE_ORG_ID) — both ""
|
||||
// on self-host where no control plane assigns them — plus a
|
||||
// platform_managed_available capability flag. The canvas topbar reads `name` to
|
||||
// render "<org name>" without an admin token; the Settings → Organization tab
|
||||
// reads name+slug+org_id to render the org-identity card on self-host (where the
|
||||
// control-plane /cp/orgs endpoint does not exist); and the Settings billing card
|
||||
// reads `platform_managed_available` to decide whether to offer platform-managed
|
||||
// (proxy) billing — exactly like /health and /buildinfo, it exposes only
|
||||
// non-sensitive identity/capability signals.
|
||||
//
|
||||
// platform_managed_available is true iff a Molecule LLM proxy is configured in
|
||||
// this process env (PlatformManagedProxyConfigured — the same base-URL + usage-
|
||||
// token precondition the strip gate enforces). On self-host both are unset, so
|
||||
// it is false and the canvas hides the "Platform (proxy)" option + defaults BYOK.
|
||||
//
|
||||
// @Summary Get the org's display name + billing capability
|
||||
// @Tags org
|
||||
// @Produce json
|
||||
// @Success 200 {object} OrgIdentityResponse
|
||||
// @Router /org/identity [get]
|
||||
func OrgIdentity(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"name": os.Getenv("MOLECULE_ORG_NAME"),
|
||||
"slug": os.Getenv("MOLECULE_ORG_SLUG"),
|
||||
"org_id": os.Getenv("MOLECULE_ORG_ID"),
|
||||
"platform_managed_available": PlatformManagedProxyConfigured(),
|
||||
})
|
||||
}
|
||||
|
||||
// MaybeProvisionPlatformAgentOnBoot best-effort provisions a container for the
|
||||
// self-hosted org's platform agent (the concierge) at boot. The boot-seed
|
||||
// (EnsureSelfHostedPlatformAgent) only creates the DB row; on a fresh self-host
|
||||
// that leaves the concierge with no container. This brings it online
|
||||
// automatically once creds exist.
|
||||
//
|
||||
// STRICTLY self-host + best-effort:
|
||||
// - The CALLER gates this on MOLECULE_SEED_PLATFORM_AGENT set AND the local
|
||||
// Docker provisioner being active (prov != nil, i.e. MOLECULE_ORG_ID unset).
|
||||
// SaaS (cpProv) never reaches here.
|
||||
// - It looks up the kind='platform' root; if absent (seed disabled / failed)
|
||||
// it no-ops. If the container is already running (prov.IsRunning) it no-ops.
|
||||
// - Otherwise it kicks off ONE provision via the same path the restart
|
||||
// endpoint uses (WorkspaceHandler.RestartByID), which reads the row's
|
||||
// runtime ('claude-code' as seeded) + config and provisions accordingly.
|
||||
//
|
||||
// On a fresh self-host with no LLM credentials the provision will fail (missing
|
||||
// key) and the agent stays 'failed' until the user configures BYOK via
|
||||
// Settings — that's expected. This never fatals and never loops: RestartByID is
|
||||
// itself debounced/coalesced, and this runs exactly once at boot. Run it in a
|
||||
// goroutine so a slow Docker pull doesn't delay the HTTP server coming up.
|
||||
func MaybeProvisionPlatformAgentOnBoot(ctx context.Context, database *sql.DB, prov localProvisionerIsRunning, restartByID func(string)) {
|
||||
if prov == nil || restartByID == nil {
|
||||
return
|
||||
}
|
||||
var id, status string
|
||||
err := database.QueryRowContext(ctx,
|
||||
`SELECT id, status FROM workspaces WHERE kind = 'platform' AND parent_id IS NULL LIMIT 1`).Scan(&id, &status)
|
||||
if err == sql.ErrNoRows {
|
||||
log.Printf("boot: platform-agent provision skipped — no platform agent row present")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("boot: platform-agent provision lookup failed (non-fatal): %v", err)
|
||||
return
|
||||
}
|
||||
// Already online AND a live container? Then it's running — but it may be a
|
||||
// concierge that pre-dates the identity overlay (booted as a vanilla
|
||||
// claude-code agent with no system-prompt.md). Probe for the concierge
|
||||
// identity; if it's missing, restart ONCE so the provision path re-seeds the
|
||||
// overlay. This is what makes the seed idempotent + self-applying on the
|
||||
// EXISTING concierge (the deterministic self-hosted id), not just new
|
||||
// installs. IsRunning is the authoritative liveness check; status is the
|
||||
// cheap one.
|
||||
running, _ := prov.IsRunning(ctx, id)
|
||||
if running {
|
||||
if conciergeIdentityPresent(ctx, prov, id) {
|
||||
log.Printf("boot: platform-agent %s already running with concierge identity — skipping", id)
|
||||
return
|
||||
}
|
||||
log.Printf("boot: platform-agent %s running but MISSING concierge identity — restarting once to apply the system prompt + platform MCP", id)
|
||||
go restartByID(id)
|
||||
return
|
||||
}
|
||||
log.Printf("boot: platform-agent %s not running (status=%s) — kicking off best-effort provision", id, status)
|
||||
go restartByID(id)
|
||||
}
|
||||
|
||||
// conciergeIdentityPresent reports whether the running concierge container
|
||||
// already carries the seeded identity (a non-empty /configs/system-prompt.md).
|
||||
// Used to decide whether a running-but-vanilla concierge needs a one-shot
|
||||
// restart to pick up the overlay. Best-effort: on a probe error or an empty
|
||||
// file it returns false (so the safe action — re-seed via restart — is taken).
|
||||
func conciergeIdentityPresent(ctx context.Context, prov localProvisionerIsRunning, id string) bool {
|
||||
reader, ok := prov.(interface {
|
||||
ExecRead(ctx context.Context, containerName, filePath string) ([]byte, error)
|
||||
})
|
||||
if !ok {
|
||||
// Can't probe — assume present to avoid a restart loop on a backend
|
||||
// that doesn't expose ExecRead.
|
||||
return true
|
||||
}
|
||||
body, err := reader.ExecRead(ctx, configDirName(id), "/configs/system-prompt.md")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(string(body), "Org Concierge")
|
||||
}
|
||||
|
||||
// localProvisionerIsRunning is the minimal slice of the local Docker
|
||||
// provisioner that MaybeProvisionPlatformAgentOnBoot needs — the
|
||||
// "is this workspace's container live?" probe. The boot helper additionally
|
||||
// type-asserts for an optional ExecRead (conciergeIdentityPresent) to detect a
|
||||
// running-but-vanilla concierge; keeping ExecRead off this interface keeps the
|
||||
// unit-test fake minimal while still letting the real *Provisioner satisfy it.
|
||||
type localProvisionerIsRunning interface {
|
||||
IsRunning(ctx context.Context, workspaceID string) (bool, error)
|
||||
}
|
||||
|
||||
type installPlatformAgentPayload struct {
|
||||
// ID is the platform agent's workspace id (a deterministic uuidv5 the
|
||||
// control plane derives per org). Required.
|
||||
@@ -54,7 +437,7 @@ func InstallPlatformAgent(c *gin.Context) {
|
||||
}
|
||||
name := p.Name
|
||||
if name == "" {
|
||||
name = "Org Concierge"
|
||||
name = defaultPlatformAgentName()
|
||||
}
|
||||
if err := installPlatformAgent(c.Request.Context(), db.DB, p.ID, name); err != nil {
|
||||
log.Printf("InstallPlatformAgent: %v (id=%s)", err, p.ID)
|
||||
|
||||
@@ -2,10 +2,17 @@ package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -25,3 +32,464 @@ func TestInstallPlatformAgent_BadJSON(t *testing.T) {
|
||||
t.Errorf("missing id: expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultPlatformAgentName covers the dynamic "<org name> Agent" name and
|
||||
// the legacy fallback. MOLECULE_ORG_NAME set → "<org> Agent"; unset → the
|
||||
// "Org Concierge" default used by both the self-host seed and the CP install
|
||||
// when no explicit name is passed.
|
||||
func TestDefaultPlatformAgentName(t *testing.T) {
|
||||
t.Run("org name set", func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_NAME", "Molecule AI")
|
||||
if got := defaultPlatformAgentName(); got != "Molecule AI Agent" {
|
||||
t.Errorf("defaultPlatformAgentName() = %q, want %q", got, "Molecule AI Agent")
|
||||
}
|
||||
})
|
||||
t.Run("org name empty → legacy fallback", func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_NAME", "")
|
||||
if got := defaultPlatformAgentName(); got != "Org Concierge" {
|
||||
t.Errorf("defaultPlatformAgentName() = %q, want %q", got, "Org Concierge")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestOrgIdentity asserts the open /org/identity contract: {"name": <env>}.
|
||||
func TestOrgIdentity(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("returns configured org name, slug and id (SaaS)", func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_NAME", "Molecule AI")
|
||||
t.Setenv("MOLECULE_ORG_SLUG", "molecule-ai")
|
||||
t.Setenv("MOLECULE_ORG_ID", "11111111-2222-3333-4444-555555555555")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/org/identity", nil)
|
||||
|
||||
OrgIdentity(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
OrgID string `json:"org_id"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if body.Name != "Molecule AI" {
|
||||
t.Errorf("name = %q, want %q", body.Name, "Molecule AI")
|
||||
}
|
||||
if body.Slug != "molecule-ai" {
|
||||
t.Errorf("slug = %q, want %q", body.Slug, "molecule-ai")
|
||||
}
|
||||
if body.OrgID != "11111111-2222-3333-4444-555555555555" {
|
||||
t.Errorf("org_id = %q, want the configured uuid", body.OrgID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("name/slug/org_id empty when unset (self-host)", func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_NAME", "")
|
||||
t.Setenv("MOLECULE_ORG_SLUG", "")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/org/identity", nil)
|
||||
|
||||
OrgIdentity(c)
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
OrgID string `json:"org_id"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if body.Name != "" {
|
||||
t.Errorf("name = %q, want empty string", body.Name)
|
||||
}
|
||||
if body.Slug != "" {
|
||||
t.Errorf("slug = %q, want empty string", body.Slug)
|
||||
}
|
||||
if body.OrgID != "" {
|
||||
t.Errorf("org_id = %q, want empty string", body.OrgID)
|
||||
}
|
||||
})
|
||||
|
||||
// platform_managed_available reflects whether a Molecule LLM proxy is wired
|
||||
// into the process env — true on SaaS (proxy base URL + usage token set),
|
||||
// false on self-host (neither set). The canvas reads it to hide/show the
|
||||
// "Platform (proxy)" billing option pre-login.
|
||||
t.Run("platform_managed_available true when proxy configured (SaaS)", func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://proxy.example/v1")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "tok-test")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/org/identity", nil)
|
||||
|
||||
OrgIdentity(c)
|
||||
|
||||
var body struct {
|
||||
PlatformManagedAvailable bool `json:"platform_managed_available"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if !body.PlatformManagedAvailable {
|
||||
t.Errorf("platform_managed_available = false, want true (proxy configured)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("platform_managed_available false when no proxy (self-host)", func(t *testing.T) {
|
||||
// Clear every proxy env so neither the molecule nor openai alias is set.
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "")
|
||||
t.Setenv("OPENAI_BASE_URL", "")
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/org/identity", nil)
|
||||
|
||||
OrgIdentity(c)
|
||||
|
||||
var body struct {
|
||||
PlatformManagedAvailable bool `json:"platform_managed_available"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if body.PlatformManagedAvailable {
|
||||
t.Errorf("platform_managed_available = true, want false (no proxy / self-host)")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("platform_managed_available true via openai alias env", func(t *testing.T) {
|
||||
// The proxy can also be wired via the OPENAI_* aliases (non-anthropic
|
||||
// runtimes). Either pair satisfies the signal.
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "")
|
||||
t.Setenv("OPENAI_BASE_URL", "https://proxy.example/v1")
|
||||
t.Setenv("OPENAI_API_KEY", "tok-test")
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/org/identity", nil)
|
||||
|
||||
OrgIdentity(c)
|
||||
|
||||
var body struct {
|
||||
PlatformManagedAvailable bool `json:"platform_managed_available"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||||
t.Fatalf("unmarshal: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
if !body.PlatformManagedAvailable {
|
||||
t.Errorf("platform_managed_available = false, want true (openai alias proxy env)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// stubBootProv is a minimal localProvisionerIsRunning for the boot-provision
|
||||
// helper test — no Docker daemon required. It deliberately does NOT implement
|
||||
// ExecRead, so conciergeIdentityPresent's type-assertion misses and a running
|
||||
// container is treated as already-identified (skip) — the legacy behaviour.
|
||||
type stubBootProv struct {
|
||||
running bool
|
||||
calledWith string
|
||||
}
|
||||
|
||||
func (s *stubBootProv) IsRunning(_ context.Context, id string) (bool, error) {
|
||||
s.calledWith = id
|
||||
return s.running, nil
|
||||
}
|
||||
|
||||
// stubBootProvExec adds ExecRead so the boot helper can probe for the concierge
|
||||
// identity on a RUNNING container — the path that restarts a running-but-vanilla
|
||||
// concierge so it picks up the seeded overlay.
|
||||
type stubBootProvExec struct {
|
||||
stubBootProv
|
||||
systemPrompt string // returned for /configs/system-prompt.md; "" with execErr to simulate a probe miss
|
||||
execErr error
|
||||
}
|
||||
|
||||
func (s *stubBootProvExec) ExecRead(_ context.Context, _ /*container*/, _ /*path*/ string) ([]byte, error) {
|
||||
if s.execErr != nil {
|
||||
return nil, s.execErr
|
||||
}
|
||||
return []byte(s.systemPrompt), nil
|
||||
}
|
||||
|
||||
const bootPlatformID = "11111111-2222-3333-4444-555555555555"
|
||||
|
||||
// TestMaybeProvisionPlatformAgentOnBoot_KicksOffWhenNotRunning: row present +
|
||||
// container not running ⇒ RestartByID is invoked with the platform agent's id.
|
||||
func TestMaybeProvisionPlatformAgentOnBoot_KicksOffWhenNotRunning(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT id, status FROM workspaces WHERE kind = 'platform'`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "status"}).AddRow(bootPlatformID, "failed"))
|
||||
|
||||
prov := &stubBootProv{running: false}
|
||||
done := make(chan string, 1)
|
||||
MaybeProvisionPlatformAgentOnBoot(context.Background(), db.DB, prov, func(id string) {
|
||||
done <- id
|
||||
})
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
if got != bootPlatformID {
|
||||
t.Errorf("RestartByID called with %q, want %q", got, bootPlatformID)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("RestartByID was not called within timeout")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeProvisionPlatformAgentOnBoot_SkipsWhenRunning: container already
|
||||
// running ⇒ RestartByID is NOT called.
|
||||
func TestMaybeProvisionPlatformAgentOnBoot_SkipsWhenRunning(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT id, status FROM workspaces WHERE kind = 'platform'`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "status"}).AddRow(bootPlatformID, "online"))
|
||||
|
||||
prov := &stubBootProv{running: true}
|
||||
called := make(chan string, 1)
|
||||
MaybeProvisionPlatformAgentOnBoot(context.Background(), db.DB, prov, func(id string) {
|
||||
called <- id
|
||||
})
|
||||
|
||||
select {
|
||||
case got := <-called:
|
||||
t.Fatalf("RestartByID should not have been called, got %q", got)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
// expected: no call
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeProvisionPlatformAgentOnBoot_NoRowNoOp: no platform agent row ⇒
|
||||
// no provision, no panic.
|
||||
func TestMaybeProvisionPlatformAgentOnBoot_NoRowNoOp(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT id, status FROM workspaces WHERE kind = 'platform'`).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
prov := &stubBootProv{running: false}
|
||||
called := make(chan string, 1)
|
||||
MaybeProvisionPlatformAgentOnBoot(context.Background(), db.DB, prov, func(id string) {
|
||||
called <- id
|
||||
})
|
||||
|
||||
select {
|
||||
case got := <-called:
|
||||
t.Fatalf("RestartByID should not have been called, got %q", got)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeProvisionPlatformAgentOnBoot_NilGuards: nil prov or nil restartFn ⇒
|
||||
// no-op (no DB access, no panic).
|
||||
func TestMaybeProvisionPlatformAgentOnBoot_NilGuards(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
// No ExpectQuery — the helper must return before touching the DB.
|
||||
MaybeProvisionPlatformAgentOnBoot(context.Background(), db.DB, nil, func(string) {})
|
||||
MaybeProvisionPlatformAgentOnBoot(context.Background(), db.DB, &stubBootProv{}, nil)
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations (should have made no queries): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeProvisionPlatformAgentOnBoot_RestartsRunningButVanilla: a RUNNING
|
||||
// concierge whose /configs/system-prompt.md lacks the identity (a pre-overlay
|
||||
// boot) is restarted ONCE so the provision path re-seeds the concierge config.
|
||||
func TestMaybeProvisionPlatformAgentOnBoot_RestartsRunningButVanilla(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT id, status FROM workspaces WHERE kind = 'platform'`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "status"}).AddRow(bootPlatformID, "online"))
|
||||
|
||||
// Running, but ExecRead of system-prompt.md returns vanilla content (no
|
||||
// "Org Concierge") → identity absent → restart.
|
||||
prov := &stubBootProvExec{stubBootProv: stubBootProv{running: true}, systemPrompt: "generic coding assistant"}
|
||||
done := make(chan string, 1)
|
||||
MaybeProvisionPlatformAgentOnBoot(context.Background(), db.DB, prov, func(id string) { done <- id })
|
||||
|
||||
select {
|
||||
case got := <-done:
|
||||
if got != bootPlatformID {
|
||||
t.Errorf("RestartByID called with %q, want %q", got, bootPlatformID)
|
||||
}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
t.Fatal("RestartByID was not called for a running-but-vanilla concierge")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaybeProvisionPlatformAgentOnBoot_SkipsRunningWithIdentity: a RUNNING
|
||||
// concierge that already carries the Org-Concierge identity is left alone.
|
||||
func TestMaybeProvisionPlatformAgentOnBoot_SkipsRunningWithIdentity(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(`SELECT id, status FROM workspaces WHERE kind = 'platform'`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "status"}).AddRow(bootPlatformID, "online"))
|
||||
|
||||
prov := &stubBootProvExec{stubBootProv: stubBootProv{running: true}, systemPrompt: "# You are Molecule AI Agent — the Org Concierge"}
|
||||
called := make(chan string, 1)
|
||||
MaybeProvisionPlatformAgentOnBoot(context.Background(), db.DB, prov, func(id string) { called <- id })
|
||||
|
||||
select {
|
||||
case got := <-called:
|
||||
t.Fatalf("RestartByID should not have been called (identity present), got %q", got)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// TestConciergeIdentityFiles asserts the overlay: a system-prompt.md carrying
|
||||
// the Org-Concierge identity, and a config.yaml that gains the platform
|
||||
// mcp_servers entry — appended idempotently onto the base config.
|
||||
func TestConciergeIdentityFiles(t *testing.T) {
|
||||
base := []byte("name: \"Org Concierge\"\nruntime: claude-code\nmodel: \"sonnet\"\n")
|
||||
files := conciergeIdentityFiles("Molecule AI Agent", base)
|
||||
|
||||
sp, ok := files["system-prompt.md"]
|
||||
if !ok {
|
||||
t.Fatal("overlay missing system-prompt.md")
|
||||
}
|
||||
for _, want := range []string{"Molecule AI Agent", "Org Concierge", "platform agent", "delegate", "approv"} {
|
||||
if !strings.Contains(string(sp), want) {
|
||||
t.Errorf("system-prompt.md missing %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
cfg, ok := files["config.yaml"]
|
||||
if !ok {
|
||||
t.Fatal("overlay missing config.yaml (mcp_servers should have been appended)")
|
||||
}
|
||||
for _, want := range []string{"mcp_servers:", "name: platform", "command: node", "/opt/molecule-mcp-server/dist/index.js", "runtime: claude-code"} {
|
||||
if !strings.Contains(string(cfg), want) {
|
||||
t.Errorf("config.yaml missing %q\n--- got ---\n%s", want, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotent: re-applying onto an already-patched config does NOT add a
|
||||
// second mcp_servers block and does NOT emit a config.yaml overlay (nothing
|
||||
// to change), so the count of "mcp_servers:" stays exactly one.
|
||||
files2 := conciergeIdentityFiles("Molecule AI Agent", cfg)
|
||||
if _, present := files2["config.yaml"]; present {
|
||||
t.Error("re-apply should NOT re-emit config.yaml when mcp_servers is already present")
|
||||
}
|
||||
if n := strings.Count(string(cfg), "mcp_servers:"); n != 1 {
|
||||
t.Errorf("mcp_servers: appears %d times, want exactly 1", n)
|
||||
}
|
||||
|
||||
// No base config (couldn't read one): identity still lands; no config.yaml.
|
||||
only := conciergeIdentityFiles("Org Concierge", nil)
|
||||
if _, present := only["system-prompt.md"]; !present {
|
||||
t.Error("system prompt must land even with no base config")
|
||||
}
|
||||
if _, present := only["config.yaml"]; present {
|
||||
t.Error("no config.yaml overlay when there is no base to append onto")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConciergePlatformMCPEnv asserts the platform-MCP env wiring: ADMIN_TOKEN →
|
||||
// MOLECULE_API_KEY, PLATFORM_URL → MOLECULE_API_URL fallback, and that an
|
||||
// already-present value is never clobbered.
|
||||
func TestConciergePlatformMCPEnv(t *testing.T) {
|
||||
t.Run("wires from ADMIN_TOKEN + PLATFORM_URL", func(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "admintok")
|
||||
t.Setenv("MOLECULE_API_URL", "")
|
||||
t.Setenv("PLATFORM_URL", "http://platform:8080")
|
||||
t.Setenv("MOLECULE_ORG_ID", "org-123")
|
||||
env := map[string]string{}
|
||||
conciergePlatformMCPEnv(env)
|
||||
if env["MOLECULE_API_KEY"] != "admintok" {
|
||||
t.Errorf("MOLECULE_API_KEY = %q, want admintok", env["MOLECULE_API_KEY"])
|
||||
}
|
||||
if env["MOLECULE_API_URL"] != "http://platform:8080" {
|
||||
t.Errorf("MOLECULE_API_URL = %q, want platform url fallback", env["MOLECULE_API_URL"])
|
||||
}
|
||||
if env["MOLECULE_ORG_ID"] != "org-123" {
|
||||
t.Errorf("MOLECULE_ORG_ID = %q, want org-123", env["MOLECULE_ORG_ID"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("does not clobber existing values", func(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "admintok")
|
||||
env := map[string]string{"MOLECULE_API_KEY": "preset"}
|
||||
conciergePlatformMCPEnv(env)
|
||||
if env["MOLECULE_API_KEY"] != "preset" {
|
||||
t.Errorf("MOLECULE_API_KEY overwritten to %q, want preset preserved", env["MOLECULE_API_KEY"])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MOLECULE_API_URL prefers explicit over PLATFORM_URL", func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_API_URL", "http://explicit:9000")
|
||||
t.Setenv("PLATFORM_URL", "http://platform:8080")
|
||||
env := map[string]string{}
|
||||
conciergePlatformMCPEnv(env)
|
||||
if env["MOLECULE_API_URL"] != "http://explicit:9000" {
|
||||
t.Errorf("MOLECULE_API_URL = %q, want the explicit env", env["MOLECULE_API_URL"])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestApplyConciergeProvisionConfig_OnlyPlatformGetsOrgMCP locks the security
|
||||
// invariant the user requires: ONLY the tenant-native concierge (kind='platform')
|
||||
// receives the org/platform MCP + the org-admin token. An ordinary workspace must
|
||||
// NOT get the platform MCP config, the system prompt, or MOLECULE_API_KEY (the
|
||||
// org-admin credential) natively — otherwise any workspace could drive org-admin
|
||||
// actions (create_workspace, set_secret, …). Gate is keyed off the DB kind column
|
||||
// (SSOT, protected by the one-platform-root CHECK constraint).
|
||||
func TestApplyConciergeProvisionConfig_OnlyPlatformGetsOrgMCP(t *testing.T) {
|
||||
t.Setenv("ADMIN_TOKEN", "secret-org-admin")
|
||||
t.Setenv("PLATFORM_URL", "http://platform:8080")
|
||||
h := &WorkspaceHandler{}
|
||||
const kindQuery = `SELECT COALESCE\(kind, 'workspace'\) FROM workspaces WHERE id =`
|
||||
|
||||
t.Run("ordinary workspace gets NO org MCP and NO admin token", func(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(kindQuery).WithArgs("ws-ordinary").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("workspace"))
|
||||
env := map[string]string{}
|
||||
cf := map[string][]byte{"config.yaml": []byte("runtime: claude-code\n")}
|
||||
out := h.applyConciergeProvisionConfig(context.Background(), "ws-ordinary", "", cf, env, "Worker")
|
||||
if _, ok := env["MOLECULE_API_KEY"]; ok {
|
||||
t.Errorf("SECURITY: ordinary workspace leaked MOLECULE_API_KEY (org-admin token): %v", env)
|
||||
}
|
||||
if _, ok := out["system-prompt.md"]; ok {
|
||||
t.Error("ordinary workspace was given the concierge system prompt")
|
||||
}
|
||||
if strings.Contains(string(out["config.yaml"]), "mcp_servers") {
|
||||
t.Error("SECURITY: ordinary workspace was given the platform mcp_servers config")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("platform agent DOES get the org MCP and admin token", func(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mock.ExpectQuery(kindQuery).WithArgs("ws-concierge").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("platform"))
|
||||
env := map[string]string{}
|
||||
cf := map[string][]byte{"config.yaml": []byte("runtime: claude-code\n")}
|
||||
out := h.applyConciergeProvisionConfig(context.Background(), "ws-concierge", "", cf, env, "Molecule AI Agent")
|
||||
if env["MOLECULE_API_KEY"] != "secret-org-admin" {
|
||||
t.Errorf("concierge did not receive the org-admin token; env=%v", env)
|
||||
}
|
||||
if _, ok := out["system-prompt.md"]; !ok {
|
||||
t.Error("concierge did not receive the system prompt")
|
||||
}
|
||||
if !strings.Contains(string(out["config.yaml"]), "mcp_servers") {
|
||||
t.Error("concierge did not receive the platform mcp_servers config")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -166,7 +166,7 @@ func (h *RegistryHandler) resolveDeliveryMode(ctx context.Context, workspaceID,
|
||||
|
||||
// errPlatformNotRoot is the client-facing message when a register call tried to
|
||||
// mark a non-root workspace as a platform agent.
|
||||
const errPlatformNotRoot = "a platform agent must be the org root (parent_id must be null)"
|
||||
const errPlatformNotRoot = "a platform agent must be the org root (parent_id must be null) and there can be only one per org"
|
||||
|
||||
// isPlatformRootViolation reports whether err is the DB rejecting a register
|
||||
// that tried to mark a non-root workspace as a platform agent (the
|
||||
@@ -175,7 +175,15 @@ const errPlatformNotRoot = "a platform agent must be the org root (parent_id mus
|
||||
// which structurally also guarantees one platform agent per org — is enforced
|
||||
// race-proof at the DB level; this is just the friendly surface.
|
||||
func isPlatformRootViolation(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "workspaces_platform_root_check")
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
// workspaces_platform_root_check: tried to mark a non-root (parented) row
|
||||
// platform. uniq_workspaces_one_platform_root: tried to create a SECOND
|
||||
// platform root. Both surface as a friendly 409 instead of a raw 500.
|
||||
return strings.Contains(msg, "workspaces_platform_root_check") ||
|
||||
strings.Contains(msg, "uniq_workspaces_one_platform_root")
|
||||
}
|
||||
|
||||
// Returns a non-nil error suitable for including in a 400 Bad Request response.
|
||||
@@ -261,9 +269,28 @@ func validateAgentURL(rawURL string) error {
|
||||
// the agent won't be reachable anyway, so blocking on DNS failure is safe.
|
||||
ips, lookupErr := net.LookupIP(hostname)
|
||||
if lookupErr != nil {
|
||||
// DNS lookup failed — block the URL rather than allow a potentially-
|
||||
// unreachable or intentionally-unresolvable hostname through. The
|
||||
// platform has no use for a workspace it cannot reach.
|
||||
// #36/#2421: a freshly-provisioned CROSS-CLOUD workspace advertises its
|
||||
// per-workspace Cloudflare tunnel hostname (ws-<id>.<appDomain>). That DNS
|
||||
// record is eventually-consistent, and a FAST-booting box (a Hetzner cpx
|
||||
// reports "workspace ready after ~1s") registers BEFORE the record
|
||||
// propagates → the lookup fails → 400 → and the runtime does not retry a
|
||||
// 4xx → agent_card never lands and the agent never comes online. AWS boots
|
||||
// slowly enough to miss the race, which is why only the fast cloud broke.
|
||||
//
|
||||
// Such a hostname is NOT an SSRF vector: it lives under the platform's own
|
||||
// domain (only the platform can create records there, so it can't be
|
||||
// pointed at 169.254/127/private space by an attacker), and it resolves to
|
||||
// nothing right now. So in SaaS mode allow a platform-tunnel hostname
|
||||
// through while its DNS settles; everything else stays blocked. The
|
||||
// unconditional metadata/loopback blocks above still apply once it
|
||||
// resolves. (Restores the pre-#1130 "let an unresolvable platform URL
|
||||
// through" behaviour, scoped to the trusted tunnel domain.)
|
||||
if saasMode() && isPlatformTunnelHostname(hostname) {
|
||||
log.Printf("Registry validateAgentURL: allowing not-yet-resolvable platform tunnel hostname %q (DNS still propagating)", hostname)
|
||||
return nil
|
||||
}
|
||||
// DNS lookup failed for a non-platform hostname — block it. The platform
|
||||
// has no use for a workspace it cannot reach.
|
||||
return fmt.Errorf("hostname %q cannot be resolved (DNS error): %w", hostname, lookupErr)
|
||||
}
|
||||
for _, ip := range ips {
|
||||
@@ -274,6 +301,24 @@ func validateAgentURL(rawURL string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// isPlatformTunnelHostname reports whether h is a platform-provisioned per-
|
||||
// workspace Cloudflare tunnel hostname — `ws-<id>.<appDomain>` under the
|
||||
// platform's OWN domain. Only the platform controls DNS there, so a not-yet-
|
||||
// resolvable such hostname is a pending-DNS tunnel (DNS propagation race), never
|
||||
// an attacker-controlled SSRF URL. The domain defaults to moleculesai.app
|
||||
// (covers prod `*.moleculesai.app` and staging `*.staging.moleculesai.app`) and
|
||||
// is overridable via MOLECULE_APP_DOMAIN for other deployments.
|
||||
func isPlatformTunnelHostname(h string) bool {
|
||||
if !strings.HasPrefix(h, "ws-") {
|
||||
return false
|
||||
}
|
||||
domain := strings.TrimSpace(os.Getenv("MOLECULE_APP_DOMAIN"))
|
||||
if domain == "" {
|
||||
domain = "moleculesai.app"
|
||||
}
|
||||
return strings.HasSuffix(h, "."+domain)
|
||||
}
|
||||
|
||||
// Register handles POST /registry/register
|
||||
// Upserts workspace, sets Redis TTL, broadcasts WORKSPACE_ONLINE.
|
||||
func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
@@ -316,6 +361,32 @@ func (h *RegistryHandler) Register(c *gin.Context) {
|
||||
return // 401 response already written by requireWorkspaceToken
|
||||
}
|
||||
|
||||
// SECURITY (privilege-escalation fix): the public register path must never
|
||||
// CREATE or PROMOTE a row to kind='platform'. The org root is minted only by
|
||||
// the AdminAuth/boot-gated install paths (InstallPlatformAgent /
|
||||
// EnsureSelfHostedPlatformAgent). Without this, an ordinary in-VPC workspace
|
||||
// could register a fresh UUID as {"kind":"platform"} (a bootstrap-allowed call,
|
||||
// parent_id defaults NULL so the per-row CHECK is satisfied) and then be
|
||||
// provisioned with the tenant org-admin token (MOLECULE_API_KEY=ADMIN_TOKEN).
|
||||
// A platform agent re-registering its already-platform row (or omitting kind)
|
||||
// is unaffected. uniq_workspaces_one_platform_root is the structural backstop;
|
||||
// this is the friendly app-layer guard. Placed after the token check so it
|
||||
// doesn't side-channel row existence (mirrors resolveDeliveryMode below).
|
||||
if payload.Kind == models.KindPlatform {
|
||||
var existingKind string
|
||||
kErr := db.DB.QueryRowContext(ctx,
|
||||
`SELECT kind FROM workspaces WHERE id = $1`, payload.ID).Scan(&existingKind)
|
||||
switch {
|
||||
case errors.Is(kErr, sql.ErrNoRows), kErr == nil && existingKind != models.KindPlatform:
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "kind='platform' may only be assigned by the platform-agent install path"})
|
||||
return
|
||||
case kErr != nil && !errors.Is(kErr, sql.ErrNoRows):
|
||||
log.Printf("Registry register: kind precheck failed for %s: %v", payload.ID, kErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the EFFECTIVE delivery mode for THIS register call: the
|
||||
// payload's explicit value wins; falling back to the existing row's
|
||||
// stored value; falling back to push (the schema default). Done AFTER
|
||||
|
||||
@@ -882,6 +882,42 @@ func TestValidateAgentURL_SaaSMode_AllowsRFC1918(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgentURL_PendingPlatformTunnel (#36/#2421): a freshly-provisioned
|
||||
// cross-cloud workspace advertises its per-workspace tunnel hostname
|
||||
// (ws-<id>.<appDomain>) whose DNS has not propagated yet when a FAST box (Hetzner
|
||||
// ~1s boot) registers. validateAgentURL must allow such a platform-tunnel
|
||||
// hostname through in SaaS mode instead of 400 (which the runtime never retries
|
||||
// → agent_card never lands). Non-platform unresolvable hostnames stay blocked.
|
||||
func TestValidateAgentURL_PendingPlatformTunnel(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
h string
|
||||
want bool
|
||||
}{
|
||||
{"ws-abc123.moleculesai.app", true},
|
||||
{"ws-abc123.staging.moleculesai.app", true},
|
||||
{"ws-abc123.evil.com", false}, // not under the platform domain
|
||||
{"api.moleculesai.app", false}, // no ws- prefix
|
||||
{"ws-x.fakemoleculesai.app", false}, // lookalike domain, not a subdomain
|
||||
} {
|
||||
if got := isPlatformTunnelHostname(tc.h); got != tc.want {
|
||||
t.Errorf("isPlatformTunnelHostname(%q)=%v want %v", tc.h, got, tc.want)
|
||||
}
|
||||
}
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "saas")
|
||||
// A platform tunnel hostname is allowed — whether or not its DNS has
|
||||
// propagated (a resolved record is a public Cloudflare IP = allowed; an
|
||||
// unresolved one is allowed by the pending-tunnel branch).
|
||||
if err := validateAgentURL("https://ws-deadbeef0001.staging.moleculesai.app/a2a"); err != nil {
|
||||
t.Errorf("SaaS: pending platform tunnel must be allowed, got %v", err)
|
||||
}
|
||||
// A NON-platform unresolvable hostname stays blocked even in SaaS
|
||||
// (.invalid never resolves — RFC 2606).
|
||||
if err := validateAgentURL("https://ws-x.attacker.invalid/a2a"); err == nil {
|
||||
t.Error("SaaS: non-platform unresolvable hostname must stay blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgentURL_SaaSMode_StillBlocksMetadataEtAl verifies that even in
|
||||
// SaaS mode the always-blocked ranges (metadata, loopback, TEST-NET, CGNAT,
|
||||
// non-fd00 ULA) stay blocked.
|
||||
@@ -1664,12 +1700,12 @@ func TestRegister_InvalidKind(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_PlatformKind_PersistsKind verifies that a workspace registering
|
||||
// with kind="platform" has that value written through the upsert (the platform
|
||||
// agent self-registers as the org root). The platform==root invariant itself is
|
||||
// enforced by the workspaces_platform_root_check DB constraint and exercised by
|
||||
// the integration test, which sqlmock cannot enforce.
|
||||
func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
|
||||
// TestRegister_AllowsAlreadyPlatformReRegister verifies that a workspace whose
|
||||
// row is ALREADY kind="platform" (pre-seeded by the AdminAuth/boot-gated install
|
||||
// path) may re-register through the public /registry/register path with
|
||||
// kind="platform", and the value is preserved through the upsert. This is the
|
||||
// legitimate platform-agent boot flow.
|
||||
func TestRegister_AllowsAlreadyPlatformReRegister(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
@@ -1682,6 +1718,12 @@ func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// SECURITY precheck: the row is already kind="platform", so the re-register
|
||||
// is allowed to proceed.
|
||||
mock.ExpectQuery("SELECT kind FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("platform"))
|
||||
|
||||
// delivery_mode="push" is set explicitly, so resolveDeliveryMode
|
||||
// short-circuits (no SELECT delivery_mode lookup). The upsert MUST carry
|
||||
// kind="platform" as the 6th arg.
|
||||
@@ -1715,7 +1757,83 @@ func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("platform register: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
t.Fatalf("already-platform re-register: expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_RejectsFreshPlatformKind locks the privilege-escalation fix: the
|
||||
// public /registry/register path must NOT let a brand-new (fresh-id) workspace
|
||||
// declare kind="platform" and mint itself a second org root. It must be refused
|
||||
// (403) before any upsert — only the AdminAuth/boot-gated install paths may mint
|
||||
// the platform agent.
|
||||
func TestRegister_RejectsFreshPlatformKind(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewRegistryHandler(newTestBroadcaster())
|
||||
|
||||
const wsID = "ws-rogue-fresh"
|
||||
|
||||
// Bootstrap path — no live tokens (a fresh id).
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// SECURITY precheck: no existing row → empty result → sql.ErrNoRows → refuse.
|
||||
// No upsert / token issuance must follow.
|
||||
mock.ExpectQuery("SELECT kind FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"kind"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","delivery_mode":"push","kind":"platform","agent_card":{"name":"rogue"}}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("fresh kind=platform register: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_RejectsPlatformPromotion locks the other half of the fix: a row
|
||||
// that already exists as kind="workspace" must NOT be promotable to "platform"
|
||||
// via the public register path (which would later get it provisioned with the
|
||||
// org-admin token). Refused (403) before the upsert.
|
||||
func TestRegister_RejectsPlatformPromotion(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewRegistryHandler(newTestBroadcaster())
|
||||
|
||||
const wsID = "ws-ordinary"
|
||||
|
||||
// Has no live tokens for test simplicity (bootstrap-allowed call).
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
// SECURITY precheck: existing row is kind="workspace" → refuse promotion.
|
||||
mock.ExpectQuery("SELECT kind FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"kind"}).AddRow("workspace"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","delivery_mode":"push","kind":"platform","agent_card":{"name":"rogue"}}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("promote workspace->platform: expected 403, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
|
||||
@@ -80,10 +80,25 @@ func enrichFromRegistry(summary *templateSummary, runtime string) {
|
||||
return
|
||||
}
|
||||
|
||||
// SSOT filter (the BLOCKER): when no Molecule LLM proxy is wired into this
|
||||
// process the platform_managed billing path cannot inject a credential, so
|
||||
// the closed `platform` provider (and every model that derives to it) is
|
||||
// not actually selectable. Drop it AT THE SOURCE so every consumer of the
|
||||
// /templates payload (ConfigTab, CreateWorkspaceDialog, MissingKeysModal)
|
||||
// respects it — instead of a frontend leaf-filter that each consumer must
|
||||
// remember to apply. On SaaS the proxy is configured -> proxyOn=true and
|
||||
// this is a no-op, leaving the payload byte-identical to before.
|
||||
proxyOn := PlatformManagedProxyConfigured()
|
||||
|
||||
// registry_providers — the runtime's native provider set, in registry
|
||||
// declared order, projected to the canvas-facing view.
|
||||
views := make([]registryProviderView, 0, len(provs))
|
||||
for _, p := range provs {
|
||||
if !proxyOn && p.IsPlatform() {
|
||||
// Self-host: no proxy -> platform-managed billing is impossible.
|
||||
// Hide the platform provider so it can't be offered anywhere.
|
||||
continue
|
||||
}
|
||||
views = append(views, registryProviderView{
|
||||
Name: p.Name,
|
||||
DisplayName: p.DisplayName,
|
||||
@@ -110,6 +125,12 @@ func enrichFromRegistry(summary *templateSummary, runtime string) {
|
||||
for _, id := range models {
|
||||
ms := modelSpec{ID: id}
|
||||
if derived, derr := m.DeriveProvider(runtime, id, nil); derr == nil {
|
||||
if !proxyOn && derived.IsPlatform() {
|
||||
// Self-host: this model derives to the platform-managed
|
||||
// provider, which is unusable without a proxy. Drop it at the
|
||||
// source so it can't leak as a selectable id in any consumer.
|
||||
continue
|
||||
}
|
||||
ms.Provider = derived.Name
|
||||
ms.BillingMode = billingModeForRegistryProvider(derived)
|
||||
ms.RequiredEnv = requiredEnvForRegistryProvider(derived)
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/providers"
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -1347,6 +1348,11 @@ func TestCWE78_DeleteFile_TraversalVariants(t *testing.T) {
|
||||
// config.yaml runtime_config.models happens to list. A template author can no
|
||||
// longer surface an unregistered model into the canvas dropdown.
|
||||
func TestTemplatesList_RegistryServesSelectableModels(t *testing.T) {
|
||||
// SaaS path: a Molecule proxy is configured, so the platform-managed
|
||||
// provider + its models ARE selectable (this test's assertions cover them).
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://llm.example.test")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "proxy-tok")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmplDir := filepath.Join(tmpDir, "claude-code-default")
|
||||
if err := os.MkdirAll(tmplDir, 0755); err != nil {
|
||||
@@ -1414,6 +1420,11 @@ skills: []
|
||||
// to show the billing-mode of the DERIVED provider (folds in #1931 intent),
|
||||
// instead of its hardcoded billingModeForProvider rule.
|
||||
func TestTemplatesList_RegistryAnnotatesDerivedProviderAndBilling(t *testing.T) {
|
||||
// SaaS path: a Molecule proxy is configured, so the platform-managed
|
||||
// provider + its models ARE present (this test pins their annotations).
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://llm.example.test")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "proxy-tok")
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmplDir := filepath.Join(tmpDir, "claude-code-default")
|
||||
if err := os.MkdirAll(tmplDir, 0755); err != nil {
|
||||
@@ -1637,3 +1648,112 @@ func TestTemplatesList_DisplayableFlag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTemplatesList_PlatformManagedFilteredWhenNoProxy pins the SSOT filter:
|
||||
// the closed `platform` provider (and every model that derives to it) is
|
||||
// dropped from the /templates payload AT THE SOURCE when no Molecule LLM
|
||||
// proxy is configured (self-host) — so every consumer (ConfigTab,
|
||||
// CreateWorkspaceDialog, MissingKeysModal) inherits it — and is present,
|
||||
// unchanged, when the proxy IS configured (SaaS).
|
||||
func TestTemplatesList_PlatformManagedFilteredWhenNoProxy(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
tmplDir := filepath.Join(tmpDir, "claude-code-default")
|
||||
if err := os.MkdirAll(tmplDir, 0755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
configYaml := `name: Claude Code
|
||||
runtime: claude-code
|
||||
runtime_config:
|
||||
model: claude-sonnet-4-6
|
||||
skills: []
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(tmplDir, "config.yaml"), []byte(configYaml), 0644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
|
||||
fetch := func(t *testing.T) templateSummary {
|
||||
t.Helper()
|
||||
handler := NewTemplatesHandler(tmpDir, nil, nil)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("GET", "/templates", nil)
|
||||
handler.List(c)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", w.Code)
|
||||
}
|
||||
var resp []templateSummary
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 template, got %d", len(resp))
|
||||
}
|
||||
return resp[0]
|
||||
}
|
||||
|
||||
hasPlatformProvider := func(s templateSummary) bool {
|
||||
for _, p := range s.RegistryProviders {
|
||||
if p.Name == providers.PlatformProviderName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
hasPlatformModel := func(s templateSummary) bool {
|
||||
for _, m := range s.RegistryModels {
|
||||
if m.Provider == providers.PlatformProviderName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Self-host: NO proxy → platform provider + its models are filtered out.
|
||||
t.Run("no_proxy_filters_platform", func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "")
|
||||
t.Setenv("OPENAI_BASE_URL", "")
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
if PlatformManagedProxyConfigured() {
|
||||
t.Fatalf("precondition: proxy must read as unconfigured")
|
||||
}
|
||||
|
||||
got := fetch(t)
|
||||
if !got.RegistryBacked {
|
||||
t.Fatalf("claude-code is registry-backed; RegistryBacked must be true")
|
||||
}
|
||||
if hasPlatformProvider(got) {
|
||||
t.Errorf("self-host: platform provider must be filtered from RegistryProviders; got %+v", got.RegistryProviders)
|
||||
}
|
||||
if hasPlatformModel(got) {
|
||||
t.Errorf("self-host: platform-derived models must be filtered from RegistryModels; got %+v", got.RegistryModels)
|
||||
}
|
||||
// A BYOK provider/model must STILL be present — only platform is dropped.
|
||||
byokPresent := false
|
||||
for _, p := range got.RegistryProviders {
|
||||
if p.Name == "anthropic-api" {
|
||||
byokPresent = true
|
||||
}
|
||||
}
|
||||
if !byokPresent {
|
||||
t.Errorf("self-host: BYOK provider anthropic-api must remain; got %+v", got.RegistryProviders)
|
||||
}
|
||||
})
|
||||
|
||||
// SaaS: proxy configured → platform provider + its models ARE present.
|
||||
t.Run("proxy_keeps_platform", func(t *testing.T) {
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "https://llm.example.test")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "proxy-tok")
|
||||
if !PlatformManagedProxyConfigured() {
|
||||
t.Fatalf("precondition: proxy must read as configured")
|
||||
}
|
||||
|
||||
got := fetch(t)
|
||||
if !hasPlatformProvider(got) {
|
||||
t.Errorf("SaaS: platform provider must be present in RegistryProviders; got %+v", got.RegistryProviders)
|
||||
}
|
||||
if !hasPlatformModel(got) {
|
||||
t.Errorf("SaaS: platform-derived models must be present in RegistryModels; got %+v", got.RegistryModels)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package handlers
|
||||
|
||||
// UserTaskStore is the SSOT for the "user tasks" primitive — the structured
|
||||
// asks an agent raises for the human user (e.g. "Review the draft", "Provide
|
||||
// the API key"). Every surface that mutates or reads user_tasks — the REST
|
||||
// handlers in user_tasks.go AND the MCP tools in mcp_tools.go — MUST route
|
||||
// through this store rather than re-implement the SQL + status-enum
|
||||
// validation + USER_TASK_* broadcast inline.
|
||||
//
|
||||
// Why: pre-consolidation the REST handler and the MCP bridge each hand-wrote
|
||||
// the SAME INSERT / COALESCE-UPDATE / DELETE SQL, the SAME pending/done/
|
||||
// dismissed enum check, and the SAME EventUserTaskRequested broadcast. Two
|
||||
// copies of one contract drift silently (the AgentMessageWriter consolidation
|
||||
// in agent_message_writer.go exists for exactly this reason — the reno-stars
|
||||
// data-loss incident was the symptom of one half lagging the other). This
|
||||
// store gives both call sites a single well-tested implementation.
|
||||
//
|
||||
// The store owns persistence + validation + the event broadcast. HTTP-specific
|
||||
// concerns (gin binding, status codes) and MCP-specific concerns (arg parsing,
|
||||
// string replies) stay in their respective handlers.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
|
||||
)
|
||||
|
||||
// ErrUserTaskNotFound is returned by Update/Delete/Resolve when no row matches
|
||||
// the (id, workspace_id) scope — the task does not exist, is owned by another
|
||||
// workspace, or (for Resolve) is already resolved. Callers translate to HTTP
|
||||
// 404 / a JSON-RPC error.
|
||||
var ErrUserTaskNotFound = errors.New("user_task: not found")
|
||||
|
||||
// ErrInvalidUserTaskStatus is returned when a caller supplies a status outside
|
||||
// the pending/done/dismissed enum. Callers translate to HTTP 400.
|
||||
var ErrInvalidUserTaskStatus = errors.New("user_task: status must be 'pending', 'done' or 'dismissed'")
|
||||
|
||||
// UserTaskRow is one row of a workspace's own user-task list (List).
|
||||
type UserTaskRow struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Detail *string `json:"detail"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ResolvedAt *string `json:"resolved_at"`
|
||||
ResolvedBy *string `json:"resolved_by"`
|
||||
}
|
||||
|
||||
// UserTaskStore persists + broadcasts user-task mutations. Construct per call
|
||||
// site via NewUserTaskStore (mirroring AgentMessageWriter's usage in
|
||||
// activity.go / mcp_tools.go) so the REST handlers — which read the global
|
||||
// db.DB that tests swap under them — and the MCP bridge share one code path.
|
||||
//
|
||||
// Takes events.EventEmitter (not the concrete *Broadcaster) so tests can
|
||||
// substitute a fake emitter.
|
||||
type UserTaskStore struct {
|
||||
db *sql.DB
|
||||
broadcaster events.EventEmitter
|
||||
}
|
||||
|
||||
// NewUserTaskStore binds the store to a DB pool + the platform broadcaster.
|
||||
func NewUserTaskStore(db *sql.DB, broadcaster events.EventEmitter) *UserTaskStore {
|
||||
return &UserTaskStore{db: db, broadcaster: broadcaster}
|
||||
}
|
||||
|
||||
// Create inserts a new pending user task and broadcasts USER_TASK_REQUESTED.
|
||||
// detail is optional — pass "" to leave it NULL. Returns the new task id.
|
||||
func (s *UserTaskStore) Create(ctx context.Context, workspaceID, title, detail string) (string, error) {
|
||||
var detailArg interface{}
|
||||
if detail != "" {
|
||||
detailArg = detail
|
||||
}
|
||||
|
||||
var taskID string
|
||||
err := s.db.QueryRowContext(ctx, `
|
||||
INSERT INTO user_tasks (workspace_id, title, detail)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, workspaceID, title, detailArg).Scan(&taskID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("user_task: create: %w", err)
|
||||
}
|
||||
|
||||
if err := s.broadcaster.RecordAndBroadcast(ctx, string(events.EventUserTaskRequested), workspaceID, map[string]interface{}{
|
||||
"user_task_id": taskID,
|
||||
"title": title,
|
||||
}); err != nil {
|
||||
log.Printf("user_task: failed to broadcast requested for %s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
return taskID, nil
|
||||
}
|
||||
|
||||
// List returns the asks a workspace itself raised, any status, newest first.
|
||||
func (s *UserTaskStore) List(ctx context.Context, workspaceID string) ([]UserTaskRow, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `
|
||||
SELECT id, title, detail, status, created_at, resolved_at, resolved_by
|
||||
FROM user_tasks WHERE workspace_id = $1
|
||||
ORDER BY created_at DESC LIMIT 50
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("user_task: list: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tasks := make([]UserTaskRow, 0)
|
||||
for rows.Next() {
|
||||
var t UserTaskRow
|
||||
if rows.Scan(&t.ID, &t.Title, &t.Detail, &t.Status, &t.CreatedAt, &t.ResolvedAt, &t.ResolvedBy) != nil {
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("user_task: list rows.Err workspace=%s: %v", workspaceID, err)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// Update applies a partial edit (title / detail / status — nil leaves a column
|
||||
// untouched via COALESCE), scoped by workspace_id so an agent only touches its
|
||||
// own tasks. Returns ErrInvalidUserTaskStatus on a bad status and
|
||||
// ErrUserTaskNotFound when no row matches.
|
||||
func (s *UserTaskStore) Update(ctx context.Context, workspaceID, taskID string, title, detail, status *string) error {
|
||||
if err := validateUserTaskStatusPtr(status); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE user_tasks SET
|
||||
title = COALESCE($1, title),
|
||||
detail = COALESCE($2, detail),
|
||||
status = COALESCE($3, status)
|
||||
WHERE id = $4 AND workspace_id = $5
|
||||
`, title, detail, status, taskID, workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user_task: update: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("user_task: update RowsAffected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrUserTaskNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes a workspace's own task, scoped by workspace_id. Returns
|
||||
// ErrUserTaskNotFound when no row matches.
|
||||
func (s *UserTaskStore) Delete(ctx context.Context, workspaceID, taskID string) error {
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
DELETE FROM user_tasks WHERE id = $1 AND workspace_id = $2
|
||||
`, taskID, workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("user_task: delete: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("user_task: delete RowsAffected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrUserTaskNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve marks a pending task done or dismissed (the user worklist action)
|
||||
// and broadcasts USER_TASK_RESOLVED. status MUST be "done" or "dismissed"
|
||||
// (the resolve enum is narrower than Update's). resolvedBy defaults to "human"
|
||||
// when empty. Returns ErrInvalidUserTaskStatus on a bad status and
|
||||
// ErrUserTaskNotFound when the task is missing or already resolved.
|
||||
func (s *UserTaskStore) Resolve(ctx context.Context, workspaceID, taskID, status, resolvedBy string) (string, error) {
|
||||
if status != "done" && status != "dismissed" {
|
||||
return "", ErrInvalidUserTaskStatus
|
||||
}
|
||||
if resolvedBy == "" {
|
||||
resolvedBy = "human"
|
||||
}
|
||||
|
||||
result, err := s.db.ExecContext(ctx, `
|
||||
UPDATE user_tasks
|
||||
SET status = $1, resolved_at = now(), resolved_by = $2
|
||||
WHERE id = $3 AND workspace_id = $4 AND status = 'pending'
|
||||
`, status, resolvedBy, taskID, workspaceID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("user_task: resolve: %w", err)
|
||||
}
|
||||
n, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("user_task: resolve RowsAffected: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return "", ErrUserTaskNotFound
|
||||
}
|
||||
|
||||
if err := s.broadcaster.RecordAndBroadcast(ctx, string(events.EventUserTaskResolved), workspaceID, map[string]interface{}{
|
||||
"user_task_id": taskID,
|
||||
"status": status,
|
||||
"resolved_by": resolvedBy,
|
||||
}); err != nil {
|
||||
log.Printf("user_task: failed to broadcast resolved for %s: %v", workspaceID, err)
|
||||
}
|
||||
|
||||
return resolvedBy, nil
|
||||
}
|
||||
|
||||
// validateUserTaskStatusPtr enforces the pending/done/dismissed enum for a
|
||||
// nil-able status (Update semantics: nil = "don't change", so skip the check).
|
||||
func validateUserTaskStatusPtr(status *string) error {
|
||||
if status == nil {
|
||||
return nil
|
||||
}
|
||||
switch *status {
|
||||
case "pending", "done", "dismissed":
|
||||
return nil
|
||||
default:
|
||||
return ErrInvalidUserTaskStatus
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/db"
|
||||
"git.moleculesai.app/molecule-ai/molecule-core/workspace-server/internal/events"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// UserTasksHandler serves the "user tasks" primitive — structured asks an
|
||||
// agent raises for the human user (e.g. "Review the draft", "Provide the API
|
||||
// key"). It mirrors ApprovalsHandler but resolving a task has no enforcement
|
||||
// effect; it is a worklist signal. See docs/design/rfc-user-tasks.md.
|
||||
type UserTasksHandler struct {
|
||||
broadcaster *events.Broadcaster
|
||||
}
|
||||
|
||||
// --- OpenAPI doc shapes (used by swaggo; the handlers emit gin.H inline) ---
|
||||
|
||||
// CreateUserTaskRequest is the body of POST /workspaces/{id}/user-tasks.
|
||||
type CreateUserTaskRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
// CreateUserTaskResponse is returned by POST /workspaces/{id}/user-tasks.
|
||||
type CreateUserTaskResponse struct {
|
||||
UserTaskID string `json:"user_task_id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// ResolveUserTaskRequest is the body of
|
||||
// POST /workspaces/{id}/user-tasks/{taskId}/resolve.
|
||||
type ResolveUserTaskRequest struct {
|
||||
Status string `json:"status" binding:"required" enums:"done,dismissed"`
|
||||
ResolvedBy string `json:"resolved_by"`
|
||||
}
|
||||
|
||||
// ResolveUserTaskResponse is returned by the resolve endpoint.
|
||||
type ResolveUserTaskResponse struct {
|
||||
Status string `json:"status"`
|
||||
UserTaskID string `json:"user_task_id"`
|
||||
}
|
||||
|
||||
// UpdateUserTaskRequest is the body of
|
||||
// PATCH /workspaces/{id}/user-tasks/{taskId}. All fields are optional;
|
||||
// only provided keys are updated (COALESCE).
|
||||
type UpdateUserTaskRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Detail *string `json:"detail"`
|
||||
Status *string `json:"status" enums:"pending,done,dismissed"`
|
||||
}
|
||||
|
||||
// UserTaskMutationResponse is the {status, user_task_id} echo returned by
|
||||
// the update and delete endpoints.
|
||||
type UserTaskMutationResponse struct {
|
||||
Status string `json:"status"`
|
||||
UserTaskID string `json:"user_task_id"`
|
||||
}
|
||||
|
||||
// UserTask is a single ask a workspace raised, as returned by
|
||||
// GET /workspaces/{id}/user-tasks. detail/resolved_at/resolved_by are
|
||||
// null until the task is resolved.
|
||||
type UserTask struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Detail *string `json:"detail"`
|
||||
Status string `json:"status" enums:"pending,done,dismissed"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
ResolvedAt *string `json:"resolved_at"`
|
||||
ResolvedBy *string `json:"resolved_by"`
|
||||
}
|
||||
|
||||
// PendingUserTask is one row of the cross-workspace pending list returned by
|
||||
// GET /user-tasks/pending (joined with the workspace name).
|
||||
type PendingUserTask struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
WorkspaceName string `json:"workspace_name"`
|
||||
Title string `json:"title"`
|
||||
Detail *string `json:"detail"`
|
||||
Status string `json:"status" enums:"pending"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func NewUserTasksHandler(b *events.Broadcaster) *UserTasksHandler {
|
||||
return &UserTasksHandler{broadcaster: b}
|
||||
}
|
||||
|
||||
// store builds a UserTaskStore over the live global db.DB per request.
|
||||
// Constructed per call (rather than cached on the handler) so the global-DB
|
||||
// swap the test harness performs in setupTestDB is observed — the same shape
|
||||
// AgentMessageWriter is used with in activity.go.
|
||||
func (h *UserTasksHandler) store() *UserTaskStore {
|
||||
return NewUserTaskStore(db.DB, h.broadcaster)
|
||||
}
|
||||
|
||||
// Create handles POST /workspaces/:id/user-tasks — an agent raises an ask.
|
||||
//
|
||||
// @Summary Raise a user task
|
||||
// @Tags user-tasks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param body body CreateUserTaskRequest true "Task fields"
|
||||
// @Success 201 {object} CreateUserTaskResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/user-tasks [post]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *UserTasksHandler) Create(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var body struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
taskID, err := h.store().Create(ctx, workspaceID, body.Title, body.Detail)
|
||||
if err != nil {
|
||||
log.Printf("Create user task error workspace=%s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user task"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"user_task_id": taskID, "status": "pending"})
|
||||
}
|
||||
|
||||
// ListAll handles GET /user-tasks/pending — all pending asks across the org
|
||||
// (for the concierge Tasks tab). Cross-workspace, so AdminAuth-gated.
|
||||
//
|
||||
// @Summary List pending user tasks across all workspaces
|
||||
// @Tags user-tasks
|
||||
// @Produce json
|
||||
// @Success 200 {array} PendingUserTask
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /user-tasks/pending [get]
|
||||
// @Security BearerAuth
|
||||
func (h *UserTasksHandler) ListAll(c *gin.Context) {
|
||||
ctx := c.Request.Context()
|
||||
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT t.id, t.workspace_id, w.name, t.title, t.detail, t.status, t.created_at
|
||||
FROM user_tasks t
|
||||
JOIN workspaces w ON w.id = t.workspace_id
|
||||
WHERE t.status = 'pending'
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT 50
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
tasks := make([]map[string]interface{}, 0)
|
||||
for rows.Next() {
|
||||
var id, wsID, wsName, title, status, createdAt string
|
||||
var detail *string
|
||||
if rows.Scan(&id, &wsID, &wsName, &title, &detail, &status, &createdAt) != nil {
|
||||
continue
|
||||
}
|
||||
tasks = append(tasks, map[string]interface{}{
|
||||
"id": id,
|
||||
"workspace_id": wsID,
|
||||
"workspace_name": wsName,
|
||||
"title": title,
|
||||
"detail": detail,
|
||||
"status": status,
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListAll user tasks rows.Err: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
// Resolve handles POST /workspaces/:id/user-tasks/:taskId/resolve — the user
|
||||
// marks an ask done or dismissed.
|
||||
//
|
||||
// @Summary Resolve a user task
|
||||
// @Tags user-tasks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param taskId path string true "User task ID"
|
||||
// @Param body body ResolveUserTaskRequest true "Resolution"
|
||||
// @Success 200 {object} ResolveUserTaskResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/user-tasks/{taskId}/resolve [post]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *UserTasksHandler) Resolve(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
taskID := c.Param("taskId")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var body struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
ResolvedBy string `json:"resolved_by"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if body.Status != "done" && body.Status != "dismissed" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be 'done' or 'dismissed'"})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := h.store().Resolve(ctx, workspaceID, taskID, body.Status, body.ResolvedBy); err != nil {
|
||||
if errors.Is(err, ErrUserTaskNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user task not found or already resolved"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrInvalidUserTaskStatus) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be 'done' or 'dismissed'"})
|
||||
return
|
||||
}
|
||||
log.Printf("User task resolve error task=%s workspace=%s: %v", taskID, workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": body.Status, "user_task_id": taskID})
|
||||
}
|
||||
|
||||
// List handles GET /workspaces/:id/user-tasks — the asks a workspace itself
|
||||
// raised (any status). Lets an agent read back its own created tasks.
|
||||
//
|
||||
// @Summary List a workspace's own user tasks
|
||||
// @Tags user-tasks
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Success 200 {array} UserTask
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/user-tasks [get]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *UserTasksHandler) List(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
tasks, err := h.store().List(ctx, workspaceID)
|
||||
if err != nil {
|
||||
log.Printf("List user tasks error workspace=%s: %v", workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, tasks)
|
||||
}
|
||||
|
||||
// Update handles PATCH /workspaces/:id/user-tasks/:taskId — a workspace edits
|
||||
// its own ask (title / detail / status). The workspace_id scope means an
|
||||
// agent can only touch tasks it raised. Fields are optional (COALESCE).
|
||||
//
|
||||
// @Summary Update a workspace's own user task
|
||||
// @Tags user-tasks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param taskId path string true "User task ID"
|
||||
// @Param body body UpdateUserTaskRequest true "Partial task fields (only provided keys are updated)"
|
||||
// @Success 200 {object} UserTaskMutationResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/user-tasks/{taskId} [patch]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *UserTasksHandler) Update(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
taskID := c.Param("taskId")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var body struct {
|
||||
Title *string `json:"title"`
|
||||
Detail *string `json:"detail"`
|
||||
Status *string `json:"status"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store().Update(ctx, workspaceID, taskID, body.Title, body.Detail, body.Status); err != nil {
|
||||
if errors.Is(err, ErrInvalidUserTaskStatus) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "status must be 'pending', 'done' or 'dismissed'"})
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrUserTaskNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user task not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("User task update error task=%s workspace=%s: %v", taskID, workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "updated", "user_task_id": taskID})
|
||||
}
|
||||
|
||||
// Delete handles DELETE /workspaces/:id/user-tasks/:taskId — a workspace
|
||||
// removes its own ask. Scoped by workspace_id so agents can only delete
|
||||
// tasks they raised.
|
||||
//
|
||||
// @Summary Delete a workspace's own user task
|
||||
// @Tags user-tasks
|
||||
// @Produce json
|
||||
// @Param id path string true "Workspace ID"
|
||||
// @Param taskId path string true "User task ID"
|
||||
// @Success 200 {object} UserTaskMutationResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /workspaces/{id}/user-tasks/{taskId} [delete]
|
||||
// @Security BearerAuth && OrgSlugAuth
|
||||
func (h *UserTasksHandler) Delete(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
taskID := c.Param("taskId")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
if err := h.store().Delete(ctx, workspaceID, taskID); err != nil {
|
||||
if errors.Is(err, ErrUserTaskNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "user task not found"})
|
||||
return
|
||||
}
|
||||
log.Printf("User task delete error task=%s workspace=%s: %v", taskID, workspaceID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"status": "deleted", "user_task_id": taskID})
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ---------- UserTasksHandler: Create ----------
|
||||
|
||||
func TestUserTasks_Create_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewUserTasksHandler(broadcaster)
|
||||
|
||||
// Insert user_task → returns id
|
||||
mock.ExpectQuery("INSERT INTO user_tasks").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ut-1"))
|
||||
// RecordAndBroadcast for USER_TASK_REQUESTED
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
body := `{"title":"Review the launch draft","detail":"posts/launch.md"}`
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["user_task_id"] != "ut-1" {
|
||||
t.Errorf("expected user_task_id ut-1, got %v", resp["user_task_id"])
|
||||
}
|
||||
if resp["status"] != "pending" {
|
||||
t.Errorf("expected status 'pending', got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTasks_Create_MissingTitle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewUserTasksHandler(newTestBroadcaster())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"detail":"no title"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing title, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- UserTasksHandler: Resolve ----------
|
||||
|
||||
func TestUserTasks_Resolve_Done(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewUserTasksHandler(broadcaster)
|
||||
|
||||
// Update user_task → 1 row affected
|
||||
mock.ExpectExec("UPDATE user_tasks").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// RecordAndBroadcast for USER_TASK_RESOLVED
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "taskId", Value: "ut-1"}}
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"status":"done"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] != "done" {
|
||||
t.Errorf("expected status 'done', got %v", resp["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTasks_Resolve_InvalidStatus(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewUserTasksHandler(newTestBroadcaster())
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "taskId", Value: "ut-1"}}
|
||||
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(`{"status":"maybe"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for invalid status, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- UserTasksHandler: List / Update / Delete (workspace-owned) ----------
|
||||
|
||||
func TestUserTasks_List_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewUserTasksHandler(newTestBroadcaster())
|
||||
|
||||
mock.ExpectQuery("SELECT id, title, detail, status, created_at, resolved_at, resolved_by FROM user_tasks WHERE workspace_id").
|
||||
WithArgs("ws-1").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "title", "detail", "status", "created_at", "resolved_at", "resolved_by"}).
|
||||
AddRow("ut-1", "Review draft", nil, "pending", "2026-06-07T00:00:00Z", nil, nil))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}}
|
||||
c.Request = httptest.NewRequest("GET", "/", nil)
|
||||
|
||||
handler.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if len(resp) != 1 || resp[0]["id"] != "ut-1" {
|
||||
t.Errorf("expected one task ut-1, got %v", resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTasks_Update_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewUserTasksHandler(newTestBroadcaster())
|
||||
|
||||
mock.ExpectExec("UPDATE user_tasks SET").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "taskId", Value: "ut-1"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/", bytes.NewBufferString(`{"title":"Updated"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTasks_Update_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewUserTasksHandler(newTestBroadcaster())
|
||||
|
||||
mock.ExpectExec("UPDATE user_tasks SET").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "taskId", Value: "nope"}}
|
||||
c.Request = httptest.NewRequest("PATCH", "/", bytes.NewBufferString(`{"title":"x"}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Update(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserTasks_Delete_Success(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewUserTasksHandler(newTestBroadcaster())
|
||||
|
||||
mock.ExpectExec("DELETE FROM user_tasks").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-1"}, {Key: "taskId", Value: "ut-1"}}
|
||||
c.Request = httptest.NewRequest("DELETE", "/", nil)
|
||||
|
||||
handler.Delete(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -970,7 +970,7 @@ func (h *WorkspaceHandler) ProvisionTimeoutSecondsForRuntime(runtime string) int
|
||||
func scanWorkspaceRow(rows interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (map[string]interface{}, error) {
|
||||
var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir string
|
||||
var id, name, role, status, url, sampleError, currentTask, runtime, workspaceDir, kind string
|
||||
var computeRaw []byte
|
||||
var tier, activeTasks, maxConcurrentTasks, uptimeSeconds int
|
||||
var errorRate, x, y float64
|
||||
@@ -983,7 +983,7 @@ func scanWorkspaceRow(rows interface {
|
||||
err := rows.Scan(&id, &name, &role, &tier, &status, &agentCard, &url,
|
||||
&parentID, &activeTasks, &maxConcurrentTasks, &errorRate, &sampleError, &uptimeSeconds,
|
||||
¤tTask, &runtime, &workspaceDir, &x, &y, &collapsed,
|
||||
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled, &computeRaw)
|
||||
&budgetLimit, &monthlySpend, &broadcastEnabled, &talkToUserEnabled, &computeRaw, &kind)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -995,6 +995,11 @@ func scanWorkspaceRow(rows interface {
|
||||
"status": status,
|
||||
"url": url,
|
||||
"parent_id": parentID,
|
||||
// kind discriminates the org-level platform agent ('platform') from
|
||||
// ordinary workspaces ('workspace'). The canvas hides the platform
|
||||
// root from the node graph (it's the undeletable org anchor) and uses
|
||||
// it to resolve the concierge for the shell home/settings.
|
||||
"kind": kind,
|
||||
"active_tasks": activeTasks,
|
||||
"max_concurrent_tasks": maxConcurrentTasks,
|
||||
"last_error_rate": errorRate,
|
||||
@@ -1051,7 +1056,8 @@ const workspaceListQuery = `
|
||||
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
|
||||
w.budget_limit, COALESCE(w.monthly_spend, 0),
|
||||
w.broadcast_enabled, w.talk_to_user_enabled,
|
||||
COALESCE(w.compute, '{}'::jsonb)
|
||||
COALESCE(w.compute, '{}'::jsonb),
|
||||
COALESCE(w.kind, 'workspace')
|
||||
FROM workspaces w
|
||||
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
|
||||
WHERE w.status != 'removed'
|
||||
@@ -1113,7 +1119,8 @@ func (h *WorkspaceHandler) Get(c *gin.Context) {
|
||||
COALESCE(cl.x, 0), COALESCE(cl.y, 0), COALESCE(cl.collapsed, false),
|
||||
w.budget_limit, COALESCE(w.monthly_spend, 0),
|
||||
w.broadcast_enabled, w.talk_to_user_enabled,
|
||||
COALESCE(w.compute, '{}'::jsonb)
|
||||
COALESCE(w.compute, '{}'::jsonb),
|
||||
COALESCE(w.kind, 'workspace')
|
||||
FROM workspaces w
|
||||
LEFT JOIN canvas_layouts cl ON cl.workspace_id = w.id
|
||||
WHERE w.id = $1
|
||||
|
||||
@@ -33,7 +33,7 @@ var wsColumns = []string{
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
|
||||
// ==================== GET — financial fields stripped from open endpoint ====================
|
||||
@@ -57,7 +57,7 @@ func TestWorkspaceBudget_Get_NilLimit(t *testing.T) {
|
||||
0, // monthly_spend 0
|
||||
false, // broadcast_enabled
|
||||
true, // talk_to_user_enabled
|
||||
[]byte(`{}`)))
|
||||
[]byte(`{}`), "workspace"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -102,7 +102,7 @@ func TestWorkspaceBudget_Get_WithLimit(t *testing.T) {
|
||||
int64(500), // budget_limit = $5.00 in DB
|
||||
int64(123), // monthly_spend = $1.23 in DB
|
||||
false, true, // broadcast_enabled, talk_to_user_enabled
|
||||
[]byte(`{}`)))
|
||||
[]byte(`{}`), "workspace"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -302,8 +302,14 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
// present) wins, matching the existing WorkspaceDir precedence.
|
||||
workspacePath := payload.WorkspaceDir
|
||||
workspaceAccess := payload.WorkspaceAccess
|
||||
if (workspacePath == "" || workspaceAccess == "") && db.DB != nil {
|
||||
var dbDir, dbAccess string
|
||||
// kind drives the platform-agent image selection in the provisioner (a
|
||||
// kind='platform' concierge runs on the platform-agent image variant, which
|
||||
// bakes /opt/molecule-mcp-server so the org-admin MCP can load). Sourced from
|
||||
// the DB row (CreateWorkspacePayload carries no kind — the row is the SSOT,
|
||||
// written by InstallPlatformAgent / EnsureSelfHostedPlatformAgent).
|
||||
var kind string
|
||||
if db.DB != nil {
|
||||
var dbDir, dbAccess, dbKind string
|
||||
// QueryRowContext (not QueryRow) so the provision-timeout ctx
|
||||
// propagates here too. Previously ctx flowed in only to be passed
|
||||
// to resolveRuntimeImage; that dead reader was removed by
|
||||
@@ -312,15 +318,16 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
// nudge (a 10s ProvisionTimeout now actually bounds this lookup).
|
||||
if err := db.DB.QueryRowContext(
|
||||
ctx,
|
||||
`SELECT COALESCE(workspace_dir, ''), COALESCE(workspace_access, 'none') FROM workspaces WHERE id = $1`,
|
||||
`SELECT COALESCE(workspace_dir, ''), COALESCE(workspace_access, 'none'), COALESCE(kind, 'workspace') FROM workspaces WHERE id = $1`,
|
||||
workspaceID,
|
||||
).Scan(&dbDir, &dbAccess); err == nil {
|
||||
).Scan(&dbDir, &dbAccess, &dbKind); err == nil {
|
||||
if workspacePath == "" && dbDir != "" {
|
||||
workspacePath = dbDir
|
||||
}
|
||||
if workspaceAccess == "" {
|
||||
workspaceAccess = dbAccess
|
||||
}
|
||||
kind = dbKind
|
||||
}
|
||||
}
|
||||
if workspacePath == "" {
|
||||
@@ -337,6 +344,7 @@ func (h *WorkspaceHandler) buildProvisionerConfig(
|
||||
PluginsPath: pluginsPath,
|
||||
WorkspacePath: workspacePath,
|
||||
WorkspaceAccess: workspaceAccess,
|
||||
Kind: kind,
|
||||
Tier: payload.Tier,
|
||||
Runtime: payload.Runtime,
|
||||
InstanceType: payload.Compute.InstanceType,
|
||||
@@ -1298,6 +1306,25 @@ func firstNonEmptyEnv(names ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// PlatformManagedProxyConfigured reports whether a Molecule LLM proxy is wired
|
||||
// into THIS workspace-server process — i.e. whether the platform_managed billing
|
||||
// path can actually inject a usable credential. It is the SAME precondition the
|
||||
// strip gate enforces in applyPlatformManagedLLMEnv on the platform_managed
|
||||
// branch: a proxy base URL (MOLECULE_LLM_BASE_URL / OPENAI_BASE_URL) AND a proxy
|
||||
// usage token (MOLECULE_LLM_USAGE_TOKEN / OPENAI_API_KEY) must BOTH be present.
|
||||
//
|
||||
// On a SELF-HOSTED stack neither is set (there is no hosted Molecule proxy and
|
||||
// no org credit ledger), so this returns false and platform_managed cannot work.
|
||||
// The open GET /org/identity handler surfaces this as platform_managed_available
|
||||
// so the canvas can hide the "Platform (proxy)" option and default to BYOK.
|
||||
// On SaaS the CP provisioner exports both, so it returns true and the canvas
|
||||
// behaves exactly as before.
|
||||
func PlatformManagedProxyConfigured() bool {
|
||||
baseURL := firstNonEmptyEnv("MOLECULE_LLM_BASE_URL", "OPENAI_BASE_URL")
|
||||
token := firstNonEmptyEnv("MOLECULE_LLM_USAGE_TOKEN", "OPENAI_API_KEY")
|
||||
return baseURL != "" && token != ""
|
||||
}
|
||||
|
||||
// loadWorkspaceSecrets loads global + workspace-specific secrets into a map.
|
||||
// Returns nil map + error string on decrypt failure. Shared by both Docker
|
||||
// and control plane provisioning paths to avoid duplication.
|
||||
|
||||
@@ -261,6 +261,15 @@ func (h *WorkspaceHandler) prepareProvisionContext(
|
||||
return nil, &provisionAbort{Msg: "plugin env mutator chain failed"}
|
||||
}
|
||||
|
||||
// Concierge identity (RFC docs/design/rfc-platform-agent.md): when this
|
||||
// workspace is the org platform agent (kind='platform'), overlay the
|
||||
// Org-Concierge system prompt + the platform-MCP declaration and inject the
|
||||
// org-admin MCP env. No-op for ordinary workspaces. Runs BEFORE the
|
||||
// required-env preflight so a concierge config.yaml that the overlay just
|
||||
// wrote is the one preflight inspects. Rebinds configFiles because it is nil
|
||||
// on the auto-restart path (where the overlay is what introduces the files).
|
||||
configFiles = h.applyConciergeProvisionConfig(ctx, workspaceID, templatePath, configFiles, envVars, payload.Name)
|
||||
|
||||
// Preflight #5: refuse to launch when config.yaml declares required
|
||||
// env vars that are not set. Skipped in SaaS mode when configFiles
|
||||
// is nil (CP-mode's cfg is built without local config bytes — the
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("cccccccc-0001-0000-0000-000000000000").
|
||||
@@ -38,7 +38,7 @@ func TestWorkspaceGet_Success(t *testing.T) {
|
||||
AddRow("cccccccc-0001-0000-0000-000000000000", "My Agent", "worker", 1, "online", []byte(`{"name":"test"}`),
|
||||
"http://localhost:8001", nil, 2, 1, 0.05, "", 3600, "working", "claude-code",
|
||||
"", 10.0, 20.0, false,
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
nil, 0, false, true, []byte(`{}`), "workspace"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -120,7 +120,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -128,7 +128,7 @@ func TestWorkspaceGet_RemovedReturns410(t *testing.T) {
|
||||
AddRow(id, "Old Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "claude-code",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
nil, 0, false, true, []byte(`{}`), "workspace"))
|
||||
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"updated_at"}).AddRow(removedAt))
|
||||
@@ -184,7 +184,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -192,7 +192,7 @@ func TestWorkspaceGet_RemovedReturns410WithNullRemovedAtOnTimestampFetchFailure(
|
||||
AddRow(id, "Vanished", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "claude-code",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
nil, 0, false, true, []byte(`{}`), "workspace"))
|
||||
// Simulate the row vanishing between the two queries.
|
||||
mock.ExpectQuery(`SELECT updated_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
@@ -247,7 +247,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs(id).
|
||||
@@ -255,7 +255,7 @@ func TestWorkspaceGet_RemovedWithIncludeQueryReturns200(t *testing.T) {
|
||||
AddRow(id, "Audit Agent", "worker", 1, string(models.StatusRemoved), []byte(`null`),
|
||||
"", nil, 0, 1, 0.0, "", 0, "", "claude-code",
|
||||
"", 0.0, 0.0, false,
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
nil, 0, false, true, []byte(`{}`), "workspace"))
|
||||
// last_outbound_at follow-up query (existing path)
|
||||
mock.ExpectQuery(`SELECT last_outbound_at FROM workspaces`).
|
||||
WithArgs(id).
|
||||
@@ -832,7 +832,7 @@ func TestWorkspaceList_Empty(t *testing.T) {
|
||||
"parent_id", "active_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1593,7 +1593,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
// Populate with non-zero financial values to confirm they are stripped.
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
@@ -1602,7 +1602,7 @@ func TestWorkspaceGet_FinancialFieldsStripped(t *testing.T) {
|
||||
AddRow("cccccccc-0010-0000-0000-000000000000", "Finance Test", "worker", 1, "online", []byte(`{}`),
|
||||
"http://localhost:9001", nil, 0, 1, 0.0, "", 0, "", "claude-code",
|
||||
"", 0.0, 0.0, false,
|
||||
int64(50000), int64(12500), false, true, []byte(`{}`))) // budget_limit=500 USD, spend=125 USD
|
||||
int64(50000), int64(12500), false, true, []byte(`{}`), "workspace")) // budget_limit=500 USD, spend=125 USD
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -1650,7 +1650,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
|
||||
"parent_id", "active_tasks", "max_concurrent_tasks", "last_error_rate", "last_sample_error",
|
||||
"uptime_seconds", "current_task", "runtime", "workspace_dir", "x", "y", "collapsed",
|
||||
"budget_limit", "monthly_spend",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute",
|
||||
"broadcast_enabled", "talk_to_user_enabled", "compute", "kind",
|
||||
}
|
||||
mock.ExpectQuery("SELECT w.id, w.name").
|
||||
WithArgs("cccccccc-0955-0000-0000-000000000000").
|
||||
@@ -1663,7 +1663,7 @@ func TestWorkspaceGet_SensitiveFieldsStripped(t *testing.T) {
|
||||
"claude-code",
|
||||
"/home/user/secret-projects/client-work",
|
||||
0.0, 0.0, false,
|
||||
nil, 0, false, true, []byte(`{}`)))
|
||||
nil, 0, false, true, []byte(`{}`), "workspace"))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
@@ -163,6 +163,50 @@ func LocalImageLatestTag(runtime string) string {
|
||||
return fmt.Sprintf("%s/workspace-template-%s:latest", localImagePrefix, runtime)
|
||||
}
|
||||
|
||||
// platformAgentImageSuffix names the dedicated platform-agent image variant
|
||||
// (the plain runtime image + a baked /opt/molecule-mcp-server so the org-admin
|
||||
// platform MCP can load). The local image tag is
|
||||
// `molecule-local/workspace-template-<runtime>-platform-agent:<tag>`, built from
|
||||
// workspace-configs-templates/claude-code-default/Dockerfile.platform-agent.
|
||||
const platformAgentImageSuffix = "-platform-agent"
|
||||
|
||||
// LocalPlatformAgentLatestTag returns the floating `:latest` tag for the
|
||||
// platform-agent image variant of a runtime in local-build mode. This is the
|
||||
// image the local Docker provisioner prefers for a kind='platform' workspace
|
||||
// (the org concierge) so the platform MCP binary is present.
|
||||
func LocalPlatformAgentLatestTag(runtime string) string {
|
||||
return fmt.Sprintf("%s/workspace-template-%s%s:latest", localImagePrefix, runtime, platformAgentImageSuffix)
|
||||
}
|
||||
|
||||
// resolvePlatformAgentImage returns the platform-agent image variant to use for
|
||||
// a kind='platform' workspace, or ("", false) when no such image is available
|
||||
// (so the caller falls back to the plain runtime image). It is deliberately
|
||||
// gated on the image already being present in the local store: the
|
||||
// platform-agent image is built out-of-band (Dockerfile.platform-agent), not by
|
||||
// the runtime template repo's local-build clone, so we never try to build it
|
||||
// here — we only USE it if an operator has built+tagged it.
|
||||
//
|
||||
// fallbackImage is the plain runtime image the caller already resolved; it is
|
||||
// only used to keep the log line informative. hasTagFn is the docker
|
||||
// image-inspect probe (seam for tests).
|
||||
func resolvePlatformAgentImage(ctx context.Context, runtime, fallbackImage string, hasTagFn func(ctx context.Context, tag string) (bool, error)) (string, bool) {
|
||||
tag := LocalPlatformAgentLatestTag(runtime)
|
||||
if hasTagFn == nil {
|
||||
hasTagFn = dockerHasTagProd
|
||||
}
|
||||
exists, err := hasTagFn(ctx, tag)
|
||||
if err != nil {
|
||||
log.Printf("local-build: platform-agent image probe for %s failed (%v); falling back to plain runtime image %s — the concierge's platform MCP will be skipped (build %s via Dockerfile.platform-agent to enable it)", tag, err, fallbackImage, tag)
|
||||
return "", false
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("local-build: platform-agent image %s not present; falling back to plain runtime image %s — the concierge's platform MCP will be skipped (build %s via Dockerfile.platform-agent to enable it)", tag, fallbackImage, tag)
|
||||
return "", false
|
||||
}
|
||||
log.Printf("local-build: kind=platform → using platform-agent image %s (bakes /opt/molecule-mcp-server)", tag)
|
||||
return tag, true
|
||||
}
|
||||
|
||||
// EnsureLocalImage is the entry point the provisioner calls before
|
||||
// ContainerCreate when Resolve().Mode == RegistryModeLocal. Returns the
|
||||
// image tag (SHA-pinned form) the caller should hand to Docker, or an
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user