Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c4f78fb2b0 |
@@ -29,13 +29,6 @@ Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn):
|
||||
or `https://github.com/.../releases/download` without a
|
||||
workflow-level `env.GITHUB_SERVER_URL` set to the Gitea instance.
|
||||
Memory: feedback_act_runner_github_server_url.
|
||||
7. Production deploy/redeploy workflows may not rely on Gitea
|
||||
`concurrency.cancel-in-progress: false` for serialization. Gitea
|
||||
1.22.6 can cancel queued runs despite that setting.
|
||||
8. Production deploy/redeploy workflows may not dump raw CP responses or
|
||||
raw `.error` fields into CI logs/summaries.
|
||||
9. Production deploy/redeploy workflows must expose an operational control:
|
||||
kill switch for auto deploys or rollback tag for manual deploys.
|
||||
|
||||
Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to
|
||||
validate this lint must mirror real Gitea 1.22.6 YAML semantics, not
|
||||
@@ -262,19 +255,6 @@ GITHUB_API_REF_RE = re.compile(
|
||||
)
|
||||
|
||||
|
||||
PROD_CP_URL_RE = re.compile(r"https://api\.moleculesai\.app\b")
|
||||
REDEPLOY_FLEET_RE = re.compile(r"\b/cp/admin/tenants/redeploy-fleet\b")
|
||||
RAW_CP_RESPONSE_RE = re.compile(
|
||||
r"""(?x)
|
||||
(?:\bjq\s+\.\s+["']?\$HTTP_RESPONSE["']?)
|
||||
|
|
||||
(?:\bcat\s+["']?\$HTTP_RESPONSE["']?)
|
||||
|
|
||||
(?:\|\s*\.error\b)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def _has_workflow_level_server_url(doc: Any) -> bool:
|
||||
if not isinstance(doc, dict):
|
||||
return False
|
||||
@@ -306,83 +286,6 @@ def check_github_server_url_missing(filename: str, doc: Any, raw: str) -> list[s
|
||||
return warns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 7-9 — production CI/CD hardening rules
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _is_production_redeploy_workflow(raw: str) -> bool:
|
||||
"""Heuristic production-side-effect detector.
|
||||
|
||||
We intentionally key on the production CP host plus the redeploy-fleet
|
||||
endpoint. Staging workflows call the same endpoint on staging-api and are
|
||||
governed by looser staging verification policy.
|
||||
"""
|
||||
|
||||
return bool(PROD_CP_URL_RE.search(raw) and REDEPLOY_FLEET_RE.search(raw))
|
||||
|
||||
|
||||
def _iter_concurrency_blocks(doc: Any) -> Iterable[dict[str, Any]]:
|
||||
if not isinstance(doc, dict):
|
||||
return
|
||||
top = doc.get("concurrency")
|
||||
if isinstance(top, dict):
|
||||
yield top
|
||||
jobs = doc.get("jobs")
|
||||
if not isinstance(jobs, dict):
|
||||
return
|
||||
for job in jobs.values():
|
||||
if isinstance(job, dict) and isinstance(job.get("concurrency"), dict):
|
||||
yield job["concurrency"]
|
||||
|
||||
|
||||
def check_production_concurrency(filename: str, doc: Any, raw: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not _is_production_redeploy_workflow(raw):
|
||||
return errors
|
||||
for block in _iter_concurrency_blocks(doc):
|
||||
if block.get("cancel-in-progress") is False:
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 7 (FATAL): production deploy "
|
||||
f"workflow uses `concurrency.cancel-in-progress: false`. "
|
||||
f"Gitea 1.22.6 can cancel queued runs despite that setting, "
|
||||
f"so this is not a safe production serialization primitive. "
|
||||
f"Use an external queue/lock or make the deploy idempotent."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def check_production_raw_response_logging(filename: str, raw: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not _is_production_redeploy_workflow(raw):
|
||||
return errors
|
||||
if RAW_CP_RESPONSE_RE.search(raw):
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 8 (FATAL): production deploy "
|
||||
f"workflow appears to print a raw production CP response or raw "
|
||||
f"`.error` field. CI logs are persistent and broad-read. Redact "
|
||||
f"runtime/SSM error details; print counts, booleans, status "
|
||||
f"codes, and links to restricted observability instead."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def check_production_operational_control(filename: str, raw: str) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not _is_production_redeploy_workflow(raw):
|
||||
return errors
|
||||
has_kill_switch = "PROD_AUTO_DEPLOY_DISABLED" in raw
|
||||
has_rollback = "PROD_MANUAL_REDEPLOY_TARGET_TAG" in raw
|
||||
if not (has_kill_switch or has_rollback):
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 9 (FATAL): production deploy "
|
||||
f"workflow calls redeploy-fleet without an operational control. "
|
||||
f"Auto deploys need a `PROD_AUTO_DEPLOY_DISABLED` kill switch; "
|
||||
f"manual deploys need a `PROD_MANUAL_REDEPLOY_TARGET_TAG` "
|
||||
f"rollback/pin path."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -433,9 +336,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
fatal_errors.extend(check_workflow_run_event(rel, doc))
|
||||
fatal_errors.extend(check_name_with_slash(rel, doc))
|
||||
fatal_errors.extend(check_cross_repo_uses(rel, doc))
|
||||
fatal_errors.extend(check_production_concurrency(rel, doc, raw))
|
||||
fatal_errors.extend(check_production_raw_response_logging(rel, raw))
|
||||
fatal_errors.extend(check_production_operational_control(rel, raw))
|
||||
warnings.extend(check_github_server_url_missing(rel, doc, raw))
|
||||
|
||||
# Cross-file checks
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Production auto-deploy helpers for Gitea Actions.
|
||||
|
||||
The workflow keeps network side effects in shell/curl, but centralizes the
|
||||
release decision shape here so it has unit coverage: disable flag parsing,
|
||||
target tag selection, CP payload construction, and status-context selection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
|
||||
PROD_CP_URL = "https://api.moleculesai.app"
|
||||
DEFAULT_REQUIRED_CONTEXTS = [
|
||||
"CI / Platform (Go) (push)",
|
||||
"CI / Canvas (Next.js) (push)",
|
||||
"CI / Shellcheck (E2E scripts) (push)",
|
||||
"CI / Python Lint & Test (push)",
|
||||
"CI / all-required (push)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (push)",
|
||||
]
|
||||
TERMINAL_FAILURE_STATES = {"failure", "error", "cancelled", "canceled", "skipped"}
|
||||
|
||||
|
||||
def truthy_flag(value: str | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
return value.strip().lower() in TRUE_VALUES
|
||||
|
||||
|
||||
def _int_env(env: dict[str, str], name: str, default: int, minimum: int = 1) -> int:
|
||||
raw = env.get(name, "")
|
||||
if not raw:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"{name} must be an integer, got {raw!r}") from exc
|
||||
if value < minimum:
|
||||
raise ValueError(f"{name} must be >= {minimum}, got {value}")
|
||||
return value
|
||||
|
||||
|
||||
def build_plan(env: dict[str, str]) -> dict:
|
||||
sha = env.get("GITHUB_SHA", "").strip()
|
||||
if not sha:
|
||||
raise ValueError("GITHUB_SHA is required")
|
||||
|
||||
disabled_value = env.get("PROD_AUTO_DEPLOY_DISABLED", "")
|
||||
if truthy_flag(disabled_value):
|
||||
return {
|
||||
"enabled": False,
|
||||
"sha": sha,
|
||||
"disabled_reason": f"PROD_AUTO_DEPLOY_DISABLED={disabled_value}",
|
||||
}
|
||||
|
||||
short_sha = sha[:7]
|
||||
target_tag = env.get("PROD_AUTO_DEPLOY_TARGET_TAG", "").strip() or f"staging-{short_sha}"
|
||||
canary_slug = env.get("PROD_AUTO_DEPLOY_CANARY_SLUG", "hongming").strip()
|
||||
body = {
|
||||
"target_tag": target_tag,
|
||||
"soak_seconds": _int_env(env, "PROD_AUTO_DEPLOY_SOAK_SECONDS", 60, minimum=0),
|
||||
"batch_size": _int_env(env, "PROD_AUTO_DEPLOY_BATCH_SIZE", 3),
|
||||
"dry_run": truthy_flag(env.get("PROD_AUTO_DEPLOY_DRY_RUN", "")),
|
||||
}
|
||||
if canary_slug:
|
||||
body["canary_slug"] = canary_slug
|
||||
|
||||
cp_url = env.get("CP_URL", "").strip() or PROD_CP_URL
|
||||
if cp_url != PROD_CP_URL and not truthy_flag(env.get("PROD_ALLOW_NON_PROD_CP_URL", "")):
|
||||
raise ValueError(
|
||||
f"Refusing production deploy to CP_URL={cp_url!r}; "
|
||||
f"set PROD_ALLOW_NON_PROD_CP_URL=true for an explicit non-prod drill"
|
||||
)
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"sha": sha,
|
||||
"short_sha": short_sha,
|
||||
"target_tag": target_tag,
|
||||
"cp_url": cp_url,
|
||||
"body": body,
|
||||
}
|
||||
|
||||
|
||||
def latest_status_for_context(statuses: list[dict], context: str) -> dict | None:
|
||||
"""Return the first matching status.
|
||||
|
||||
Gitea's combined-status response is newest-first in practice. The merge
|
||||
queue relies on the same contract; keeping the selector explicit makes
|
||||
stale duplicate contexts easy to test.
|
||||
"""
|
||||
|
||||
for status in statuses:
|
||||
if status.get("context") == context:
|
||||
return status
|
||||
return None
|
||||
|
||||
|
||||
def ci_context_state(statuses: list[dict], context: str) -> str:
|
||||
status = latest_status_for_context(statuses, context)
|
||||
if not status:
|
||||
return "missing"
|
||||
return str(status.get("status") or status.get("state") or "missing").lower()
|
||||
|
||||
|
||||
def context_is_satisfied(state: str) -> bool:
|
||||
return state == "success"
|
||||
|
||||
|
||||
def context_is_terminal_failure(state: str) -> bool:
|
||||
return state in TERMINAL_FAILURE_STATES
|
||||
|
||||
|
||||
def required_contexts(env: dict[str, str]) -> list[str]:
|
||||
raw = env.get("PROD_AUTO_DEPLOY_REQUIRED_CONTEXTS", "")
|
||||
if not raw.strip():
|
||||
return DEFAULT_REQUIRED_CONTEXTS
|
||||
return [line.strip() for line in raw.replace(",", "\n").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _api_json(url: str, token: str) -> dict:
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read().decode("utf-8", errors="replace")[:500]
|
||||
raise RuntimeError(f"GET {url} -> HTTP {exc.code}: {body}") from exc
|
||||
|
||||
|
||||
def _api_json_optional(url: str, token: str) -> tuple[int, dict | None]:
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return exc.code, None
|
||||
body = exc.read().decode("utf-8", errors="replace")[:300]
|
||||
print(f"::warning::GET {url} -> HTTP {exc.code}: {body}", file=sys.stderr)
|
||||
return exc.code, None
|
||||
|
||||
|
||||
def live_disable_flag(env: dict[str, str]) -> str:
|
||||
"""Return a live disable value from Gitea variables when readable.
|
||||
|
||||
Gitea evaluates `${{ vars.* }}` once when the job starts. This API read is
|
||||
the emergency re-check immediately before production side effects.
|
||||
"""
|
||||
|
||||
token = env.get("GITEA_TOKEN", "").strip()
|
||||
if not token:
|
||||
return ""
|
||||
host = env.get("GITEA_HOST", "git.moleculesai.app")
|
||||
repo = env.get("GITHUB_REPOSITORY", "molecule-ai/molecule-core")
|
||||
variable = quote("PROD_AUTO_DEPLOY_DISABLED", safe="")
|
||||
url = f"https://{host}/api/v1/repos/{repo}/actions/variables/{variable}"
|
||||
status, body = _api_json_optional(url, token)
|
||||
if status != 200 or not isinstance(body, dict):
|
||||
return ""
|
||||
return str(body.get("data") or body.get("value") or "")
|
||||
|
||||
|
||||
def assert_not_disabled(env: dict[str, str]) -> None:
|
||||
plan = build_plan(env)
|
||||
if not plan.get("enabled"):
|
||||
raise RuntimeError(plan.get("disabled_reason", "production auto-deploy disabled"))
|
||||
live_value = live_disable_flag(env)
|
||||
if truthy_flag(live_value):
|
||||
raise RuntimeError(f"PROD_AUTO_DEPLOY_DISABLED={live_value} (live Gitea variable)")
|
||||
|
||||
|
||||
def wait_for_ci_context(env: dict[str, str]) -> str:
|
||||
host = env.get("GITEA_HOST", "git.moleculesai.app")
|
||||
repo = env.get("GITHUB_REPOSITORY", "molecule-ai/molecule-core")
|
||||
sha = env.get("GITHUB_SHA", "").strip()
|
||||
token = env.get("GITEA_TOKEN", "").strip()
|
||||
contexts = required_contexts(env)
|
||||
interval = _int_env(env, "CI_STATUS_POLL_INTERVAL_SECONDS", 15)
|
||||
timeout = _int_env(env, "CI_STATUS_TIMEOUT_SECONDS", 1800)
|
||||
|
||||
if not sha:
|
||||
raise ValueError("GITHUB_SHA is required")
|
||||
if not token:
|
||||
raise ValueError("GITEA_TOKEN is required to wait for CI status")
|
||||
|
||||
url = f"https://{host}/api/v1/repos/{repo}/commits/{sha}/status"
|
||||
deadline = time.time() + timeout
|
||||
last_states: dict[str, str] = {}
|
||||
while time.time() <= deadline:
|
||||
body = _api_json(url, token)
|
||||
statuses = body.get("statuses") or []
|
||||
states = {context: ci_context_state(statuses, context) for context in contexts}
|
||||
for context, state in states.items():
|
||||
if state != last_states.get(context):
|
||||
print(f"CI context {context!r}: {state}", file=sys.stderr)
|
||||
last_states = states
|
||||
|
||||
failures = [
|
||||
f"{context}={state}"
|
||||
for context, state in states.items()
|
||||
if context_is_terminal_failure(state)
|
||||
]
|
||||
if failures:
|
||||
raise RuntimeError(
|
||||
"Required CI context failed; refusing production deploy: "
|
||||
+ ", ".join(failures)
|
||||
)
|
||||
if all(context_is_satisfied(state) for state in states.values()):
|
||||
return "success"
|
||||
time.sleep(interval)
|
||||
last = ", ".join(f"{context}={state}" for context, state in last_states.items()) or "none"
|
||||
raise TimeoutError(f"Timed out waiting {timeout}s for required CI contexts; last_states={last}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
sub.add_parser("plan", help="print production deploy plan as JSON")
|
||||
sub.add_parser("assert-enabled", help="fail if production deploy is currently disabled")
|
||||
sub.add_parser("wait-ci", help="block until required CI context is green")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
if args.command == "plan":
|
||||
print(json.dumps(build_plan(dict(os.environ)), sort_keys=True))
|
||||
return 0
|
||||
if args.command == "assert-enabled":
|
||||
assert_not_disabled(dict(os.environ))
|
||||
return 0
|
||||
if args.command == "wait-ci":
|
||||
wait_for_ci_context(dict(os.environ))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001 - CLI should render operator-friendly errors.
|
||||
print(f"::error::{exc}", file=sys.stderr)
|
||||
return 1
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,120 +0,0 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "prod-auto-deploy.py"
|
||||
spec = importlib.util.spec_from_file_location("prod_auto_deploy", SCRIPT)
|
||||
prod = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = prod
|
||||
spec.loader.exec_module(prod)
|
||||
|
||||
|
||||
def test_truthy_flag_accepts_operator_disable_values():
|
||||
for value in ("1", "true", "TRUE", "yes", "on", "disabled", "disable"):
|
||||
assert prod.truthy_flag(value) is True
|
||||
|
||||
for value in ("", "0", "false", "no", "off", None):
|
||||
assert prod.truthy_flag(value) is False
|
||||
|
||||
|
||||
def test_build_plan_defaults_to_staging_sha_target_and_prod_cp():
|
||||
plan = prod.build_plan(
|
||||
{
|
||||
"GITHUB_SHA": "abcdef1234567890",
|
||||
"PROD_AUTO_DEPLOY_DISABLED": "",
|
||||
}
|
||||
)
|
||||
|
||||
assert plan["enabled"] is True
|
||||
assert plan["sha"] == "abcdef1234567890"
|
||||
assert plan["target_tag"] == "staging-abcdef1"
|
||||
assert plan["cp_url"] == "https://api.moleculesai.app"
|
||||
assert plan["body"] == {
|
||||
"target_tag": "staging-abcdef1",
|
||||
"canary_slug": "hongming",
|
||||
"soak_seconds": 60,
|
||||
"batch_size": 3,
|
||||
"dry_run": False,
|
||||
}
|
||||
|
||||
|
||||
def test_build_plan_rejects_non_prod_cp_without_explicit_override():
|
||||
try:
|
||||
prod.build_plan(
|
||||
{
|
||||
"GITHUB_SHA": "abcdef1234567890",
|
||||
"CP_URL": "https://staging-api.moleculesai.app",
|
||||
}
|
||||
)
|
||||
except ValueError as exc:
|
||||
assert "PROD_ALLOW_NON_PROD_CP_URL=true" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected non-prod CP URL rejection")
|
||||
|
||||
|
||||
def test_build_plan_allows_non_prod_cp_only_with_override():
|
||||
plan = prod.build_plan(
|
||||
{
|
||||
"GITHUB_SHA": "abcdef1234567890",
|
||||
"CP_URL": "https://staging-api.moleculesai.app",
|
||||
"PROD_ALLOW_NON_PROD_CP_URL": "true",
|
||||
}
|
||||
)
|
||||
|
||||
assert plan["cp_url"] == "https://staging-api.moleculesai.app"
|
||||
|
||||
|
||||
def test_build_plan_disable_flag_short_circuits_before_credentials():
|
||||
plan = prod.build_plan(
|
||||
{
|
||||
"GITHUB_SHA": "abcdef1234567890",
|
||||
"PROD_AUTO_DEPLOY_DISABLED": "true",
|
||||
}
|
||||
)
|
||||
|
||||
assert plan["enabled"] is False
|
||||
assert plan["disabled_reason"] == "PROD_AUTO_DEPLOY_DISABLED=true"
|
||||
|
||||
|
||||
def test_latest_status_for_context_uses_first_matching_status():
|
||||
statuses = [
|
||||
{"context": "CI / all-required (push)", "status": "pending"},
|
||||
{"context": "CI / all-required (pull_request)", "status": "success"},
|
||||
{"context": "CI / all-required (push)", "status": "success"},
|
||||
]
|
||||
|
||||
latest = prod.latest_status_for_context(statuses, "CI / all-required (push)")
|
||||
|
||||
assert latest == {"context": "CI / all-required (push)", "status": "pending"}
|
||||
|
||||
|
||||
def test_ci_context_state_handles_missing_and_gitea_status_key():
|
||||
assert prod.ci_context_state([], "CI / all-required (push)") == "missing"
|
||||
assert (
|
||||
prod.ci_context_state(
|
||||
[{"context": "CI / all-required (push)", "status": "success"}],
|
||||
"CI / all-required (push)",
|
||||
)
|
||||
== "success"
|
||||
)
|
||||
assert (
|
||||
prod.ci_context_state(
|
||||
[{"context": "CI / all-required (push)", "state": "failure"}],
|
||||
"CI / all-required (push)",
|
||||
)
|
||||
== "failure"
|
||||
)
|
||||
|
||||
|
||||
def test_context_is_satisfied_accepts_only_success():
|
||||
assert prod.context_is_satisfied("success") is True
|
||||
for state in ("failure", "error", "cancelled", "canceled", "skipped", "pending", "missing"):
|
||||
assert prod.context_is_satisfied(state) is False
|
||||
|
||||
|
||||
def test_context_is_terminal_failure_rejects_cancelled_and_skipped():
|
||||
for state in ("failure", "error", "cancelled", "canceled", "skipped"):
|
||||
assert prod.context_is_terminal_failure(state) is True
|
||||
for state in ("pending", "missing", "success"):
|
||||
assert prod.context_is_terminal_failure(state) is False
|
||||
@@ -1,165 +0,0 @@
|
||||
name: MCP Stdio Transport Regression
|
||||
|
||||
# Regression test for molecule-ai-workspace-runtime#61:
|
||||
# asyncio.connect_read_pipe / connect_write_pipe fail with
|
||||
# ValueError: "Pipe transport is only for pipes, sockets and character devices"
|
||||
# when stdout is a regular file (openclaw capture, CI tee, debugging).
|
||||
#
|
||||
# This workflow reproduces the exact failure mode and verifies the
|
||||
# fallback to direct buffer I/O works. It runs on every PR that
|
||||
# touches the MCP server or this workflow, plus nightly cron.
|
||||
#
|
||||
# Why a separate workflow (not folded into ci.yml python-lint):
|
||||
# - The test needs to spawn the MCP server with stdout redirected
|
||||
# to a regular file (not a TTY/pipe), which conflicts with
|
||||
# pytest's own capture mechanism.
|
||||
# - It exercises the actual process spawn path (python a2a_mcp_server.py)
|
||||
# not just unit-test mocks — closer to the real openclaw integration.
|
||||
# - A dedicated workflow surfaces stdio-specific regressions without
|
||||
# coupling to the broader Python test suite's coverage gate.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/mcp_cli.py'
|
||||
- 'workspace/tests/test_a2a_mcp_server.py'
|
||||
- '.gitea/workflows/ci-mcp-stdio-transport.yml'
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/mcp_cli.py'
|
||||
- 'workspace/tests/test_a2a_mcp_server.py'
|
||||
- '.gitea/workflows/ci-mcp-stdio-transport.yml'
|
||||
schedule:
|
||||
# Nightly at 04:00 UTC — catches drift from dependency updates
|
||||
# (e.g. asyncio behavior changes in new Python patch releases).
|
||||
- cron: '0 4 * * *'
|
||||
|
||||
concurrency:
|
||||
group: mcp-stdio-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: regression canary for runtime#61; not a merge gate — informational only until promoted to required.
|
||||
# mc#774: continue-on-error mask — new workflow, flip to false once it's green on ≥3 consecutive main runs.
|
||||
mcp-stdio-regular-file:
|
||||
name: MCP stdio with regular-file stdout
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: true # mc#774
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
WORKSPACE_ID: "00000000-0000-0000-0000-000000000001"
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
cache: pip
|
||||
cache-dependency-path: workspace/requirements.txt
|
||||
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
|
||||
|
||||
- name: Reproduce runtime#61 — stdout as regular file
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== Reproducing molecule-ai-workspace-runtime#61 ==="
|
||||
echo ""
|
||||
echo "Before the fix, this command would fail with:"
|
||||
echo ' ValueError: Pipe transport is only for pipes, sockets and character devices'
|
||||
echo ""
|
||||
|
||||
# Spawn the MCP server with stdout redirected to a regular file.
|
||||
# This is exactly what openclaw does when capturing MCP output.
|
||||
OUTPUT=$(mktemp)
|
||||
trap 'rm -f "$OUTPUT"' EXIT
|
||||
|
||||
# Send initialize request, then tools/list, then exit
|
||||
{
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
|
||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
||||
} | python a2a_mcp_server.py > "$OUTPUT" 2>&1 || {
|
||||
RC=$?
|
||||
echo "FAIL: MCP server exited with code $RC"
|
||||
echo "--- stdout+stderr ---"
|
||||
cat "$OUTPUT"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "PASS: MCP server handled regular-file stdout without crashing"
|
||||
echo ""
|
||||
echo "--- Output (first 20 lines) ---"
|
||||
head -20 "$OUTPUT"
|
||||
echo ""
|
||||
|
||||
# Verify we got valid JSON-RPC responses
|
||||
if grep -q '"result"' "$OUTPUT"; then
|
||||
echo "PASS: JSON-RPC responses found in output"
|
||||
else
|
||||
echo "FAIL: No JSON-RPC responses in output"
|
||||
cat "$OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Reproduce runtime#61 — stdin from regular file
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== stdin as regular file (CI tee / capture pattern) ==="
|
||||
|
||||
INPUT=$(mktemp)
|
||||
OUTPUT=$(mktemp)
|
||||
trap 'rm -f "$INPUT" "$OUTPUT"' EXIT
|
||||
|
||||
cat > "$INPUT" <<'EOF'
|
||||
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
|
||||
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
|
||||
EOF
|
||||
|
||||
python a2a_mcp_server.py < "$INPUT" > "$OUTPUT" 2>&1 || {
|
||||
RC=$?
|
||||
echo "FAIL: MCP server exited with code $RC"
|
||||
cat "$OUTPUT"
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo "PASS: MCP server handled regular-file stdin without crashing"
|
||||
|
||||
if grep -q '"result"' "$OUTPUT"; then
|
||||
echo "PASS: JSON-RPC responses found in output"
|
||||
else
|
||||
echo "FAIL: No JSON-RPC responses in output"
|
||||
cat "$OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify warning is emitted for non-pipe stdio
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== Verify diagnostic warning ==="
|
||||
|
||||
OUTPUT=$(mktemp)
|
||||
trap 'rm -f "$OUTPUT"' EXIT
|
||||
|
||||
{
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
|
||||
} | python a2a_mcp_server.py > "$OUTPUT" 2>&1
|
||||
|
||||
# The warning should mention "not a pipe" for operator visibility
|
||||
if grep -qi "not a pipe" "$OUTPUT"; then
|
||||
echo "PASS: Diagnostic warning emitted for non-pipe stdio"
|
||||
else
|
||||
echo "NOTE: No warning in output (may be suppressed by log level)"
|
||||
fi
|
||||
|
||||
- name: Run unit tests for stdio transport
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== Running stdio transport unit tests ==="
|
||||
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion -v --no-cov
|
||||
@@ -535,13 +535,11 @@ jobs:
|
||||
# hourly if this list diverges from status_check_contexts or from
|
||||
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
|
||||
#
|
||||
# mc#923 fix: canvas-deploy-reminder added to needs: above.
|
||||
# The job's `if:` gate (push-to-main only) means it is legitimately
|
||||
# skipped on PRs — the drift detector's F1 should exclude it (it uses
|
||||
# ci_job_names() which skips github.event_name-gated jobs), but
|
||||
# to be safe and consistent with main, include it in needs:. The
|
||||
# all-required sentinel will see it as 'skipped' on PRs and handle
|
||||
# that per its Phase-3 exclusion logic.
|
||||
# Excluded from `needs:`: `canvas-deploy-reminder` — gated by
|
||||
# `if: ... github.event_name == 'push' && github.ref == 'refs/heads/main'`,
|
||||
# so on PR events it's legitimately `skipped`. The drift detector
|
||||
# explicitly excludes `github.event_name`-gated jobs from F1 (see
|
||||
# `.gitea/scripts/ci-required-drift.py::ci_job_names`).
|
||||
#
|
||||
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
|
||||
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
|
||||
@@ -559,7 +557,6 @@ jobs:
|
||||
- changes
|
||||
- platform-build
|
||||
- canvas-build
|
||||
- canvas-deploy-reminder
|
||||
- shellcheck
|
||||
- python-lint
|
||||
if: always()
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
- id: filter
|
||||
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
|
||||
run: |
|
||||
BASE="${GITHUB_BASE_REF:-${GITHUB_EVENT_BEFORE:-}}"
|
||||
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
fi
|
||||
|
||||
@@ -20,6 +20,12 @@ name: publish-workspace-server-image
|
||||
#
|
||||
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
|
||||
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
|
||||
#
|
||||
# mc#711: Docker daemon not accessible on ubuntu-latest runner (molecule-canonical-1
|
||||
# shows client-only in `docker info` — daemon not running). DinD mount is present but
|
||||
# daemon doesn't respond. Fix: add diagnostic step showing socket info so ops can
|
||||
# identify which runners have a live daemon. If no daemon is available, the job
|
||||
# fails fast with actionable output rather than silent deep failure.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -57,23 +63,20 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Health check: verify Docker daemon is accessible before attempting any
|
||||
# build steps. This fails loudly at step 1 when the runner's docker.sock
|
||||
# is inaccessible (e.g. permission change, daemon restart, or group-membership
|
||||
# drift) rather than silently continuing to step 2 where `docker build`
|
||||
# fails deep in the process with a cryptic ECR auth error that doesn't
|
||||
# surface the root cause. Also reports the daemon version so operator
|
||||
# can correlate with runner host logs.
|
||||
- name: Verify Docker daemon access
|
||||
- name: Diagnose Docker daemon access
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
|
||||
exit 1
|
||||
}
|
||||
echo "Docker daemon OK"
|
||||
echo "::group::Docker daemon diagnosis"
|
||||
echo "Runner: ${HOSTNAME:-unknown}"
|
||||
echo "--- Socket info ---"
|
||||
ls -la /var/run/docker.sock 2>/dev/null || echo "/var/run/docker.sock: not found"
|
||||
stat /var/run/docker.sock 2>/dev/null || true
|
||||
echo "--- User info ---"
|
||||
id
|
||||
echo "--- docker version ---"
|
||||
docker version 2>&1 || true
|
||||
echo "--- docker info (full) ---"
|
||||
docker info 2>&1 || echo "docker info failed: exit $?"
|
||||
echo "::endgroup::"
|
||||
|
||||
# Pre-clone manifest deps before docker build.
|
||||
@@ -92,13 +95,12 @@ jobs:
|
||||
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .tenant-bundle-deps
|
||||
# Strip JSON5 comments before jq parsing — Integration Tester appends
|
||||
# `// Triggered by ...` which breaks `jq` in clone-manifest.sh.
|
||||
sed '/^[[:space:]]*\/\//d' manifest.json > .manifest-stripped.json
|
||||
bash scripts/clone-manifest.sh \
|
||||
manifest.json \
|
||||
.manifest-stripped.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
@@ -115,6 +117,11 @@ jobs:
|
||||
# Build + push platform image (inline ECR auth — mirrors the operator-host
|
||||
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
|
||||
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
|
||||
# docker buildx bake / build required for `imagetools inspect` digest
|
||||
# capture in the CP pin-update step (RFC internal#229 §X step 4 PR-1).
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
|
||||
env:
|
||||
IMAGE_NAME: ${{ env.IMAGE_NAME }}
|
||||
@@ -130,17 +137,16 @@ jobs:
|
||||
ECR_REGISTRY="${IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
docker buildx build \
|
||||
--file ./workspace-server/Dockerfile \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.source=https://git.moleculesai.app/molecule-ai/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI platform — pending canary verify" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--label "molecule.workflow.run_id=${GITHUB_RUN_ID}" \
|
||||
--tag "${IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${IMAGE_NAME}:${TAG_LATEST}"
|
||||
--push .
|
||||
|
||||
# Build + push tenant image (Go platform + Next.js canvas in one image).
|
||||
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
|
||||
@@ -158,15 +164,14 @@ jobs:
|
||||
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
|
||||
aws ecr get-login-password --region us-east-2 | \
|
||||
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
|
||||
docker build \
|
||||
docker buildx build \
|
||||
--file ./workspace-server/Dockerfile.tenant \
|
||||
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
|
||||
--build-arg GIT_SHA="${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
|
||||
--label "org.opencontainers.image.source=https://git.moleculesai.app/molecule-ai/${REPO}" \
|
||||
--label "org.opencontainers.image.revision=${GIT_SHA}" \
|
||||
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
|
||||
--label "org.opencontainers.image.created=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
||||
--label "molecule.workflow.run_id=${GITHUB_RUN_ID}" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
|
||||
.
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
|
||||
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"
|
||||
--push .
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: manual-redeploy-tenants-on-main
|
||||
name: redeploy-tenants-on-main
|
||||
|
||||
# Ported from .github/workflows/redeploy-tenants-on-main.yml on 2026-05-11 per RFC
|
||||
# internal#219 §1 sweep. Differences from the GitHub version:
|
||||
@@ -9,21 +9,14 @@ name: manual-redeploy-tenants-on-main
|
||||
# - Workflow-level env.GITHUB_SERVER_URL pinned per
|
||||
# feedback_act_runner_github_server_url.
|
||||
# - `continue-on-error: true` on each job (RFC §1 contract).
|
||||
# - Gitea 1.22.6 does not support workflow_run (task #81). This Gitea
|
||||
# fallback is manual-only; automatic production deploy is attached to
|
||||
# publish-workspace-server-image.yml after image push succeeds.
|
||||
# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
|
||||
# push+paths filter per this PR. Gitea 1.22.6 does not support
|
||||
# `workflow_run` (task #81). The push trigger fires on every
|
||||
# commit to publish-workspace-server-image.yml which is the
|
||||
# same signal (only successful runs commit to main).
|
||||
#
|
||||
|
||||
# Manual production tenant redeploy fallback.
|
||||
#
|
||||
# Primary automatic production deployment now lives in
|
||||
# publish-workspace-server-image.yml:
|
||||
# build images -> wait for `CI / all-required (push)` green on the same SHA
|
||||
# -> call production redeploy-fleet.
|
||||
#
|
||||
# This workflow remains as an operator fallback. By default it reruns current
|
||||
# main; set repo variable PROD_MANUAL_REDEPLOY_TARGET_TAG to a known-good
|
||||
# `staging-<sha>` tag for rollback.
|
||||
# Auto-refresh prod tenant EC2s after every main merge.
|
||||
#
|
||||
# Why this workflow exists: publish-workspace-server-image builds and
|
||||
# pushes a new platform-tenant :<sha> to ECR on every merge to main,
|
||||
@@ -41,26 +34,60 @@ name: manual-redeploy-tenants-on-main
|
||||
# Gitea suspension migration. The staging-verify.yml promote step now
|
||||
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
|
||||
#
|
||||
# Any failure aborts the rollout and leaves older tenants on the prior image.
|
||||
# Runtime ordering:
|
||||
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
|
||||
# 2. This workflow fires via workflow_run, calls redeploy-fleet with
|
||||
# target_tag=staging-<sha>. No CDN propagation wait needed —
|
||||
# ECR image manifest is consistent immediately after push.
|
||||
# 3. Calls redeploy-fleet with canary_slug (if set) and a soak
|
||||
# period. Canary proves the image boots; batches follow.
|
||||
# 4. Any failure aborts the rollout and leaves older tenants on the
|
||||
# prior image — safer default than half-and-half state.
|
||||
#
|
||||
# Rollback path: re-run this workflow with a specific SHA pinned via
|
||||
# the workflow_dispatch input. That calls redeploy-fleet with
|
||||
# target_tag=<sha>, re-pulling the older image on every tenant.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
# not the GitHub API.
|
||||
|
||||
# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite
|
||||
# `cancel-in-progress: false`; operators should not dispatch overlapping manual
|
||||
# production redeploys.
|
||||
# Serialize redeploys so two rapid main pushes' redeploys don't overlap
|
||||
# and cause confusing per-tenant SSM state. Without this, GitHub's
|
||||
# implicit workflow_run queueing would *probably* serialize them, but
|
||||
# the explicit block makes the invariant defensible. Mirrors the
|
||||
# concurrency block on redeploy-tenants-on-staging.yml for shape parity.
|
||||
#
|
||||
# cancel-in-progress: false → aborting a half-rolled-out fleet would
|
||||
# leave tenants stuck on whatever image they happened to be on when
|
||||
# cancelled. Better to finish the in-flight rollout before starting
|
||||
# the next one.
|
||||
concurrency:
|
||||
group: redeploy-tenants-on-main
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
redeploy:
|
||||
# Skip the auto-trigger if publish-workspace-server-image didn't
|
||||
# actually succeed. workflow_run fires on any completion state; we
|
||||
# don't want to redeploy against a half-built image.
|
||||
# NOTE (Gitea port): workflow_dispatch trigger dropped; only the
|
||||
# workflow_run path remains.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Note on ECR propagation
|
||||
@@ -71,20 +98,30 @@ jobs:
|
||||
|
||||
- name: Compute target tag
|
||||
id: tag
|
||||
# Gitea 1.22.6 does not support workflow_dispatch inputs reliably.
|
||||
# Use repo variable PROD_MANUAL_REDEPLOY_TARGET_TAG for rollback.
|
||||
# Resolution order:
|
||||
# 1. Operator-supplied input (workflow_dispatch with explicit
|
||||
# tag) → used verbatim. Lets ops pin `latest` for emergency
|
||||
# rollback to last canary-verified digest, or pin a specific
|
||||
# `staging-<sha>` to roll back to a known-good build.
|
||||
# 2. Default → `staging-<short_head_sha>`. The just-published
|
||||
# digest. Bypasses the `:latest` retag path that's currently
|
||||
# dead (staging-verify soft-skips without canary fleet, so
|
||||
# the only thing retagging `:latest` today is the manual
|
||||
# promote-latest.yml — last run 2026-04-28). Auto-trigger
|
||||
# from workflow_run uses workflow_run.head_sha; manual
|
||||
# dispatch with no input falls through to github.sha.
|
||||
env:
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
MANUAL_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
|
||||
INPUT_TAG: ${{ inputs.target_tag }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${MANUAL_TARGET_TAG:-}" ]; then
|
||||
echo "target_tag=$MANUAL_TARGET_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Using operator-pinned manual target tag: $MANUAL_TARGET_TAG"
|
||||
if [ -n "${INPUT_TAG:-}" ]; then
|
||||
echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "Using operator-pinned tag: $INPUT_TAG"
|
||||
else
|
||||
SHORT="${HEAD_SHA:0:7}"
|
||||
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
|
||||
echo "Using manual fallback tag: staging-$SHORT (head_sha=$HEAD_SHA)"
|
||||
echo "Using auto tag: staging-$SHORT (head_sha=$HEAD_SHA)"
|
||||
fi
|
||||
|
||||
- name: Call CP redeploy-fleet
|
||||
@@ -93,13 +130,13 @@ jobs:
|
||||
# CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this
|
||||
# repo's secrets for CI.
|
||||
env:
|
||||
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
||||
CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
|
||||
SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
|
||||
BATCH_SIZE: ${{ vars.PROD_AUTO_DEPLOY_BATCH_SIZE || '3' }}
|
||||
DRY_RUN: ${{ vars.PROD_AUTO_DEPLOY_DRY_RUN || false }}
|
||||
CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }}
|
||||
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
|
||||
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
|
||||
DRY_RUN: ${{ inputs.dry_run || false }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -152,7 +189,7 @@ jobs:
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
|
||||
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
|
||||
|
||||
# Pretty-print per-tenant results in the job summary so
|
||||
# ops can see which tenants were redeployed without drilling
|
||||
@@ -168,9 +205,9 @@ jobs:
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
echo '| Slug | Phase | SSM Status | Exit | Healthz | Error present |'
|
||||
echo '|------|-------|------------|------|---------|---------------|'
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true
|
||||
echo '| Slug | Phase | SSM Status | Exit | Healthz | Error |'
|
||||
echo '|------|-------|------------|------|---------|-------|'
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
@@ -209,10 +246,13 @@ jobs:
|
||||
# fail the workflow, which is what `ok=true` should have
|
||||
# guaranteed all along.
|
||||
#
|
||||
# Manual Gitea fallback redeploys current main's staging-<sha> tag, so
|
||||
# the expected SHA is github.sha.
|
||||
# When the redeploy was triggered by workflow_dispatch with a
|
||||
# specific tag (target_tag != "latest"), the expected SHA may
|
||||
# not equal ${{ github.sha }} — in that case we resolve via
|
||||
# GHCR's manifest. For workflow_run (default :latest) the
|
||||
# workflow_run.head_sha is the SHA that just published.
|
||||
env:
|
||||
EXPECTED_SHA: ${{ github.sha }}
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
||||
# Tenant subdomain template — slugs from the response are
|
||||
# appended. Production CP issues `<slug>.moleculesai.app`;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# required_approving_reviews: 1
|
||||
# approving_review_teams: ["ceo", "managers", "engineers"]
|
||||
#
|
||||
# Tier → required-team expression (internal#343 AND-composition):
|
||||
# Tier → required-team expression (internal#189 AND-composition):
|
||||
# tier:low → engineers,managers,ceo (OR: any one suffices)
|
||||
# tier:medium → managers AND engineers AND qa???,security??? (AND: all required)
|
||||
# tier:high → ceo (OR: single team, wired for AND)
|
||||
@@ -32,7 +32,7 @@
|
||||
# for PRs in-flight when AND-composition deployed.
|
||||
# Burn-in: remove after 2026-05-17 (7-day window).
|
||||
#
|
||||
# BURN-IN NOTE (internal#343 Phase 1): continue-on-error: true is set on
|
||||
# BURN-IN NOTE (internal#189 Phase 1): continue-on-error: true is set on
|
||||
# the tier-check job below. This prevents AND-composition from blocking
|
||||
# PRs during the 7-day burn-in. After 2026-05-17:
|
||||
# 1. Remove `continue-on-error: true` from this job block.
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
tier-check:
|
||||
runs-on: ubuntu-latest
|
||||
# BURN-IN: continue-on-error prevents AND-composition from blocking
|
||||
# PRs during the 7-day window. Remove after 2026-05-17 (internal#343).
|
||||
# PRs during the 7-day window. Remove after 2026-05-17 (internal#189).
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -16,8 +16,6 @@ interface PendingApproval {
|
||||
|
||||
export function ApprovalBanner() {
|
||||
const [approvals, setApprovals] = useState<PendingApproval[]>([]);
|
||||
// Guards double-click / double-keypress during in-flight POST.
|
||||
const [pendingApprovalId, setPendingApprovalId] = useState<string | null>(null);
|
||||
|
||||
// Single endpoint — no N+1 per-workspace polling
|
||||
const pollApprovals = useCallback(async () => {
|
||||
@@ -37,8 +35,6 @@ export function ApprovalBanner() {
|
||||
}, [pollApprovals]);
|
||||
|
||||
const handleDecide = async (approval: PendingApproval, decision: "approved" | "denied") => {
|
||||
if (pendingApprovalId !== null) return; // guard double-submit
|
||||
setPendingApprovalId(approval.id);
|
||||
try {
|
||||
await api.post(`/workspaces/${approval.workspace_id}/approvals/${approval.id}/decide`, {
|
||||
decision,
|
||||
@@ -48,8 +44,6 @@ export function ApprovalBanner() {
|
||||
setApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
||||
} catch {
|
||||
showToast("Failed to submit decision", "error");
|
||||
} finally {
|
||||
setPendingApprovalId(null);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -78,25 +72,22 @@ export function ApprovalBanner() {
|
||||
<div className="flex gap-2 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingApprovalId !== null}
|
||||
onClick={() => handleDecide(approval, "approved")}
|
||||
aria-disabled={pendingApprovalId !== null}
|
||||
// Hover goes DARKER — emerald-600 on white text is 3.3:1 (WCAG AA FAIL).
|
||||
// emerald-700 is 4.6:1 (WCAG AA PASS). Hover darkens to emerald-600.
|
||||
className="px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 disabled:cursor-not-allowed text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
|
||||
// Hover DARKER not lighter — emerald-500 on white text
|
||||
// drops contrast vs emerald-700.
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
|
||||
>
|
||||
{pendingApprovalId === approval.id ? "…" : "Approve"}
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pendingApprovalId !== null}
|
||||
onClick={() => handleDecide(approval, "denied")}
|
||||
aria-disabled={pendingApprovalId !== null}
|
||||
// `text-ink` (not text-ink-mid) for WCAG AA contrast on bg-surface-card.
|
||||
// text-ink-mid on zinc-800 fails AA at ~3:1; text-ink passes at ~7:1.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-ink disabled:opacity-40 disabled:cursor-not-allowed text-xs rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
|
||||
// Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
|
||||
// Lift to surface-elevated on hover so the button visibly
|
||||
// responds before a destructive deny.
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
|
||||
>
|
||||
{pendingApprovalId === approval.id ? "…" : "Deny"}
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,7 +98,7 @@ export function ConfirmDialog({
|
||||
confirmVariant === "danger"
|
||||
? "bg-red-600 hover:bg-red-700 text-white"
|
||||
: confirmVariant === "warning"
|
||||
? "bg-amber-800 hover:bg-amber-700 text-white"
|
||||
? "bg-amber-600 hover:bg-amber-700 text-white"
|
||||
: "bg-accent hover:bg-accent-strong text-white";
|
||||
|
||||
// Render via Portal so the fixed-position dialog escapes any containing block
|
||||
|
||||
@@ -80,7 +80,6 @@ export function CreateWorkspaceButton() {
|
||||
// isExternal is true the template / model / hermes-provider fields are
|
||||
// hidden (they're meaningless for BYO-compute agents).
|
||||
const [isExternal, setIsExternal] = useState(false);
|
||||
const [externalRuntime, setExternalRuntime] = useState("external");
|
||||
const [externalConnection, setExternalConnection] =
|
||||
useState<ExternalConnectionInfo | null>(null);
|
||||
|
||||
@@ -224,7 +223,6 @@ export function CreateWorkspaceButton() {
|
||||
setBudgetLimit("");
|
||||
setError(null);
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setHermesApiKey("");
|
||||
setHermesModel("");
|
||||
api
|
||||
@@ -284,7 +282,7 @@ export function CreateWorkspaceButton() {
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
// returned in the response for the modal below.
|
||||
...(isExternal ? { runtime: externalRuntime } : {}),
|
||||
...(isExternal ? { runtime: "external" } : {}),
|
||||
...(!isExternal && isHermes && provider
|
||||
? {
|
||||
secrets: { [provider.envVar]: hermesApiKey.trim() },
|
||||
@@ -384,23 +382,6 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{isExternal && (
|
||||
<div>
|
||||
<label className="text-[11px] text-ink-mid block mb-1">
|
||||
External Runtime
|
||||
</label>
|
||||
<select
|
||||
value={externalRuntime}
|
||||
onChange={(e) => setExternalRuntime(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="external">Generic External</option>
|
||||
<option value="kimi">Kimi CLI</option>
|
||||
<option value="kimi-cli">Kimi CLI (alt)</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isExternal && (
|
||||
<InputField
|
||||
label="Template"
|
||||
|
||||
@@ -18,109 +18,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
// ─── Pure fill helpers ────────────────────────────────────────────────────────
|
||||
// Each snippet is server-stamped with workspace_id + platform_url but leaves
|
||||
// AUTH_TOKEN as a placeholder. These helpers stamp the real token in so the
|
||||
// operator's copy-paste is truly ready-to-run. All are pure string ops.
|
||||
|
||||
export function fillPythonSnippet(
|
||||
snippet: string,
|
||||
authToken: string,
|
||||
): string {
|
||||
return snippet.replace(
|
||||
'AUTH_TOKEN = "<paste from create response>"',
|
||||
`AUTH_TOKEN = "${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillCurlSnippet(
|
||||
snippet: string,
|
||||
authToken: string,
|
||||
): string {
|
||||
return snippet.replace(
|
||||
'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_AUTH_TOKEN="${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillChannelSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
`MOLECULE_WORKSPACE_TOKENS=${authToken}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillUniversalMcpSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillHermesSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillCodexSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN = "${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export function fillOpenClawSnippet(
|
||||
snippet: string | undefined,
|
||||
authToken: string,
|
||||
): string | undefined {
|
||||
return snippet?.replace(
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${authToken}"`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Build the ordered tab list shown in the modal. Each tab only appears when
|
||||
* the platform supplies the corresponding snippet. */
|
||||
export function buildTabOrder(info: ExternalConnectionInfo): Tab[] {
|
||||
const tabs: Tab[] = [];
|
||||
const { filledUniversalMcp, filledChannel, filledHermes, filledCodex, filledOpenClaw } = buildFilledSnippets(info);
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/** Pre-fill all snippets from an info object. Exposed for testing. */
|
||||
export function buildFilledSnippets(info: ExternalConnectionInfo) {
|
||||
return {
|
||||
filledPython: fillPythonSnippet(info.python_snippet, info.auth_token),
|
||||
filledCurl: fillCurlSnippet(info.curl_register_template, info.auth_token),
|
||||
filledChannel: fillChannelSnippet(info.claude_code_channel_snippet, info.auth_token),
|
||||
filledUniversalMcp: fillUniversalMcpSnippet(info.universal_mcp_snippet, info.auth_token),
|
||||
filledHermes: fillHermesSnippet(info.hermes_channel_snippet, info.auth_token),
|
||||
filledCodex: fillCodexSnippet(info.codex_snippet, info.auth_token),
|
||||
filledOpenClaw: fillOpenClawSnippet(info.openclaw_snippet, info.auth_token),
|
||||
};
|
||||
}
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
|
||||
|
||||
export interface ExternalConnectionInfo {
|
||||
@@ -205,7 +102,54 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
|
||||
if (!info) return null;
|
||||
|
||||
const { filledPython, filledCurl, filledChannel, filledUniversalMcp, filledHermes, filledCodex, filledOpenClaw } = buildFilledSnippets(info);
|
||||
// Python snippet is stamped server-side with workspace_id +
|
||||
// platform_url but leaves AUTH_TOKEN as a "<paste …>" placeholder
|
||||
// (that's what we're showing in the modal). Fill in the real
|
||||
// token here so the snippet the operator copies is truly ready-to-run.
|
||||
const filledPython = info.python_snippet.replace(
|
||||
'AUTH_TOKEN = "<paste from create response>"',
|
||||
`AUTH_TOKEN = "${info.auth_token}"`,
|
||||
);
|
||||
const filledCurl = info.curl_register_template.replace(
|
||||
'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_AUTH_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// The channel snippet asks the operator to paste the auth_token into
|
||||
// the .env file's MOLECULE_WORKSPACE_TOKENS field. Stamp it server-side
|
||||
// here so the copy-paste-block is truly ready-to-run.
|
||||
const filledChannel = info.claude_code_channel_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
`MOLECULE_WORKSPACE_TOKENS=${info.auth_token}`,
|
||||
);
|
||||
// Universal MCP snippet uses MOLECULE_WORKSPACE_TOKEN as the env-var
|
||||
// name passed through to molecule-mcp via `claude mcp add ... -- env
|
||||
// MOLECULE_WORKSPACE_TOKEN=...`. The placeholder must match the
|
||||
// template's literal — pre-2026-04-30 polish this looked for
|
||||
// WORKSPACE_AUTH_TOKEN (carryover from the curl tab), which silently
|
||||
// skipped the substitution and left "<paste from create response>"
|
||||
// visible in the operator's clipboard.
|
||||
const filledUniversalMcp = info.universal_mcp_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Hermes channel snippet uses MOLECULE_WORKSPACE_TOKEN (same env-var
|
||||
// name as Universal MCP). Stamp the auth_token in so the operator's
|
||||
// copy-paste is fully ready-to-run.
|
||||
const filledHermes = info.hermes_channel_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Codex + OpenClaw snippets carry the placeholder inside the
|
||||
// generated config block (TOML / JSON respectively). Stamp the
|
||||
// token in so the copy-paste is one less manual edit.
|
||||
const filledCodex = info.codex_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
`MOLECULE_WORKSPACE_TOKEN = "${info.auth_token}"`,
|
||||
);
|
||||
const filledOpenClaw = info.openclaw_snippet?.replace(
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -227,7 +171,27 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
aria-label="Connection snippet format"
|
||||
className="mt-4 flex gap-1 border-b border-line"
|
||||
>
|
||||
{buildTabOrder(info).map((t) => (
|
||||
{(() => {
|
||||
// Build the tab order dynamically. Claude Code first
|
||||
// (when offered) since it's the simplest setup; Python
|
||||
// SDK second (full register+heartbeat+inbound); Universal
|
||||
// MCP third (any MCP-aware runtime, outbound-only); curl
|
||||
// for one-shot register; Fields for raw values.
|
||||
// Tab order: Universal MCP first (default, runtime-
|
||||
// agnostic primitives), then runtime-specific channel/
|
||||
// SDK tabs, then curl + Fields. Each runtime tab only
|
||||
// appears when the platform supplies the snippet — no
|
||||
// dead "tab missing snippet" UX.
|
||||
const tabs: Tab[] = [];
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
|
||||
@@ -117,7 +117,7 @@ function PlanCard({
|
||||
<ul className="mt-6 flex-1 space-y-2 text-sm text-ink-mid">
|
||||
{plan.features.map((f) => (
|
||||
<li key={f} className="flex items-start">
|
||||
<span className="mr-2 text-accent" aria-hidden="true">
|
||||
<span className="mr-2 text-accent" aria-hidden>
|
||||
✓
|
||||
</span>
|
||||
{f}
|
||||
|
||||
@@ -87,21 +87,20 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
<>
|
||||
{children}
|
||||
{status === "pending" && (
|
||||
// Backdrop is purely decorative (blur overlay). Separated from the
|
||||
// dialog so aria-hidden on the backdrop does NOT hide the dialog from
|
||||
// assistive tech. Backdrop click does nothing — this is a hard gate.
|
||||
<>
|
||||
<div aria-hidden="true" className="fixed inset-0 z-50 bg-surface/80 backdrop-blur-sm" />
|
||||
// Backdrop is decorative — does NOT carry aria-hidden anymore.
|
||||
// The earlier version put aria-hidden="true" on this wrapper,
|
||||
// which hid the dialog AND its descendants from screen readers,
|
||||
// making the entire terms-acceptance flow invisible to AT users.
|
||||
// Backdrop click intentionally does nothing — this is a hard
|
||||
// gate.
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="terms-dialog-title"
|
||||
aria-describedby="terms-dialog-body"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
|
||||
>
|
||||
<div
|
||||
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
|
||||
>
|
||||
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms & conditions</h2>
|
||||
<div id="terms-dialog-body">
|
||||
<p className="mt-3 text-sm text-ink-mid">
|
||||
@@ -136,17 +135,16 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
ref={agreeButtonRef}
|
||||
onClick={accept}
|
||||
disabled={submitting}
|
||||
aria-disabled={submitting}
|
||||
// Hover goes DARKER — emerald-600 on white text is 3.3:1 (WCAG AA FAIL).
|
||||
// emerald-700 is 4.6:1 (WCAG AA PASS). Hover darkens to emerald-600.
|
||||
className="rounded bg-emerald-700 hover:bg-emerald-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
// Hover goes DARKER, not lighter — emerald-500 on white
|
||||
// text drops contrast below AA vs emerald-700. Same trap
|
||||
// I fixed in ApprovalBanner + ConfirmDialog.
|
||||
className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
|
||||
>
|
||||
{submitting ? "…" : "I agree"}
|
||||
{submitting ? "Saving…" : "I agree"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div role="alert" className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">
|
||||
|
||||
@@ -314,7 +314,7 @@ export function Toolbar() {
|
||||
<div ref={helpRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHelpOpen(true)}
|
||||
onClick={() => setHelpOpen((open) => !open)}
|
||||
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
aria-expanded={helpOpen}
|
||||
aria-label="Open shortcuts and tips"
|
||||
|
||||
@@ -9,7 +9,6 @@ import { Tooltip } from "@/components/Tooltip";
|
||||
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
|
||||
import { useOrgDeployState } from "@/components/canvas/useOrgDeployState";
|
||||
import { OrgCancelButton } from "@/components/canvas/OrgCancelButton";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
/** Descendant count for the "N sub" badge — children are first-class nodes
|
||||
* rendered as full cards inside this one via React Flow's native parentId,
|
||||
@@ -249,9 +248,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
if (!runtime) return null;
|
||||
return (
|
||||
<div className="mb-1 flex items-center gap-1">
|
||||
{isExternalLikeRuntime(runtime) ? (
|
||||
{runtime === "external" ? (
|
||||
<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-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-600 border border-violet-700"
|
||||
title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec."
|
||||
>
|
||||
★ REMOTE
|
||||
|
||||
@@ -1,114 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("ConfirmDialog — WCAG dialog accessibility", () => {
|
||||
it("dialog has role=dialog and aria-modal=true", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Are you sure?"
|
||||
message="This action cannot be undone."
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
});
|
||||
|
||||
it("dialog has aria-labelledby pointing to the title", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete workspace"
|
||||
message="This will permanently delete the workspace."
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
const labelledBy = dialog.getAttribute("aria-labelledby");
|
||||
expect(labelledBy).toBeTruthy();
|
||||
const titleEl = document.getElementById(labelledBy!);
|
||||
expect(titleEl?.textContent?.trim()).toBe("Delete workspace");
|
||||
});
|
||||
|
||||
it("Escape key invokes onCancel", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("Enter key invokes onConfirm", () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
fireEvent.keyDown(window, { key: "Enter" });
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("moves focus to the first button when dialog opens (WCAG 2.4.3)", async () => {
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Flush requestAnimationFrame so ConfirmDialog's internal rAF focus fires
|
||||
await act(async () => {
|
||||
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
|
||||
});
|
||||
const firstButton = screen.getAllByRole("button")[0];
|
||||
expect(document.activeElement).toBe(firstButton);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmDialog — backdrop", () => {
|
||||
it("backdrop click invokes onCancel", () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Title"
|
||||
message="Message"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
const backdrop = document.querySelector('[aria-label="Dismiss dialog"]') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
fireEvent.click(backdrop);
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfirmDialog singleButton prop", () => {
|
||||
it("renders Cancel button by default", () => {
|
||||
render(
|
||||
|
||||
@@ -1,275 +1,237 @@
|
||||
'use client';
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ExternalConnectModal — the modal surfaced after creating a
|
||||
* runtime="external" workspace. Surfaces workspace_auth_token + ready-to-paste
|
||||
* snippets so the operator can configure their off-host agent.
|
||||
*
|
||||
* Coverage:
|
||||
* - Renders nothing when info=null
|
||||
* - Opens dialog when info is provided
|
||||
* - Default tab: "Universal MCP" when universal_mcp_snippet present, else "Python SDK"
|
||||
* - Tab switching between all available tabs
|
||||
* - Snippets show with auth_token replacing placeholders
|
||||
* - Copy button: calls clipboard API, shows "Copied!", clears after 1.5s
|
||||
* - Copy failure: shows fallback textarea
|
||||
* - "I've saved it — close" calls onClose
|
||||
* - Security warning: one-time token display
|
||||
* - Fields tab shows raw values
|
||||
* - Tabs hidden when their snippet is absent
|
||||
*
|
||||
* Fake timers: applied per-describe to avoid mixing with waitFor. Tests that
|
||||
* use waitFor (which needs real timers) run without fake timers. Tests that
|
||||
* verify setTimeout behavior use vi.useFakeTimers() + act(vi.advanceTimersByTime).
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fillPythonSnippet,
|
||||
fillCurlSnippet,
|
||||
fillChannelSnippet,
|
||||
fillUniversalMcpSnippet,
|
||||
fillHermesSnippet,
|
||||
fillCodexSnippet,
|
||||
fillOpenClawSnippet,
|
||||
buildFilledSnippets,
|
||||
buildTabOrder,
|
||||
ExternalConnectionInfo,
|
||||
} from '../ExternalConnectModal';
|
||||
ExternalConnectModal,
|
||||
type ExternalConnectionInfo,
|
||||
} from "../ExternalConnectModal";
|
||||
|
||||
// ─── fillPythonSnippet ───────────────────────────────────────────────────────
|
||||
const defaultInfo: ExternalConnectionInfo = {
|
||||
workspace_id: "ws-123",
|
||||
platform_url: "https://app.example.com",
|
||||
auth_token: "secret-auth-token-abc",
|
||||
registry_endpoint: "https://app.example.com/api/a2a/register",
|
||||
heartbeat_endpoint: "https://app.example.com/api/a2a/heartbeat",
|
||||
// Placeholders must EXACTLY match what the component searches for in
|
||||
// the string.replace() calls (the component does NOT normalise whitespace).
|
||||
// Python: 'AUTH_TOKEN = "...' (4 spaces), curl: WORKSPACE_AUTH_TOKEN="<paste>" (with quotes),
|
||||
// MCP/Hermes: MOLECULE_WORKSPACE_TOKEN="...", Codex: same with 1 space.
|
||||
curl_register_template:
|
||||
`curl -X POST https://app.example.com/api/a2a/register \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"auth_token": "WORKSPACE_AUTH_TOKEN=\"<paste from create response>\"", ...}'`,
|
||||
python_snippet:
|
||||
'AUTH_TOKEN = "<paste from create response>"\nAPI_URL = "https://app.example.com"',
|
||||
universal_mcp_snippet:
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
hermes_channel_snippet:
|
||||
'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
};
|
||||
|
||||
describe('fillPythonSnippet', () => {
|
||||
it('stamps auth_token into the AUTH_TOKEN placeholder', () => {
|
||||
const input =
|
||||
'AUTH_TOKEN = "<paste from create response>"\n' +
|
||||
'PLATFORM_URL = "http://localhost:8080"';
|
||||
const got = fillPythonSnippet(input, 'tok-abc123');
|
||||
expect(got).toContain('AUTH_TOKEN = "tok-abc123"');
|
||||
// Original placeholder is gone
|
||||
expect(got).not.toContain('<paste from create response>');
|
||||
});
|
||||
// ─── Clipboard mock helpers ────────────────────────────────────────────────────
|
||||
|
||||
it('leaves other lines untouched', () => {
|
||||
const input = 'PLATFORM_URL = "http://localhost:8080"\nAUTH_TOKEN = "<paste from create response>"';
|
||||
const got = fillPythonSnippet(input, 'tok-xyz');
|
||||
expect(got).toContain('PLATFORM_URL = "http://localhost:8080"');
|
||||
});
|
||||
let clipboardWriteText = vi.fn();
|
||||
|
||||
it('handles empty token', () => {
|
||||
const input = 'AUTH_TOKEN = "<paste from create response>"';
|
||||
const got = fillPythonSnippet(input, '');
|
||||
expect(got).toContain('AUTH_TOKEN = ""');
|
||||
beforeEach(() => {
|
||||
clipboardWriteText.mockReset().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText: clipboardWriteText },
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillCurlSnippet ─────────────────────────────────────────────────────────
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('fillCurlSnippet', () => {
|
||||
it('stamps auth_token into WORKSPACE_AUTH_TOKEN placeholder', () => {
|
||||
const input = 'WORKSPACE_AUTH_TOKEN="<paste from create response>"';
|
||||
const got = fillCurlSnippet(input, 'tok-curl');
|
||||
expect(got).toContain('WORKSPACE_AUTH_TOKEN="tok-curl"');
|
||||
expect(got).not.toContain('<paste from create response>');
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderModal(info: ExternalConnectionInfo | null) {
|
||||
return render(
|
||||
<ExternalConnectModal info={info} onClose={vi.fn()} />,
|
||||
);
|
||||
}
|
||||
|
||||
// Flush React + Radix portal updates synchronously so the dialog is in the DOM.
|
||||
function renderAndFlush(info: ExternalConnectionInfo | null) {
|
||||
const result = renderModal(info);
|
||||
act(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ExternalConnectModal — render conditions", () => {
|
||||
it("renders nothing when info is null", () => {
|
||||
renderModal(null);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("renders the dialog when info is provided", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.queryByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows the security warning about one-time token display", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.getByText(/only once/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillChannelSnippet ─────────────────────────────────────────────────────
|
||||
|
||||
describe('fillChannelSnippet', () => {
|
||||
it('stamps token into MOLECULE_WORKSPACE_TOKENS placeholder', () => {
|
||||
const input = 'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>';
|
||||
const got = fillChannelSnippet(input, 'tok-channel');
|
||||
expect(got).toContain('MOLECULE_WORKSPACE_TOKENS=tok-channel');
|
||||
describe("ExternalConnectModal — default tab selection", () => {
|
||||
it("opens the Universal MCP tab by default when universal_mcp_snippet is present", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
const mcpTab = screen.getByRole("tab", { name: /universal mcp/i });
|
||||
expect(mcpTab.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillChannelSnippet(undefined, 'tok')).toBeUndefined();
|
||||
it("opens the Python SDK tab by default when universal_mcp_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, universal_mcp_snippet: undefined });
|
||||
const pythonTab = screen.getByRole("tab", { name: /python sdk/i });
|
||||
expect(pythonTab.getAttribute("aria-selected")).toBe("true");
|
||||
});
|
||||
|
||||
it("tab order: Universal MCP appears before Python SDK when both exist", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
const mcpIndex = tabs.findIndex((t) => t.textContent?.includes("Universal MCP"));
|
||||
const pythonIndex = tabs.findIndex((t) => t.textContent?.includes("Python SDK"));
|
||||
expect(mcpIndex).toBeLessThan(pythonIndex);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillUniversalMcpSnippet ───────────────────────────────────────────────
|
||||
|
||||
describe('fillUniversalMcpSnippet', () => {
|
||||
it('stamps token with double-quoted value', () => {
|
||||
const input = 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"';
|
||||
const got = fillUniversalMcpSnippet(input, 'tok-mcp');
|
||||
expect(got).toContain('MOLECULE_WORKSPACE_TOKEN="tok-mcp"');
|
||||
describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the Python SDK tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("AUTH_TOKEN");
|
||||
// The placeholder is replaced with the real auth token
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillUniversalMcpSnippet(undefined, 'tok')).toBeUndefined();
|
||||
it("switches to the curl tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("curl");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("switches to the Fields tab and shows raw values", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
expect(screen.getByText("ws-123")).toBeTruthy();
|
||||
expect(screen.getByText("https://app.example.com")).toBeTruthy();
|
||||
expect(screen.getByText("secret-auth-token-abc")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, hermes_channel_snippet: undefined });
|
||||
expect(screen.queryByRole("tab", { name: /hermes/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows Hermes tab when hermes_channel_snippet is present", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
expect(screen.getByRole("tab", { name: /hermes/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillHermesSnippet ─────────────────────────────────────────────────────
|
||||
|
||||
describe('fillHermesSnippet', () => {
|
||||
it('stamps token with double-quoted value', () => {
|
||||
const input = 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"';
|
||||
const got = fillHermesSnippet(input, 'tok-hermes');
|
||||
expect(got).toContain('MOLECULE_WORKSPACE_TOKEN="tok-hermes"');
|
||||
describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the Python snippet instead of the placeholder", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillHermesSnippet(undefined, 'tok')).toBeUndefined();
|
||||
it("stamps the real auth_token into the curl snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
// curl template uses WORKSPACE_AUTH_TOKEN placeholder, not the generic one
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("stamps the real auth_token into the Universal MCP snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillCodexSnippet ──────────────────────────────────────────────────────
|
||||
|
||||
describe('fillCodexSnippet', () => {
|
||||
it('uses TOML spacing (space around equals)', () => {
|
||||
const input = 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"';
|
||||
const got = fillCodexSnippet(input, 'tok-codex');
|
||||
expect(got).toContain('MOLECULE_WORKSPACE_TOKEN = "tok-codex"');
|
||||
expect(got).not.toContain('<paste from create response>');
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillCodexSnippet(undefined, 'tok')).toBeUndefined();
|
||||
describe("ExternalConnectModal — copy functionality", () => {
|
||||
it("calls navigator.clipboard.writeText with the snippet text", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
fireEvent.click(screen.getByRole("button", { name: /^copy$/i }));
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-auth-token-abc"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── fillOpenClawSnippet ───────────────────────────────────────────────────
|
||||
|
||||
describe('fillOpenClawSnippet', () => {
|
||||
it('stamps token with WORKSPACE_TOKEN key name', () => {
|
||||
const input = 'WORKSPACE_TOKEN="<paste from create response>"';
|
||||
const got = fillOpenClawSnippet(input, 'tok-oc');
|
||||
expect(got).toContain('WORKSPACE_TOKEN="tok-oc"');
|
||||
expect(got).not.toContain('<paste from create response>');
|
||||
});
|
||||
|
||||
it('returns undefined when snippet is undefined', () => {
|
||||
expect(fillOpenClawSnippet(undefined, 'tok')).toBeUndefined();
|
||||
describe("ExternalConnectModal — close behavior", () => {
|
||||
it('calls onClose when "I\'ve saved it — close" is clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
render(
|
||||
<ExternalConnectModal info={defaultInfo} onClose={onClose} />,
|
||||
);
|
||||
act(() => {});
|
||||
fireEvent.click(screen.getByRole("button", { name: /i've saved it/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildFilledSnippets ────────────────────────────────────────────────────
|
||||
|
||||
describe('buildFilledSnippets', () => {
|
||||
const makeInfo = (overrides: Partial<ExternalConnectionInfo> = {}): ExternalConnectionInfo =>
|
||||
({
|
||||
workspace_id: 'ws-1',
|
||||
platform_url: 'http://localhost:8080',
|
||||
auth_token: 'tok-test',
|
||||
registry_endpoint: 'http://localhost:8080/registry/register',
|
||||
heartbeat_endpoint: 'http://localhost:8080/registry/heartbeat',
|
||||
python_snippet: 'AUTH_TOKEN = "<paste from create response>"',
|
||||
curl_register_template: 'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('fills python snippet', () => {
|
||||
const { filledPython } = buildFilledSnippets(makeInfo());
|
||||
expect(filledPython).toContain('tok-test');
|
||||
describe("ExternalConnectModal — missing optional fields", () => {
|
||||
it("shows (missing) for absent optional fields in the Fields tab", () => {
|
||||
// Use empty string so Field renders "(missing)" for registry_endpoint
|
||||
const minimalInfo: ExternalConnectionInfo = {
|
||||
workspace_id: "ws-min",
|
||||
platform_url: "https://min.example.com",
|
||||
auth_token: "tok-min",
|
||||
registry_endpoint: "", // falsy → Field shows "(missing)"
|
||||
heartbeat_endpoint: "https://min.example.com/api/hb",
|
||||
curl_register_template: "curl echo",
|
||||
python_snippet: "print('hello')",
|
||||
};
|
||||
renderAndFlush(minimalInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
expect(screen.getByText("(missing)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('fills curl snippet', () => {
|
||||
const { filledCurl } = buildFilledSnippets(makeInfo());
|
||||
expect(filledCurl).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills claude_code_channel_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
claude_code_channel_snippet: 'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
});
|
||||
const { filledChannel } = buildFilledSnippets(info);
|
||||
expect(filledChannel).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills universal_mcp_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
universal_mcp_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
});
|
||||
const { filledUniversalMcp } = buildFilledSnippets(info);
|
||||
expect(filledUniversalMcp).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills hermes_channel_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
hermes_channel_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
});
|
||||
const { filledHermes } = buildFilledSnippets(info);
|
||||
expect(filledHermes).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills codex_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
});
|
||||
const { filledCodex } = buildFilledSnippets(info);
|
||||
expect(filledCodex).toContain('tok-test');
|
||||
});
|
||||
|
||||
it('fills openclaw_snippet when present', () => {
|
||||
const info = makeInfo({
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
});
|
||||
const { filledOpenClaw } = buildFilledSnippets(info);
|
||||
expect(filledOpenClaw).toContain('tok-test');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── buildTabOrder ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('buildTabOrder', () => {
|
||||
const makeInfo = (overrides: Partial<ExternalConnectionInfo> = {}): ExternalConnectionInfo =>
|
||||
({
|
||||
workspace_id: 'ws-1',
|
||||
platform_url: 'http://localhost:8080',
|
||||
auth_token: 'tok-test',
|
||||
registry_endpoint: 'http://localhost:8080/registry/register',
|
||||
heartbeat_endpoint: 'http://localhost:8080/registry/heartbeat',
|
||||
python_snippet: 'AUTH_TOKEN = "<paste from create response>"',
|
||||
curl_register_template: 'WORKSPACE_AUTH_TOKEN="<paste from create response>"',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('python is always present', () => {
|
||||
const tabs = buildTabOrder(makeInfo());
|
||||
expect(tabs).toContain('python');
|
||||
});
|
||||
|
||||
it('curl and fields are always present', () => {
|
||||
const tabs = buildTabOrder(makeInfo());
|
||||
expect(tabs).toContain('curl');
|
||||
expect(tabs).toContain('fields');
|
||||
});
|
||||
|
||||
it('mcp first when universal_mcp_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
universal_mcp_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
}));
|
||||
expect(tabs[0]).toBe('mcp');
|
||||
});
|
||||
|
||||
it('python first when universal_mcp_snippet is absent', () => {
|
||||
const tabs = buildTabOrder(makeInfo());
|
||||
expect(tabs[0]).toBe('python');
|
||||
});
|
||||
|
||||
it('mcp excluded when universal_mcp_snippet is absent', () => {
|
||||
const tabs = buildTabOrder(makeInfo());
|
||||
expect(tabs).not.toContain('mcp');
|
||||
});
|
||||
|
||||
it('includes claude when claude_code_channel_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
claude_code_channel_snippet: 'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
}));
|
||||
expect(tabs).toContain('claude');
|
||||
});
|
||||
|
||||
it('includes hermes when hermes_channel_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
hermes_channel_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
}));
|
||||
expect(tabs).toContain('hermes');
|
||||
});
|
||||
|
||||
it('includes codex when codex_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
}));
|
||||
expect(tabs).toContain('codex');
|
||||
});
|
||||
|
||||
it('includes openclaw when openclaw_snippet is present', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
}));
|
||||
expect(tabs).toContain('openclaw');
|
||||
});
|
||||
|
||||
it('all optional tabs at once: full house', () => {
|
||||
const tabs = buildTabOrder(makeInfo({
|
||||
universal_mcp_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
claude_code_channel_snippet: 'MOLECULE_WORKSPACE_TOKENS=<paste auth_token from create response>',
|
||||
hermes_channel_snippet: 'MOLECULE_WORKSPACE_TOKEN="<paste from create response>"',
|
||||
codex_snippet: 'MOLECULE_WORKSPACE_TOKEN = "<paste from create response>"',
|
||||
openclaw_snippet: 'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
}));
|
||||
expect(tabs).toEqual([
|
||||
'mcp', 'python', 'claude', 'hermes', 'codex', 'openclaw', 'curl', 'fields',
|
||||
]);
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
renderAndFlush({ ...defaultInfo, hermes_channel_snippet: undefined });
|
||||
expect(screen.queryByRole("tab", { name: /hermes/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* itself (MemoryInspectorPanel) requires full API + store mocking and
|
||||
* is exercised by the existing MemoryTab.test.tsx.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
|
||||
|
||||
// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx
|
||||
@@ -47,9 +47,6 @@ describe("isPluginUnavailableError", () => {
|
||||
});
|
||||
|
||||
describe("formatTTL", () => {
|
||||
beforeEach(() => { vi.useFakeTimers(); });
|
||||
afterEach(() => { vi.useRealTimers(); });
|
||||
|
||||
it("returns '' for null", () => {
|
||||
expect(formatTTL(null)).toBe("");
|
||||
});
|
||||
|
||||
@@ -145,17 +145,6 @@ describe("PricingTable", () => {
|
||||
expect(mockedStartCheckout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("marks feature checkmarks as aria-hidden (decorative, not exposed to screen readers)", () => {
|
||||
render(<PricingTable />);
|
||||
const checks = document.body.querySelectorAll('[aria-hidden="true"]');
|
||||
// Every feature list has a ✓ glyph; all should be aria-hidden.
|
||||
expect(checks.length).toBeGreaterThan(0);
|
||||
// The checkmark spans use text-accent (decorative SVG-like glyphs).
|
||||
checks.forEach((el) => {
|
||||
expect(el.textContent?.trim()).toBe("✓");
|
||||
});
|
||||
});
|
||||
|
||||
it("disables the button while a checkout call is in flight", async () => {
|
||||
mockedFetchSession.mockResolvedValue({
|
||||
user_id: "u1",
|
||||
|
||||
@@ -189,49 +189,6 @@ describe("TermsGate — accept flow", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TermsGate — I agree button accessibility", () => {
|
||||
it("shows ellipsis on the I agree button while POST is in flight", async () => {
|
||||
// Deferred POST so we can control when it resolves and observe the
|
||||
// mid-flight button state without fake timers.
|
||||
let resolvePost: (r: Response) => void;
|
||||
const postDeferred = new Promise<Response>((r) => { resolvePost = r; });
|
||||
// Intercept: terms-status → pending (first fetch), POST deferred (second).
|
||||
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
|
||||
vi.spyOn(global, "fetch").mockImplementation(
|
||||
() => postDeferred as unknown as Promise<Response>
|
||||
);
|
||||
|
||||
render(<TermsGate><div>App content</div></TermsGate>);
|
||||
await waitFor(() => screen.getByRole("dialog"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
|
||||
|
||||
// Ellipsis replaces "I agree" while POST is in flight
|
||||
expect(screen.queryByRole("button", { name: /i agree/i })).toBeNull();
|
||||
expect(screen.getAllByRole("button").some((b) => b.textContent === "…")).toBeTruthy();
|
||||
|
||||
act(() => { resolvePost!(new Response("ok", { status: 200 })); });
|
||||
});
|
||||
|
||||
it("has aria-disabled while submitting", async () => {
|
||||
let resolvePost: (r: Response) => void;
|
||||
const postDeferred = new Promise<Response>((r) => { resolvePost = r; });
|
||||
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
|
||||
vi.spyOn(global, "fetch").mockImplementation(
|
||||
() => postDeferred as unknown as Promise<Response>
|
||||
);
|
||||
|
||||
render(<TermsGate><div>App content</div></TermsGate>);
|
||||
await waitFor(() => screen.getByRole("dialog"));
|
||||
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
|
||||
|
||||
// Find the ellipsis button and check aria-disabled
|
||||
const ellipsisBtn = screen.getAllByRole("button").find((b) => b.textContent === "…");
|
||||
expect(ellipsisBtn?.getAttribute("aria-disabled")).toBe("true");
|
||||
|
||||
act(() => { resolvePost!(new Response("ok", { status: 200 })); });
|
||||
});
|
||||
});
|
||||
|
||||
describe("TermsGate — error state", () => {
|
||||
it("shows an error alert when terms-status fetch fails with non-401", async () => {
|
||||
mockFetch(new Response("Gateway Timeout", { status: 504 }));
|
||||
|
||||
@@ -255,32 +255,6 @@ describe("Toolbar — Help popover", () => {
|
||||
fireEvent.click(closeBtn);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("closes when pointer is pressed outside the help popover", () => {
|
||||
render(<Toolbar />);
|
||||
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
|
||||
fireEvent.click(helpBtn);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Simulate pointerdown outside the help popover (not on the help button)
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("opens on click even after a previous pointer-outside close", () => {
|
||||
// Regression: clicking outside closed the popover AND toggled the button
|
||||
// state, so the next click on the button would close it again.
|
||||
// The fix makes the button always open (never toggle) so re-opening works.
|
||||
render(<Toolbar />);
|
||||
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
|
||||
fireEvent.click(helpBtn);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
// Click outside (pointerdown on body, not on help button)
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
// Click the help button again — must re-open, not double-close
|
||||
fireEvent.click(helpBtn);
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Toolbar — A2A edges toggle", () => {
|
||||
|
||||
@@ -75,7 +75,7 @@ export function DropTargetBadge() {
|
||||
)}
|
||||
<div
|
||||
data-testid="drop-badge"
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
|
||||
style={{ left: badge.x, top: badge.y - 6 }}
|
||||
>
|
||||
Drop into: {targetName}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
findProviderForModel,
|
||||
type SelectorValue,
|
||||
} from "../ProviderModelSelector";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -176,7 +175,7 @@ function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
// exactly the point of the platform adaptor. The deep `~/.hermes/
|
||||
// config.yaml` on the container is a separate runtime-internal file,
|
||||
// not this one.
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli"]);
|
||||
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external"]);
|
||||
|
||||
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
|
||||
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
|
||||
@@ -1004,7 +1003,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
: "This runtime manages its own config outside the platform template."}
|
||||
</div>
|
||||
)}
|
||||
{!error && isExternalLikeRuntime(config.runtime) && (
|
||||
{!error && config.runtime === "external" && (
|
||||
<ExternalConnectionSection workspaceId={workspaceId} />
|
||||
)}
|
||||
{success && (
|
||||
|
||||
@@ -9,7 +9,6 @@ import { FileEditor } from "./FilesTab/FileEditor";
|
||||
import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel";
|
||||
import { useFilesApi } from "./FilesTab/useFilesApi";
|
||||
import { buildTree } from "./FilesTab/tree";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
// Re-exports preserved for external imports (e.g. tests importing from `../tabs/FilesTab`)
|
||||
export { buildTree } from "./FilesTab/tree";
|
||||
@@ -33,6 +32,8 @@ interface Props {
|
||||
* has no platform-owned filesystem. Otherwise the user loses access to
|
||||
* a real surface (e.g. claude-code SaaS workspaces have files served
|
||||
* by ListFiles via EIC; they belong on the rendering path, not here). */
|
||||
const RUNTIMES_WITHOUT_FILES = new Set(["external"]);
|
||||
|
||||
export function FilesTab({ workspaceId, data }: Props) {
|
||||
// Early-return for runtimes whose filesystem is not platform-owned.
|
||||
// Skips the whole useFilesApi hook + tree render below — without this,
|
||||
@@ -42,7 +43,7 @@ export function FilesTab({ workspaceId, data }: Props) {
|
||||
// "0 files / No config files yet" reads as a bug. The placeholder
|
||||
// makes the absence intentional and points the user at the right
|
||||
// surface (Chat).
|
||||
if (data && isExternalLikeRuntime(data.runtime)) {
|
||||
if (data && RUNTIMES_WITHOUT_FILES.has(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
|
||||
|
||||
@@ -13,7 +13,6 @@ interface Props {
|
||||
}
|
||||
|
||||
import { deriveWsBaseUrl } from "@/lib/ws-url";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
const WS_URL = deriveWsBaseUrl();
|
||||
|
||||
@@ -88,6 +87,8 @@ function NotAvailablePanel({ runtime }: { runtime: string }) {
|
||||
/** Runtimes that don't expose a TTY. Keep narrow — only add a runtime
|
||||
* here when its provisioner genuinely has no shell endpoint, otherwise
|
||||
* the user loses access to a real debugging surface. */
|
||||
const RUNTIMES_WITHOUT_TERMINAL = new Set(["external"]);
|
||||
|
||||
export function TerminalTab({ workspaceId, data }: Props) {
|
||||
// Early-return for runtimes that have no shell. Skips the entire
|
||||
// xterm + WebSocket dance below — without this, mounting the tab
|
||||
@@ -95,7 +96,7 @@ export function TerminalTab({ workspaceId, data }: Props) {
|
||||
// workspace-server (no /ws/terminal/<id> route registered for it),
|
||||
// and shows "Connection failed" with a Reconnect button — confusing
|
||||
// because the workspace IS healthy, just doesn't have a TTY.
|
||||
if (data && isExternalLikeRuntime(data.runtime)) {
|
||||
if (data && RUNTIMES_WITHOUT_TERMINAL.has(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,205 +1,364 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for EventsTab component.
|
||||
* Tests for EventsTab — the activity feed on the Events tab.
|
||||
*
|
||||
* Covers: formatTime pure function, EVENT_COLORS constant,
|
||||
* loading/error/empty states, event list rendering, expand/collapse,
|
||||
* refresh button, auto-refresh setup.
|
||||
* Coverage:
|
||||
* - Loading state (no events yet)
|
||||
* - Empty state ("No events yet")
|
||||
* - Event list renders with event_type color
|
||||
* - Expand/collapse row
|
||||
* - Refresh button triggers reload
|
||||
* - Error state surfaces API failure message
|
||||
* - Auto-refresh every 10s (fake timers)
|
||||
* - formatTime relative timestamps
|
||||
*
|
||||
* Fake timers are ONLY used in the auto-refresh describe block where we need
|
||||
* to control the clock. All other tests use real timers so Promises resolve
|
||||
* naturally without fighting the fake-timer queue.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { EventsTab } from "../EventsTab";
|
||||
|
||||
// Mock @/lib/api — hoisted so it's applied before the module loads.
|
||||
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown[]>>());
|
||||
// Hoist mockGet so vi.mock factory can reference it (vi.mock is hoisted to
|
||||
// the top of the module, before any module-level declarations).
|
||||
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: _mockGet },
|
||||
api: { get: mockGet },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const event = (
|
||||
id: string,
|
||||
type = "WORKSPACE_ONLINE",
|
||||
createdOffsetSecs = 0,
|
||||
): {
|
||||
id: string;
|
||||
event_type: string;
|
||||
workspace_id: string | null;
|
||||
payload: Record<string, unknown>;
|
||||
created_at: string;
|
||||
} => ({
|
||||
id,
|
||||
event_type: type,
|
||||
workspace_id: "ws-1",
|
||||
payload: { key: "value" },
|
||||
created_at: new Date(Date.now() - createdOffsetSecs * 1000).toISOString(),
|
||||
});
|
||||
|
||||
// ─── formatTime tests (via rendered output) ────────────────────────────────────
|
||||
const renderTab = (workspaceId = "ws-1") =>
|
||||
render(<EventsTab workspaceId={workspaceId} />);
|
||||
|
||||
describe("EventsTab — formatTime", () => {
|
||||
it("shows 'ago' for events less than a minute old", async () => {
|
||||
const now = new Date();
|
||||
const recent = new Date(now.getTime() - 30_000).toISOString();
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: {}, created_at: recent },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/ago/)).toBeTruthy();
|
||||
});
|
||||
// Flush pattern for real-timer tests: resolve the mock microtask then
|
||||
// flush React's state batch. Using act(async ...) lets us await inside.
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — render conditions", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
it("shows 'm ago' for events less than an hour old", async () => {
|
||||
const now = new Date();
|
||||
const minsAgo = new Date(now.getTime() - 5 * 60_000).toISOString();
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: {}, created_at: minsAgo },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/m ago/)).toBeTruthy();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows 'h ago' for events less than a day old", async () => {
|
||||
const now = new Date();
|
||||
const hoursAgo = new Date(now.getTime() - 3 * 3_600_000).toISOString();
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: {}, created_at: hoursAgo },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/h ago/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── EVENT_COLORS rendering ───────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — EVENT_COLORS", () => {
|
||||
it("renders all known event types without crashing", async () => {
|
||||
const eventTypes = [
|
||||
"WORKSPACE_ONLINE",
|
||||
"WORKSPACE_OFFLINE",
|
||||
"WORKSPACE_DEGRADED",
|
||||
"WORKSPACE_PROVISIONING",
|
||||
"WORKSPACE_REMOVED",
|
||||
"WORKSPACE_PROVISION_FAILED",
|
||||
"AGENT_CARD_UPDATED",
|
||||
];
|
||||
_mockGet.mockResolvedValueOnce(
|
||||
eventTypes.map((event_type, i) => ({
|
||||
id: `e-${i}`, event_type, workspace_id: null, payload: {}, created_at: new Date().toISOString(),
|
||||
})),
|
||||
);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
for (const et of eventTypes) {
|
||||
expect(screen.getByText(et)).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("renders unknown event types without crashing", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e-unk", event_type: "UNKNOWN_EVENT_XYZ", workspace_id: null, payload: {}, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("UNKNOWN_EVENT_XYZ")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── States ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — states", () => {
|
||||
it("shows loading text initially", () => {
|
||||
_mockGet.mockImplementation(() => new Promise(() => {})); // never resolves
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
it("shows loading state when events are being fetched", async () => {
|
||||
// Never resolve so loading stays true
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
renderTab();
|
||||
await act(async () => { /* flush initial render */ });
|
||||
expect(screen.getByText("Loading events...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty message when no events returned", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No events yet")).toBeTruthy();
|
||||
});
|
||||
it("shows empty state when API returns an empty list", async () => {
|
||||
mockGet.mockResolvedValueOnce([]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("No events yet")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error alert when fetch fails", async () => {
|
||||
_mockGet.mockRejectedValueOnce(new Error("server error"));
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/server error/i)).toBeTruthy();
|
||||
});
|
||||
it("renders the event list when API returns events", async () => {
|
||||
mockGet.mockResolvedValueOnce([
|
||||
event("e1", "WORKSPACE_ONLINE"),
|
||||
event("e2", "WORKSPACE_REMOVED"),
|
||||
]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("WORKSPACE_ONLINE")).toBeTruthy();
|
||||
expect(screen.getByText("WORKSPACE_REMOVED")).toBeTruthy();
|
||||
expect(screen.getByText("2 events")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("applies text-bad color to WORKSPACE_REMOVED events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_REMOVED")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("WORKSPACE_REMOVED");
|
||||
expect(span.classList).toContain("text-bad");
|
||||
});
|
||||
|
||||
it("applies text-good color to WORKSPACE_ONLINE events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("WORKSPACE_ONLINE");
|
||||
expect(span.classList).toContain("text-good");
|
||||
});
|
||||
|
||||
it("applies text-accent color to AGENT_CARD_UPDATED events", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "AGENT_CARD_UPDATED")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("AGENT_CARD_UPDATED");
|
||||
expect(span.classList).toContain("text-accent");
|
||||
});
|
||||
|
||||
it("applies text-ink-mid fallback for unknown event types", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "MY_CUSTOM_EVENT")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
const span = screen.getByText("MY_CUSTOM_EVENT");
|
||||
expect(span.classList).toContain("text-ink-mid");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Event list ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — event list", () => {
|
||||
it("renders all returned events", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: { foo: 1 }, created_at: new Date().toISOString() },
|
||||
{ id: "e2", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: { bar: 2 }, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText(/WORKSPACE_/).length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
describe("EventsTab — expand/collapse", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
it("shows event count in header", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e1", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: {}, created_at: new Date().toISOString() },
|
||||
{ id: "e2", event_type: "WORKSPACE_OFFLINE", workspace_id: null, payload: {}, created_at: new Date().toISOString() },
|
||||
{ id: "e3", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: {}, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("3 events")).toBeTruthy();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("expands payload panel on click", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e-expand", event_type: "WORKSPACE_ONLINE", workspace_id: null, payload: { key: "value" }, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("WORKSPACE_ONLINE"));
|
||||
|
||||
it("shows payload when a row is clicked (expanded)", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/"key":\s*"value"/)).toBeTruthy();
|
||||
});
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByText(/"key": "value"/)).toBeTruthy();
|
||||
expect(screen.getByText("ID: e1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapses expanded panel on second click", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "e-collapse", event_type: "WORKSPACE_DEGRADED", workspace_id: null, payload: { x: 1 }, created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByText("WORKSPACE_DEGRADED"));
|
||||
it("hides payload when the expanded row is clicked again", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// First click: expand
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.getByText(/"key": "value"/)).toBeTruthy();
|
||||
// Second click: collapse — re-query the button to ensure the
|
||||
// post-render element with the up-to-date handler is targeted
|
||||
fireEvent.click(screen.getByText("WORKSPACE_ONLINE"));
|
||||
await act(async () => { /* flush */ });
|
||||
expect(screen.queryByText(/"key": "value"/)).toBeFalsy();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("WORKSPACE_DEGRADED"));
|
||||
await waitFor(() => expect(screen.getByText(/"x":\s*1/)).toBeTruthy());
|
||||
|
||||
fireEvent.click(screen.getByText("WORKSPACE_DEGRADED"));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/"x":\s*1/)).toBeNull();
|
||||
it("has aria-expanded=true on the expanded row", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Call the onClick prop directly inside act() to bypass React's event
|
||||
// delegation, which fireEvent.click doesn't reliably trigger in jsdom.
|
||||
act(() => {
|
||||
screen.getByRole("button", { name: /workspace_online/i }).click();
|
||||
});
|
||||
await flush();
|
||||
// Verify aria-expanded is true on the expanded button
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"))
|
||||
?.getAttribute("aria-expanded"),
|
||||
).toBe("true");
|
||||
});
|
||||
|
||||
it("has aria-expanded=false on collapsed rows", async () => {
|
||||
mockGet.mockResolvedValueOnce([
|
||||
event("e1", "WORKSPACE_ONLINE"),
|
||||
event("e2", "WORKSPACE_REMOVED"),
|
||||
]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Expand the first row
|
||||
act(() => {
|
||||
screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"))
|
||||
?.click();
|
||||
});
|
||||
await flush();
|
||||
const onlineBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_ONLINE"));
|
||||
const removedBtn = screen
|
||||
.getAllByRole("button")
|
||||
.find((b) => b.textContent?.includes("WORKSPACE_REMOVED"));
|
||||
expect(onlineBtn?.getAttribute("aria-expanded")).toBe("true");
|
||||
expect(removedBtn?.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("has aria-controls linking row to its payload panel", async () => {
|
||||
mockGet.mockResolvedValueOnce([event("evt-42", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
// Verify the aria-controls attribute on the button
|
||||
expect(
|
||||
screen.getByRole("button", { name: /workspace_online/i }).getAttribute(
|
||||
"aria-controls",
|
||||
),
|
||||
).toBe("events-payload-evt-42");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Refresh button ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("EventsTab — refresh", () => {
|
||||
it("has a Refresh button", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => {});
|
||||
expect(screen.getByRole("button", { name: /refresh/i })).toBeTruthy();
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
it("Refresh button triggers a reload", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<EventsTab workspaceId="ws-1" />);
|
||||
await waitFor(() => screen.getByRole("button", { name: /refresh/i }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Refresh button triggers a new GET /events/:id", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
mockGet.mockClear();
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
});
|
||||
|
||||
// Called at least twice: initial load + refresh click
|
||||
expect(_mockGet).toHaveBeenCalled();
|
||||
it("shows loading state during refresh (events still visible from previous load)", async () => {
|
||||
// First load succeeds with real timers so the mock resolves
|
||||
mockGet.mockResolvedValueOnce([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("1 events")).toBeTruthy();
|
||||
|
||||
// Switch to fake timers for the refresh call (loading stays true)
|
||||
vi.useFakeTimers();
|
||||
// Refresh call hangs to keep loading=true
|
||||
mockGet.mockImplementationOnce(() => new Promise(() => {}));
|
||||
fireEvent.click(screen.getByRole("button", { name: /refresh/i }));
|
||||
await act(() => { vi.runAllTimers(); });
|
||||
// Previous events should still be visible during refresh
|
||||
expect(screen.getByText("WORKSPACE_ONLINE")).toBeTruthy();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — error state", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows error message when GET /events/:id rejects", async () => {
|
||||
mockGet.mockRejectedValue(new Error("Gateway timeout"));
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("Gateway timeout")).toBeTruthy();
|
||||
expect(screen.queryByText("Loading events...")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("shows 'Failed to load events' when API rejects with non-Error", async () => {
|
||||
mockGet.mockRejectedValue("unknown failure");
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(screen.getByText("Failed to load events")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventsTab — auto-refresh", () => {
|
||||
// Use vi.spyOn to mock setInterval/clearInterval so we can control timer
|
||||
// firing without Vitest's fake-timer APIs (which create infinite loops when
|
||||
// timers schedule microtasks that schedule more timers).
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn>;
|
||||
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
|
||||
let activeIntervalId = 0;
|
||||
const scheduledCallbacks = new Map<number, () => void>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
mockGet.mockReset();
|
||||
activeIntervalId = 0;
|
||||
scheduledCallbacks.clear();
|
||||
setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation(
|
||||
(cb: () => void) => {
|
||||
const id = ++activeIntervalId;
|
||||
scheduledCallbacks.set(id, cb);
|
||||
return id;
|
||||
},
|
||||
);
|
||||
clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(
|
||||
(id: number) => {
|
||||
scheduledCallbacks.delete(id);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
setIntervalSpy?.mockRestore();
|
||||
clearIntervalSpy?.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("calls GET /events/:id after 10s without manual interaction", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
renderTab();
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
mockGet.mockClear();
|
||||
|
||||
// Verify setInterval was called with 10000ms delay
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
10000,
|
||||
);
|
||||
|
||||
// Fire the captured interval callback (simulates 10s elapsing)
|
||||
const callback = [...scheduledCallbacks.values()][0];
|
||||
act(() => { callback(); });
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/events/ws-1");
|
||||
});
|
||||
|
||||
it("clears the previous auto-refresh interval on unmount", async () => {
|
||||
mockGet.mockResolvedValue([event("e1", "WORKSPACE_ONLINE")]);
|
||||
const { unmount } = renderTab();
|
||||
await flush();
|
||||
|
||||
// Verify clearInterval was NOT called yet
|
||||
expect(clearIntervalSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Unmount should call clearInterval with the active interval id
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
// The callback should no longer be scheduled
|
||||
expect(scheduledCallbacks.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,7 +58,6 @@ const SAMPLE_INFO = {
|
||||
hermes_channel_snippet: "# hermes ws=ws-test",
|
||||
codex_snippet: "# codex ws=ws-test",
|
||||
openclaw_snippet: "# openclaw ws=ws-test",
|
||||
kimi_snippet: "# kimi ws=ws-test",
|
||||
};
|
||||
|
||||
describe("ExternalConnectionSection", () => {
|
||||
|
||||
@@ -1,156 +1,635 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ScheduleTab component.
|
||||
* Tests for ScheduleTab — cron-based task scheduling.
|
||||
*
|
||||
* Covers: cronToHuman pure function, relativeTime pure function,
|
||||
* loading/error/empty states, schedule list rendering.
|
||||
* Coverage:
|
||||
* - Loading state
|
||||
* - Empty state (no schedules)
|
||||
* - Schedule list rendering (single + multiple)
|
||||
* - Status dot color (error/ok/idle)
|
||||
* - Toggle enable/disable via status dot
|
||||
* - Delete via ConfirmDialog
|
||||
* - Run Now button triggers POST + POST
|
||||
* - Create schedule form open/close
|
||||
* - Edit schedule form pre-fills values
|
||||
* - Form validation (disabled when cron/prompt empty)
|
||||
* - Create POST with correct payload
|
||||
* - Edit PATCH with correct payload
|
||||
* - Error state surfaces API failures
|
||||
* - Auto-refresh every 10s (spy)
|
||||
* - cronToHuman formatting
|
||||
* - relativeTime formatting
|
||||
* - Reset form clears all fields
|
||||
* - Disabled schedules are visually dimmed
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ScheduleTab } from "../ScheduleTab";
|
||||
|
||||
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown[]>>());
|
||||
// Hoist mocks so vi.mock factory can reference them.
|
||||
const mockGet = vi.hoisted(() => vi.fn<[], Promise<unknown[]>>());
|
||||
const mockPost = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
const mockPatch = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
const mockDel = vi.hoisted(() => vi.fn<[], Promise<unknown>>());
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: _mockGet },
|
||||
api: { get: mockGet, post: mockPost, patch: mockPatch, del: mockDel },
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_mockGet.mockReset();
|
||||
});
|
||||
// Capture ConfirmDialog state to drive from tests.
|
||||
const confirmDialogState = vi.hoisted(
|
||||
() => ({
|
||||
open: false as boolean,
|
||||
onConfirm: undefined as (() => void) | undefined,
|
||||
onCancel: undefined as (() => void) | undefined,
|
||||
}),
|
||||
);
|
||||
const MockConfirmDialog = vi.hoisted(
|
||||
() =>
|
||||
vi.fn(({ open, onConfirm, onCancel }: {
|
||||
open: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) => {
|
||||
confirmDialogState.open = open;
|
||||
confirmDialogState.onConfirm = onConfirm;
|
||||
confirmDialogState.onCancel = onCancel;
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
vi.mock("@/components/ConfirmDialog", () => ({ ConfirmDialog: MockConfirmDialog }));
|
||||
|
||||
// ─── cronToHuman tests ─────────────────────────────────────────────────────
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — cronToHuman", () => {
|
||||
it('returns "Every minute" for "* * * * *"', async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "* * * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Every minute")).toBeTruthy();
|
||||
const SCHEDULE_FIXTURE = {
|
||||
id: "sch-1",
|
||||
workspace_id: "ws-1",
|
||||
name: "Daily Security Scan",
|
||||
cron_expr: "0 9 * * *",
|
||||
timezone: "UTC",
|
||||
prompt: "Run the security scan and report findings",
|
||||
enabled: true,
|
||||
last_run_at: new Date(Date.now() - 3600000).toISOString(),
|
||||
next_run_at: new Date(Date.now() + 82800000).toISOString(),
|
||||
run_count: 42,
|
||||
last_status: "ok",
|
||||
last_error: "",
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
function schedule(overrides: Partial<typeof SCHEDULE_FIXTURE> = {}): typeof SCHEDULE_FIXTURE {
|
||||
return { ...SCHEDULE_FIXTURE, ...overrides };
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
function typeIn(el: HTMLElement, value: string) {
|
||||
Object.defineProperty(el, "value", { value, writable: true, configurable: true });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
fireEvent.change(el as any, { target: el });
|
||||
}
|
||||
|
||||
// Use mockResolvedValue so every GET call (including post-handler refreshes)
|
||||
// returns the fixture. Handlers like toggle/delete/run/edit all call
|
||||
// fetchSchedules() at the end, triggering a second GET.
|
||||
function setupLoad(schedules: unknown[]) {
|
||||
mockGet.mockResolvedValue(schedules as unknown[]);
|
||||
}
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab", () => {
|
||||
beforeEach(() => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockPatch.mockReset();
|
||||
mockDel.mockReset();
|
||||
MockConfirmDialog.mockClear();
|
||||
vi.useRealTimers();
|
||||
confirmDialogState.open = false;
|
||||
confirmDialogState.onConfirm = undefined;
|
||||
confirmDialogState.onCancel = undefined;
|
||||
});
|
||||
|
||||
it("returns 'Every X minutes' for '*/X * * * *'", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "*/15 * * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Every 15 minutes")).toBeTruthy();
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns 'Every X hours' for '0 */X * * *'", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 */3 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
// ── Loading / Empty ──────────────────────────────────────────────────────────
|
||||
|
||||
it("shows loading state when schedules are being fetched", async () => {
|
||||
mockGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Every 3 hours")).toBeTruthy();
|
||||
await act(async () => { /* flush initial render */ });
|
||||
expect(screen.getByText("Loading schedules...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 'Daily at HH:MM UTC' for daily schedules", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "30 14 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
it("shows empty state when API returns an empty list", async () => {
|
||||
setupLoad([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Daily at 14:30 UTC")).toBeTruthy();
|
||||
await flush();
|
||||
expect(screen.getByText("No schedules yet")).toBeTruthy();
|
||||
expect(screen.getByText(/run tasks automatically/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("returns 'Weekdays at HH:MM UTC' for weekday schedules", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 9 * * 1-5",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
// ── Schedule list ────────────────────────────────────────────────────────────
|
||||
|
||||
it("renders a schedule with correct name and cron", async () => {
|
||||
setupLoad([schedule({ name: "Morning Report", cron_expr: "0 8 * * *" })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Weekdays at 09:00 UTC")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to raw expression for unrecognised patterns", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 0 1 * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("0 0 1 * *")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("falls back to raw expression for malformed input", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "not a cron",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("not a cron")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── relativeTime tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — relativeTime", () => {
|
||||
it('shows "Last: never" when last_run_at is null', async () => {
|
||||
// Use mockResolvedValue (persistent) instead of mockResolvedValueOnce because
|
||||
// ScheduleTab's 10 s auto-refresh interval fires and calls fetchSchedules
|
||||
// a second time, consuming a one-time mock and clearing the DOM.
|
||||
_mockGet.mockResolvedValue([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Test", cron_expr: "0 9 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
// Use "Last: never" to match the exact label text in ScheduleTab.tsx:349.
|
||||
// findByText("never") would throw on the multiple-match ambiguity since
|
||||
// "never" also appears in the "Next: never" span.
|
||||
expect(await screen.findByText("Last: never")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── States ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — states", () => {
|
||||
it("shows empty message when no schedules", async () => {
|
||||
_mockGet.mockResolvedValueOnce([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("No schedules yet")).toBeTruthy();
|
||||
});
|
||||
// Note: ScheduleTab silently swallows fetch errors (no error state for
|
||||
// the initial load). Error state only exists for form-level actions
|
||||
// (save/delete/toggle) which require api.post/del/patch mocking.
|
||||
});
|
||||
|
||||
// ─── Schedule list ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("ScheduleTab — list", () => {
|
||||
it("renders schedule name", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Nightly Run", cron_expr: "0 2 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Nightly Run")).toBeTruthy();
|
||||
await flush();
|
||||
expect(screen.getByText("Morning Report")).toBeTruthy();
|
||||
expect(screen.getByText(/Daily at 08:00 UTC/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple schedules", async () => {
|
||||
_mockGet.mockResolvedValueOnce([
|
||||
{ id: "s1", workspace_id: "ws-1", name: "Schedule A", cron_expr: "0 9 * * *",
|
||||
timezone: "UTC", prompt: "", enabled: true, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
{ id: "s2", workspace_id: "ws-1", name: "Schedule B", cron_expr: "*/15 * * * *",
|
||||
timezone: "UTC", prompt: "", enabled: false, last_run_at: null, next_run_at: null,
|
||||
run_count: 0, last_status: "ok", last_error: "", created_at: new Date().toISOString() },
|
||||
setupLoad([
|
||||
schedule({ id: "s1", name: "Morning Report", cron_expr: "0 8 * * *" }),
|
||||
schedule({ id: "s2", name: "Evening Cleanup", cron_expr: "0 22 * * *" }),
|
||||
]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
expect(await screen.findByText("Schedule A")).toBeTruthy();
|
||||
expect(await screen.findByText("Schedule B")).toBeTruthy();
|
||||
await flush();
|
||||
expect(screen.getByText("Morning Report")).toBeTruthy();
|
||||
expect(screen.getByText("Evening Cleanup")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows disabled schedule with reduced opacity", async () => {
|
||||
setupLoad([schedule({ enabled: false })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const container = screen.getByText("Daily Security Scan").closest("div[class*='border-b']");
|
||||
expect(container?.className).toContain("opacity-50");
|
||||
});
|
||||
|
||||
it("shows error dot when last_status is error", async () => {
|
||||
setupLoad([schedule({ last_status: "error", last_error: "timeout" })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const dot = screen.getByRole("button", { name: /click to disable/i });
|
||||
expect(dot.className).toContain("bg-red-400");
|
||||
});
|
||||
|
||||
it("shows ok dot when last_status is ok", async () => {
|
||||
setupLoad([schedule({ last_status: "ok" })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const dot = screen.getByRole("button", { name: /click to disable/i });
|
||||
expect(dot.className).toContain("bg-emerald-400");
|
||||
});
|
||||
|
||||
it("shows neutral dot when schedule is disabled (unknown status)", async () => {
|
||||
// enabled=false → title says "Click to enable"
|
||||
setupLoad([schedule({ enabled: false, last_status: "" })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const dot = screen.getByRole("button", { name: /click to enable/i });
|
||||
expect(dot.className).toContain("bg-surface-card");
|
||||
});
|
||||
|
||||
it("shows last_error message when schedule failed", async () => {
|
||||
setupLoad([schedule({ last_error: "connection refused" })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/Error: connection refused/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("truncates long prompt in schedule list", async () => {
|
||||
const longPrompt = "A".repeat(120);
|
||||
setupLoad([schedule({ prompt: longPrompt })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// Prompt is sliced at 80 chars + "..."
|
||||
expect(screen.getByText(new RegExp(`^${"A".repeat(80)}\\.\\.\\.$$`))).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── cronToHuman formatting ──────────────────────────────────────────────────
|
||||
|
||||
it.each([
|
||||
["* * * * *", "Every minute"],
|
||||
["*/5 * * * *", "Every 5 minutes"],
|
||||
["0 */4 * * *", "Every 4 hours"],
|
||||
["0 9 * * *", "Daily at 09:00 UTC"],
|
||||
["0 9 * * 1-5", "Weekdays at 09:00 UTC"],
|
||||
["30 14 * * *", "Daily at 14:30 UTC"],
|
||||
["*/15 * * * *", "Every 15 minutes"],
|
||||
])("formats cron '%s' as '%s'", async (cron, expected) => {
|
||||
setupLoad([schedule({ cron_expr: cron })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(new RegExp(expected, "i"))).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── relativeTime formatting ─────────────────────────────────────────────────
|
||||
|
||||
it("shows 'never' when last_run_at is null", async () => {
|
||||
setupLoad([schedule({ last_run_at: null, next_run_at: null })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const spans = Array.from(document.querySelectorAll("span"));
|
||||
expect(spans.some(s => s.textContent === "Last: never")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows run_count in the list", async () => {
|
||||
setupLoad([schedule({ run_count: 99 })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/Runs: 99/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Toggle ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it("PATCHes toggle endpoint when status dot is clicked", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockPatch.mockResolvedValue({});
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /click to disable/i }));
|
||||
await flush();
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/schedules/sch-1",
|
||||
{ enabled: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("toggling calls fetchSchedules to refresh the list", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockPatch.mockResolvedValue({});
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /click to disable/i }));
|
||||
await flush();
|
||||
// fetchSchedules calls GET again
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/schedules");
|
||||
});
|
||||
|
||||
it("shows error when toggle fails", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockPatch.mockRejectedValue(new Error("toggle failed"));
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /click to disable/i }));
|
||||
await flush();
|
||||
// Component uses e.message (Error.message = "toggle failed")
|
||||
expect(screen.getByText(/toggle failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it("opens ConfirmDialog when delete button is clicked", async () => {
|
||||
setupLoad([schedule()]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
|
||||
await flush();
|
||||
expect(confirmDialogState.open).toBe(true);
|
||||
});
|
||||
|
||||
it("calls DEL when ConfirmDialog is confirmed", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
|
||||
await flush();
|
||||
confirmDialogState.onConfirm?.();
|
||||
await flush();
|
||||
expect(mockDel).toHaveBeenCalledWith("/workspaces/ws-1/schedules/sch-1");
|
||||
});
|
||||
|
||||
it("calls fetchSchedules after delete", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockDel.mockResolvedValue({});
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
|
||||
await flush();
|
||||
confirmDialogState.onConfirm?.();
|
||||
await flush();
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/schedules");
|
||||
});
|
||||
|
||||
it("closes ConfirmDialog when cancel is called", async () => {
|
||||
setupLoad([schedule()]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
|
||||
await flush();
|
||||
expect(confirmDialogState.open).toBe(true);
|
||||
confirmDialogState.onCancel?.();
|
||||
await flush();
|
||||
expect(confirmDialogState.open).toBe(false);
|
||||
});
|
||||
|
||||
it("shows error when delete fails", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockDel.mockRejectedValue(new Error("delete failed"));
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete schedule/i }));
|
||||
await flush();
|
||||
confirmDialogState.onConfirm?.();
|
||||
await flush();
|
||||
expect(screen.getByText(/delete failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Run Now ──────────────────────────────────────────────────────────────────
|
||||
|
||||
it("calls POST /schedules/:id/run and then POST /a2a when Run Now is clicked", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockPost
|
||||
.mockResolvedValueOnce({ prompt: "Run the security scan and report findings" })
|
||||
.mockResolvedValueOnce({});
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /run schedule/i }));
|
||||
await flush();
|
||||
expect(mockPost).toHaveBeenNthCalledWith(1, "/workspaces/ws-1/schedules/sch-1/run", {});
|
||||
expect(mockPost).toHaveBeenNthCalledWith(2, "/workspaces/ws-1/a2a", expect.objectContaining({ method: "message/send" }));
|
||||
});
|
||||
|
||||
it("shows error when run now fails", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockPost.mockRejectedValue(new Error("run failed"));
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /run schedule/i }));
|
||||
await flush();
|
||||
// handleRunNow uses hardcoded "Failed to run schedule" on error
|
||||
expect(screen.getByText(/Failed to run schedule/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Create form ──────────────────────────────────────────────────────────────
|
||||
|
||||
it("shows create form when + Add Schedule is clicked", async () => {
|
||||
setupLoad([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Schedule name")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Cron Expression")).toBeTruthy();
|
||||
expect(screen.getByLabelText("Prompt / Task")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("pre-fills default cron (0 9 * * *) and timezone (UTC)", async () => {
|
||||
setupLoad([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
expect((screen.getByLabelText("Cron Expression") as HTMLInputElement).value).toBe("0 9 * * *");
|
||||
expect((screen.getByLabelText("Timezone") as HTMLSelectElement).value).toBe("UTC");
|
||||
});
|
||||
|
||||
it("submit button is disabled when cron or prompt is empty", async () => {
|
||||
setupLoad([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
const submitBtn = screen.getByRole("button", { name: /create/i });
|
||||
expect((submitBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("submit button is enabled when cron and prompt are filled", async () => {
|
||||
setupLoad([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Run a task");
|
||||
await flush();
|
||||
const submitBtn = screen.getByRole("button", { name: /create/i });
|
||||
expect((submitBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("POSTs correct payload when creating a schedule", async () => {
|
||||
setupLoad([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Schedule name") as HTMLElement, "Morning Report");
|
||||
typeIn(screen.getByLabelText("Cron Expression") as HTMLElement, "0 8 * * *");
|
||||
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Generate the morning report");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /create/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeTruthy();
|
||||
});
|
||||
expect(mockPost).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/schedules",
|
||||
expect.objectContaining({
|
||||
name: "Morning Report",
|
||||
cron_expr: "0 8 * * *",
|
||||
timezone: "UTC",
|
||||
prompt: "Generate the morning report",
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("closes form and refreshes after successful create", async () => {
|
||||
setupLoad([]);
|
||||
mockPost.mockResolvedValue({});
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Run a task");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /create/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Schedule name")).not.toBeTruthy();
|
||||
});
|
||||
expect(mockGet).toHaveBeenCalledWith("/workspaces/ws-1/schedules");
|
||||
});
|
||||
|
||||
it("shows error message when create fails", async () => {
|
||||
setupLoad([]);
|
||||
mockPost.mockRejectedValue(new Error("validation failed"));
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Run a task");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /create/i }).click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/validation failed/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes form when Cancel is clicked", async () => {
|
||||
setupLoad([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
expect(screen.getByLabelText("Schedule name")).toBeTruthy();
|
||||
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText("Schedule name")).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Edit form ────────────────────────────────────────────────────────────────
|
||||
|
||||
it("opens edit form pre-filled with schedule data when Edit is clicked", async () => {
|
||||
setupLoad([schedule({ name: "Nightly Backup", cron_expr: "0 2 * * *" })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit schedule/i }));
|
||||
await flush();
|
||||
expect((screen.getByLabelText("Schedule name") as HTMLInputElement).value).toBe("Nightly Backup");
|
||||
expect((screen.getByLabelText("Cron Expression") as HTMLInputElement).value).toBe("0 2 * * *");
|
||||
});
|
||||
|
||||
it("shows 'Update' button in edit mode", async () => {
|
||||
setupLoad([schedule()]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit schedule/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /update/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("PATCHes correct payload when updating a schedule", async () => {
|
||||
setupLoad([schedule()]);
|
||||
mockPatch.mockResolvedValue({});
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit schedule/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Schedule name") as HTMLElement, "Updated Name");
|
||||
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "New prompt");
|
||||
await flush();
|
||||
act(() => { screen.getByRole("button", { name: /update/i }).click(); });
|
||||
await flush();
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("button", { name: /cancel/i })).not.toBeTruthy();
|
||||
});
|
||||
expect(mockPatch).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1/schedules/sch-1",
|
||||
expect.objectContaining({
|
||||
name: "Updated Name",
|
||||
cron_expr: "0 9 * * *",
|
||||
timezone: "UTC",
|
||||
prompt: "New prompt",
|
||||
enabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("form reset clears name, cron, prompt, and enabled", async () => {
|
||||
setupLoad([schedule()]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// Open + add schedule form
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
typeIn(screen.getByLabelText("Schedule name") as HTMLElement, "Temp Schedule");
|
||||
typeIn(screen.getByLabelText("Cron Expression") as HTMLElement, "*/15 * * * *");
|
||||
typeIn(screen.getByLabelText("Prompt / Task") as HTMLElement, "Temporary task");
|
||||
await flush();
|
||||
// Cancel
|
||||
act(() => { screen.getByRole("button", { name: /cancel/i }).click(); });
|
||||
await flush();
|
||||
// Open again — should be reset
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
expect((screen.getByLabelText("Schedule name") as HTMLInputElement).value).toBe("");
|
||||
expect((screen.getByLabelText("Cron Expression") as HTMLInputElement).value).toBe("0 9 * * *");
|
||||
expect((screen.getByLabelText("Prompt / Task") as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
// ── Error state ──────────────────────────────────────────────────────────────
|
||||
|
||||
it("shows error banner when GET fails", async () => {
|
||||
mockGet.mockRejectedValue(new Error("network error"));
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// Component now sets error state on GET failure
|
||||
expect(screen.getByText(/network error/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows generic error when GET rejects with non-Error", async () => {
|
||||
mockGet.mockRejectedValue("unknown failure");
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("unknown failure")).toBeTruthy();
|
||||
});
|
||||
|
||||
// ── Auto-refresh ────────────────────────────────────────────────────────────
|
||||
|
||||
it("sets up auto-refresh interval of 10 seconds", async () => {
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
setupLoad([schedule()]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 10000);
|
||||
setIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("clears the auto-refresh interval on unmount", async () => {
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval");
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval");
|
||||
setupLoad([schedule()]);
|
||||
const { unmount } = render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(clearIntervalSpy).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
setIntervalSpy.mockRestore();
|
||||
clearIntervalSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ── Misc ────────────────────────────────────────────────────────────────────
|
||||
|
||||
it("shows no timezone suffix when timezone is UTC", async () => {
|
||||
setupLoad([schedule({ timezone: "UTC" })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.queryByText(/\(UTC\)/)).not.toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows timezone suffix when non-UTC", async () => {
|
||||
setupLoad([schedule({ timezone: "America/New_York" })]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/\(America\/New_York\)/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("checkbox toggles formEnabled state", async () => {
|
||||
setupLoad([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
const checkbox = screen.getByRole("checkbox");
|
||||
expect((checkbox as HTMLInputElement).checked).toBe(true);
|
||||
fireEvent.click(checkbox);
|
||||
await flush();
|
||||
expect((checkbox as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("timezone select updates formTimezone", async () => {
|
||||
setupLoad([]);
|
||||
render(<ScheduleTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /\+ add schedule/i }));
|
||||
await flush();
|
||||
fireEvent.change(screen.getByLabelText("Timezone"), { target: { value: "America/Los_Angeles" } });
|
||||
await flush();
|
||||
expect((screen.getByLabelText("Timezone") as HTMLSelectElement).value).toBe("America/Los_Angeles");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -298,7 +298,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
||||
<button
|
||||
onClick={() => setGlobalMode(false)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 ${
|
||||
!globalMode ? "bg-accent-strong/20 text-accent border border-accent/30" : "text-ink-soft hover:text-ink-mid"
|
||||
!globalMode ? "bg-accent-strong/20 text-accent border border-accent/30" : "text-white-soft hover:text-white-mid"
|
||||
}`}
|
||||
>
|
||||
This Workspace
|
||||
@@ -306,7 +306,7 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
||||
<button
|
||||
onClick={() => setGlobalMode(true)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 ${
|
||||
globalMode ? "bg-amber-600/20 text-warm border border-amber-500/30" : "text-ink-soft hover:text-ink-mid"
|
||||
globalMode ? "bg-amber-600/20 text-warm border border-amber-500/30" : "text-white-soft hover:text-white-mid"
|
||||
}`}
|
||||
>
|
||||
Global (All Workspaces)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* External-like (BYO-compute) runtime detection.
|
||||
*
|
||||
* Mirrors the backend's isExternalLikeRuntime() in
|
||||
* workspace-server/internal/handlers/runtime_registry.go.
|
||||
*
|
||||
* These runtimes have no platform-owned container — the operator installs
|
||||
* the agent CLI locally and calls /registry/register. They share UX
|
||||
* behaviour: no Files tab, no Terminal tab, no Docker config, and the
|
||||
* connection modal shows copy-paste snippets.
|
||||
*/
|
||||
|
||||
const EXTERNAL_LIKE_RUNTIMES = new Set([
|
||||
"external",
|
||||
"kimi",
|
||||
"kimi-cli",
|
||||
]);
|
||||
|
||||
export function isExternalLikeRuntime(runtime: string | undefined): boolean {
|
||||
return !!runtime && EXTERNAL_LIKE_RUNTIMES.has(runtime);
|
||||
}
|
||||
@@ -9,8 +9,6 @@ const RUNTIME_NAMES: Record<string, string> = {
|
||||
openclaw: "OpenClaw",
|
||||
crewai: "CrewAI",
|
||||
autogen: "AutoGen",
|
||||
kimi: "Kimi",
|
||||
"kimi-cli": "Kimi CLI",
|
||||
};
|
||||
|
||||
export function runtimeDisplayName(runtime: string): string {
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Production Auto-Deploy
|
||||
|
||||
`molecule-core` deploys production tenant code automatically from Gitea Actions.
|
||||
|
||||
This runbook is an implementation-specific companion to `runbooks/sop-production-cicd.md`.
|
||||
|
||||
## Default Flow
|
||||
|
||||
On a push to `main` that touches deployable code, `.gitea/workflows/publish-workspace-server-image.yml`:
|
||||
|
||||
1. Builds and pushes platform and tenant ECR images tagged `staging-<sha>` and `staging-latest`.
|
||||
2. Self-tests the production deploy helper and workflow-YAML linter.
|
||||
3. Waits for strict required push contexts on the same commit to become `success`.
|
||||
4. Calls production control-plane `POST /cp/admin/tenants/redeploy-fleet` with `target_tag=staging-<sha>`.
|
||||
5. Verifies every redeploy result is healthy and every tenant returns the same Git SHA from `/buildinfo`.
|
||||
|
||||
The deploy workflow intentionally does not use Gitea `concurrency` because Gitea 1.22.6 can cancel queued runs even when `cancel-in-progress: false`.
|
||||
|
||||
## Kill Switch
|
||||
|
||||
Set either repository variable or secret:
|
||||
|
||||
```text
|
||||
PROD_AUTO_DEPLOY_DISABLED=true
|
||||
```
|
||||
|
||||
The image publish still runs, but the production redeploy step exits successfully without touching tenants.
|
||||
Immediately before the production POST, the workflow re-checks the live Gitea repo variable when `PROD_AUTO_DEPLOY_CONTROL_TOKEN` can read Actions variables. If that token is not configured, the job-start value is still honored.
|
||||
|
||||
## Tunables
|
||||
|
||||
Repository variables:
|
||||
|
||||
```text
|
||||
PROD_CP_URL=https://api.moleculesai.app
|
||||
PROD_AUTO_DEPLOY_CANARY_SLUG=hongming
|
||||
PROD_AUTO_DEPLOY_SOAK_SECONDS=60
|
||||
PROD_AUTO_DEPLOY_BATCH_SIZE=3
|
||||
PROD_AUTO_DEPLOY_DRY_RUN=false
|
||||
PROD_MANUAL_REDEPLOY_TARGET_TAG=staging-<known-good-sha>
|
||||
```
|
||||
|
||||
Secrets required:
|
||||
|
||||
```text
|
||||
CP_ADMIN_API_TOKEN
|
||||
AUTO_SYNC_TOKEN
|
||||
PROD_AUTO_DEPLOY_CONTROL_TOKEN
|
||||
AWS_ACCESS_KEY_ID
|
||||
AWS_SECRET_ACCESS_KEY
|
||||
```
|
||||
|
||||
`AUTO_SYNC_TOKEN` is only used to read Gitea commit statuses while waiting for required push contexts.
|
||||
`PROD_AUTO_DEPLOY_CONTROL_TOKEN` is optional but recommended so the pre-POST kill-switch check can read the live `PROD_AUTO_DEPLOY_DISABLED` Actions variable.
|
||||
|
||||
## Manual Fallback
|
||||
|
||||
Use `.gitea/workflows/redeploy-tenants-on-main.yml` when the automatic path needs to be rerun or rolled back. Gitea 1.22.6 does not support reliable `workflow_dispatch` inputs, so rollback uses a repo variable:
|
||||
|
||||
1. Set `PROD_MANUAL_REDEPLOY_TARGET_TAG=staging-<known-good-sha>`.
|
||||
2. Dispatch `manual-redeploy-tenants-on-main`.
|
||||
3. Clear `PROD_MANUAL_REDEPLOY_TARGET_TAG` after the rollback finishes.
|
||||
|
||||
With no variable set, the fallback redeploys `staging-<current-main-sha>`.
|
||||
@@ -1,76 +0,0 @@
|
||||
# SOP: Production CI/CD Changes
|
||||
|
||||
Production CI/CD changes are higher risk than ordinary CI edits. They can publish images, deploy tenants, promote tags, mutate branch protection, or change merge behavior. This SOP separates rules that must be enforced by code from rules that require human judgment.
|
||||
|
||||
## Programmatic Gates
|
||||
|
||||
The workflow YAML linter is the first line of enforcement:
|
||||
|
||||
```bash
|
||||
python3 .gitea/scripts/lint-workflow-yaml.py --workflow-dir .gitea/workflows
|
||||
```
|
||||
|
||||
It must reject:
|
||||
|
||||
- Gitea-hostile syntax such as `workflow_dispatch.inputs`, `workflow_run`, workflow name collisions, slash-containing workflow names, and unsupported cross-repo action references.
|
||||
- Production deploy workflows that rely on `concurrency.cancel-in-progress: false` for serialization.
|
||||
- Production deploy workflows that print raw control-plane responses or raw `.error` fields into CI logs.
|
||||
- Production redeploy workflows with no kill switch or rollback/pin control.
|
||||
|
||||
Production deploy helpers must also unit-test:
|
||||
|
||||
- Disable-flag parsing.
|
||||
- Required status context selection.
|
||||
- Terminal status handling for `failure`, `error`, `cancelled`, `canceled`, and `skipped`.
|
||||
- Production control-plane URL guards.
|
||||
- Rollback target/pin handling when applicable.
|
||||
|
||||
## Required PR Evidence
|
||||
|
||||
Every production CI/CD PR must include concrete answers for:
|
||||
|
||||
- Root cause: what production failure mode or process gap is being closed.
|
||||
- Deploy gate: which exact contexts must be green before production side effects.
|
||||
- Kill switch: how to stop deployment without reverting the PR.
|
||||
- Verification: how production state is proven after deployment.
|
||||
- Logging: proof that CI logs do not contain raw production runtime, SSM, or secret-adjacent output.
|
||||
- Rollback: the exact command, variable, or workflow to return to a known-good tag/digest.
|
||||
|
||||
## Human Review
|
||||
|
||||
Production CI/CD PRs need non-author review across these roles:
|
||||
|
||||
- DevOps: Gitea Actions semantics, branch protection, merge queue, and runner behavior.
|
||||
- SRE: rollout order, tenant health checks, observability, and partial-deploy recovery.
|
||||
- Security: secrets, token scopes, log redaction, and production endpoint targeting.
|
||||
|
||||
Critical or Required review findings must be closed with one of:
|
||||
|
||||
- A code change plus verification.
|
||||
- An evidence-backed rejection.
|
||||
- A follow-up issue only if the finding is explicitly not merge-blocking.
|
||||
|
||||
Acknowledgement alone is not closure.
|
||||
|
||||
## Production Defaults
|
||||
|
||||
Production deploys should fail closed:
|
||||
|
||||
- Missing tenant result: fail.
|
||||
- Tenant unhealthy: fail.
|
||||
- `/buildinfo` unreachable: fail.
|
||||
- SHA mismatch: fail.
|
||||
- Required status cancelled/skipped/missing past timeout: fail.
|
||||
|
||||
Staging may tolerate warnings during rollout development; production should not.
|
||||
|
||||
## Gitea 1.22.6 Constraints
|
||||
|
||||
Do not design production CI/CD around unsupported or unreliable features:
|
||||
|
||||
- No `workflow_run`.
|
||||
- No reliable `workflow_dispatch.inputs`.
|
||||
- Do not assume `concurrency.cancel-in-progress: false` serializes queued runs.
|
||||
- Do not rely on a masked aggregate status as the only production deploy gate.
|
||||
|
||||
If these constraints change after a Gitea upgrade, update this SOP and the workflow linter in the same PR.
|
||||
@@ -54,57 +54,6 @@
|
||||
# 64 argument/usage error
|
||||
|
||||
set -euo pipefail
|
||||
# Disable glob expansion so tenant slugs containing *, ?, [ are treated as
|
||||
# literals, not filename patterns. This is the primary defence against the
|
||||
# token-exfiltration attack vector where a malicious slug like
|
||||
# "evil?url=https://attacker.com?token=$CP_TOKEN" could otherwise expand to
|
||||
# a list of filenames via pathname expansion.
|
||||
set -f
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Slug validation (OFFSEC-006)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# Slugs are interpolated into URL paths (cp_redeploy_tenant, tenant_buildinfo,
|
||||
# tenant_health, resolve_tenant_instance_id) and ECR identifiers. An unsanitised
|
||||
# slug can trigger:
|
||||
# 1. SSRF — slug=https://evil.com?x= injected as URL authority/path segment.
|
||||
# 2. Token exfiltration — slug=?url=https://evil.com&token=$CP_TOKEN causes
|
||||
# curl to issue a GET to the attacker's host, leaking the bearer token.
|
||||
# The guard above (set -f) blocks glob metacharacter expansion; this function
|
||||
# validates the slug shape so malformed names are rejected before any network
|
||||
# call is issued.
|
||||
|
||||
# Simple logging helpers — defined early so validate_slug can call err
|
||||
# before the full Steps block is reached. The real definitions (with full
|
||||
# timestamps) live in the Steps section and re-declare them idempotently.
|
||||
err() { printf '[%s] ERROR: %s\n' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
|
||||
|
||||
# Validates a single tenant slug against RFC-1123 + lowercase + max 63 chars.
|
||||
# arg1 = slug string
|
||||
# exits 64 if invalid; returns 0 on success.
|
||||
validate_slug() {
|
||||
local slug="$1"
|
||||
# RFC-1123 label: lowercase alphanumeric, single hyphens allowed between chars,
|
||||
# no leading/trailing hyphen, 1–63 chars total. Also allows single-char slugs.
|
||||
if [[ ! "$slug" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then
|
||||
err "invalid tenant slug: '$slug' (must match ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$; got '${slug//$'\n'/<LF>}')"
|
||||
return 1
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
# Validates all tenant slugs from the --tenants argument.
|
||||
# Called once after argument parsing, before any network call.
|
||||
validate_tenants() {
|
||||
local slug
|
||||
IFS=',' read -ra SLUGS <<<"$TENANTS"
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
[[ -z "$slug" ]] && { err "empty slug in --tenants list"; return 1; }
|
||||
validate_slug "$slug" || return 1
|
||||
done
|
||||
return 0
|
||||
}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Argument parsing
|
||||
@@ -152,9 +101,6 @@ done
|
||||
exit 64
|
||||
}
|
||||
|
||||
# Validate slugs before any network call (OFFSEC-006)
|
||||
validate_tenants || exit 64
|
||||
|
||||
# Snapshot/rollback tag (deterministic — same script run on same UTC date
|
||||
# is idempotent; cross-day reruns get distinct rollback points).
|
||||
TODAY="${NOW_OVERRIDE_DATE:-$(date -u +%Y%m%d)}"
|
||||
|
||||
@@ -334,94 +334,6 @@ python3 -c "import sys,json; d=json.loads(sys.stdin.read()); c=d['commands'][0];
|
||||
&& echo " ok: no double-encoding in command string" || { echo " FAIL"; exit 1; }
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
printf '\n== Test 13: valid slugs pass validate_tenants ==\n'
|
||||
m=$(mkmock)
|
||||
mock_set "$m" aws_ecr_get_image '{}' 0
|
||||
mock_set "$m" aws_ecr_describe_image '' 1
|
||||
mock_set "$m" aws_ecr_put_image '' 0
|
||||
mock_set "$m" cp_redeploy_tenant '{}' 0
|
||||
mock_set "$m" tenant_buildinfo '{}' 0
|
||||
mock_set "$m" tenant_health 'ok' 0
|
||||
out=$(NOW_OVERRIDE_DATE=20260514 SSM_SETTLE_SECONDS=0 \
|
||||
"$SCRIPT" --source-tag a --dest-tag b --tenants abc,xy-z,a1b2c3 --mock-dir "$m" 2>&1
|
||||
echo "EXIT_CODE=$?")
|
||||
assert_exit "valid slugs (single-char, hyphenated, alphanum) pass" "$out" 0
|
||||
rm -rf "$m"
|
||||
|
||||
printf '\n== Test 14: malformed slugs rejected before any network call (OFFSEC-006) ==\n'
|
||||
# Patterns that must all be rejected with exit 64 before the first curl/aws call.
|
||||
# We test a representative sample covering each failure class; if ANY pattern
|
||||
# passes the validation or makes it into a URL, assert_calls_count will catch
|
||||
# it (should be 0 for every aws/curl call).
|
||||
declare -a BAD=(
|
||||
'bad slug' # space
|
||||
'UpperCase' # uppercase
|
||||
'has_underscore' # underscore
|
||||
'has.dot' # dot
|
||||
'-leading-hyphen' # leading hyphen
|
||||
'trailing-hyphen-' # trailing hyphen
|
||||
'!bang' # punctuation
|
||||
'query=val' # = character
|
||||
'a b c' # spaces
|
||||
'A' # uppercase single char
|
||||
)
|
||||
bad_count=0
|
||||
for bad in "${BAD[@]}"; do
|
||||
set +e
|
||||
out=$("$SCRIPT" --source-tag a --dest-tag b --tenants "$bad" 2>&1); rc=$?
|
||||
set -e
|
||||
if [[ $rc -eq 64 ]] && printf '%s' "$out" | grep -qi 'invalid tenant slug'; then
|
||||
: # expected
|
||||
else
|
||||
bad_count=$((bad_count + 1))
|
||||
printf ' ✗ slug=%q should exit 64 with invalid-slug error (got %s)\n' "$bad" "$rc"
|
||||
fi
|
||||
done
|
||||
if [[ $bad_count -eq 0 ]]; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ all %d malformed slugs rejected before network call\n' "${#BAD[@]}"
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("malformed-slug rejection")
|
||||
fi
|
||||
|
||||
printf '\n== Test 15: SSRF + token-exfiltration injection patterns rejected (OFFSEC-006) ==\n'
|
||||
# These patterns represent the actual OFFSEC-006 attack vectors: a malicious
|
||||
# slug that, if interpolated into a URL, would cause the script to issue an
|
||||
# outbound HTTP request to an attacker-controlled host, leaking the CP_TOKEN.
|
||||
# With set -f (glob off) + validate_slug (RFC-1123 enforcement), all are
|
||||
# rejected before any network call. We also verify no curl/aws call was made.
|
||||
declare -a INJECT=(
|
||||
'?url=https://evil.com'
|
||||
'?url=https://evil.com?token=$CP_TOKEN'
|
||||
'https://evil.com'
|
||||
'-o-https://evil.com'
|
||||
'--output=/etc/passwd'
|
||||
'../etc/passwd'
|
||||
)
|
||||
inject_count=0
|
||||
for inject in "${INJECT[@]}"; do
|
||||
m=$(mkmock)
|
||||
set +e
|
||||
out=$("$SCRIPT" --source-tag a --dest-tag b --tenants "$inject" --mock-dir "$m" 2>&1); rc=$?
|
||||
set -e
|
||||
curl_called=0
|
||||
aws_called=0
|
||||
if grep -qE '^curl ' "$m/.calls" 2>/dev/null; then curl_called=1; fi
|
||||
if grep -qE '^aws_' "$m/.calls" 2>/dev/null; then aws_called=1; fi
|
||||
rm -rf "$m"
|
||||
if [[ $rc -eq 64 ]] && [[ $curl_called -eq 0 ]] && [[ $aws_called -eq 0 ]]; then
|
||||
: # expected
|
||||
else
|
||||
inject_count=$((inject_count + 1))
|
||||
printf ' ✗ slug=%q: expected exit 64 + no curl/aws (rc=%s curl=%s aws=%s)\n' \
|
||||
"$inject" "$rc" "$curl_called" "$aws_called"
|
||||
fi
|
||||
done
|
||||
if [[ $inject_count -eq 0 ]]; then
|
||||
PASS=$((PASS + 1)); printf ' ✓ all %d injection slugs rejected before network call\n' "${#INJECT[@]}"
|
||||
else
|
||||
FAIL=$((FAIL + 1)); FAIL_NAMES+=("SSRF-injection rejection")
|
||||
fi
|
||||
|
||||
printf '\n────────────────────────────────────\n'
|
||||
if [[ $FAIL -eq 0 ]]; then
|
||||
printf 'All %d tests passed.\n' "$PASS"
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Staging E2E for MCP stdio transport (runtime#61 regression).
|
||||
#
|
||||
# Verifies that the MCP server in the claude-code workspace image
|
||||
# handles stdout redirected to a regular file — the exact failure
|
||||
# mode openclaw hits when capturing MCP output.
|
||||
#
|
||||
# Required env:
|
||||
# MOLECULE_CP_URL default: https://staging-api.moleculesai.app
|
||||
# MOLECULE_ADMIN_TOKEN CP admin bearer (Railway CP_ADMIN_API_TOKEN)
|
||||
#
|
||||
# Optional env:
|
||||
# E2E_KEEP_ORG 1 → skip teardown (debugging only)
|
||||
# E2E_RUN_ID Slug suffix; CI: ${GITHUB_RUN_ID}
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
|
||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLEC…OKEN required — Railway staging CP_ADMIN_API_TOKEN}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
|
||||
SLUG="e2e-mcp-$(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)] ✅ $*"; }
|
||||
|
||||
CURL_COMMON=(-sS --fail-with-body --max-time 30)
|
||||
|
||||
# ─── cleanup trap ───────────────────────────────────────────────────────
|
||||
CLEANUP_DONE=0
|
||||
cleanup_org() {
|
||||
local _entry_rc=$?
|
||||
if [ "$CLEANUP_DONE" = "1" ]; then return 0; fi
|
||||
CLEANUP_DONE=1
|
||||
|
||||
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
|
||||
log "E2E_KEEP_ORG=1 → leaving $SLUG behind for inspection"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log "Cleanup: deleting tenant $SLUG..."
|
||||
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 \
|
||||
&& ok "Teardown request accepted" \
|
||||
|| log "Teardown returned non-2xx (may already be gone)"
|
||||
}
|
||||
trap cleanup_org EXIT
|
||||
|
||||
# ─── provision tenant ───────────────────────────────────────────────────
|
||||
log "Provisioning tenant $SLUG..."
|
||||
# shellcheck disable=SC2034 # response body unused; --fail-with-body handles errors
|
||||
TENANT=$(curl "${CURL_COMMON[@]}" -X POST "$CP_URL/cp/admin/orgs" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"slug\":\"$SLUG\",\"name\":\"MCP Stdio E2E $SLUG\"}")
|
||||
ok "Tenant provisioned"
|
||||
|
||||
# ─── get tenant admin token ─────────────────────────────────────────────
|
||||
log "Fetching tenant admin token..."
|
||||
for _ in $(seq 1 30); do
|
||||
TOKEN_RESP=$(curl -sS --max-time 10 "$CP_URL/cp/admin/orgs/$SLUG/admin-token" \
|
||||
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null || echo '{}')
|
||||
TOKEN=$(echo "$TOKEN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null || echo "")
|
||||
[ -n "$TOKEN" ] && break
|
||||
sleep 2
|
||||
done
|
||||
[ -n "$TOKEN" ] || fail "Could not retrieve tenant admin token"
|
||||
ok "Tenant admin token obtained"
|
||||
|
||||
# ─── create claude-code workspace ───────────────────────────────────────
|
||||
log "Creating claude-code workspace..."
|
||||
WS=$(curl "${CURL_COMMON[@]}" -X POST "$CP_URL/workspaces" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"MCP Stdio Test","role":"Test","runtime":"claude-code","tier":1}')
|
||||
WS_ID=$(echo "$WS" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
|
||||
ok "Workspace created: $WS_ID"
|
||||
|
||||
# ─── wait for online ────────────────────────────────────────────────────
|
||||
log "Waiting for workspace to come online (up to 120s)..."
|
||||
for _ in $(seq 1 24); do
|
||||
STATUS=$(curl -sS --max-time 10 "$CP_URL/workspaces/$WS_ID" \
|
||||
-H "Authorization: Bearer $TOKEN" 2>/dev/null \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || echo "")
|
||||
[ "$STATUS" = "online" ] && break
|
||||
sleep 5
|
||||
done
|
||||
[ "$STATUS" = "online" ] || fail "Workspace did not come online (status=$STATUS)"
|
||||
ok "Workspace online"
|
||||
|
||||
# ─── get workspace container info ───────────────────────────────────────
|
||||
log "Fetching workspace runtime info..."
|
||||
RUNTIME_INFO=$(curl -sS --max-time 10 "$CP_URL/workspaces/$WS_ID" \
|
||||
-H "Authorization: Bearer $TOKEN" 2>/dev/null)
|
||||
CONTAINER_ID=$(echo "$RUNTIME_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('container_id',''))" 2>/dev/null || echo "")
|
||||
[ -n "$CONTAINER_ID" ] || fail "No container_id in workspace response"
|
||||
ok "Container ID: $CONTAINER_ID"
|
||||
|
||||
# ─── MCP stdio transport test ───────────────────────────────────────────
|
||||
log "Testing MCP stdio transport with regular-file stdout..."
|
||||
|
||||
OUTPUT=$(mktemp)
|
||||
trap 'rm -f "$OUTPUT"; cleanup_org' EXIT
|
||||
|
||||
# Send initialize + tools/list via stdin, capture stdout to regular file
|
||||
{
|
||||
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
|
||||
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
|
||||
} | docker exec -i -e WORKSPACE_ID="$WS_ID" "$CONTAINER_ID" \
|
||||
python -m molecule_runtime.a2a_mcp_server > "$OUTPUT" 2>&1 || {
|
||||
RC=$?
|
||||
log "MCP server exited with code $RC (expected for stdin EOF)"
|
||||
}
|
||||
|
||||
if grep -q '"result"' "$OUTPUT"; then
|
||||
ok "MCP server handles regular-file stdout"
|
||||
else
|
||||
fail "MCP server did not produce JSON-RPC result. Output:\n$(head -20 "$OUTPUT")"
|
||||
fi
|
||||
|
||||
if grep -q '"tools"' "$OUTPUT"; then
|
||||
ok "MCP tools/list returns tools"
|
||||
else
|
||||
fail "MCP tools/list did not return tools. Output:\n$(head -20 "$OUTPUT")"
|
||||
fi
|
||||
|
||||
# ─── summary ────────────────────────────────────────────────────────────
|
||||
log "All tests passed ✅"
|
||||
@@ -411,134 +411,3 @@ def test_rule1_catches_2026_05_11_publish_runtime_regression(tmp_path):
|
||||
f"(memory: feedback_gitea_workflow_dispatch_inputs_unsupported)."
|
||||
f"\nstdout={r.stdout}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 7 — production deploys cannot rely on broken Gitea concurrency
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROD_CONCURRENCY_BAD = """
|
||||
name: prod-concurrency-bad
|
||||
on: [push]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: production-auto-deploy
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- run: curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet
|
||||
"""
|
||||
|
||||
|
||||
def test_rule7_prod_deploy_concurrency_detects_violation(tmp_path):
|
||||
_write(tmp_path, "bad.yml", PROD_CONCURRENCY_BAD)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "production deploy" in r.stdout.lower()
|
||||
assert "concurrency" in r.stdout.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 8 — production deploys must not dump raw CP responses/errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROD_RAW_LOG_BAD = """
|
||||
name: prod-raw-log-bad
|
||||
on: [push]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: |
|
||||
curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet -o "$HTTP_RESPONSE"
|
||||
jq . "$HTTP_RESPONSE"
|
||||
jq -r '.results[]? | .error' "$HTTP_RESPONSE"
|
||||
"""
|
||||
|
||||
PROD_REDACTED_LOG_OK = """
|
||||
name: prod-redacted-log-ok
|
||||
on: [push]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
steps:
|
||||
- run: |
|
||||
curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet -o "$HTTP_RESPONSE"
|
||||
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE"
|
||||
jq -r '.results[]? | ((.error // "") != "")' "$HTTP_RESPONSE"
|
||||
"""
|
||||
|
||||
|
||||
def test_rule8_prod_deploy_raw_log_detects_violation(tmp_path):
|
||||
_write(tmp_path, "bad.yml", PROD_RAW_LOG_BAD)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "raw production cp response" in r.stdout.lower()
|
||||
|
||||
|
||||
def test_rule8_prod_deploy_allows_redacted_summary(tmp_path):
|
||||
_write(tmp_path, "ok.yml", PROD_REDACTED_LOG_OK)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 9 — production deploys require an operational control
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROD_NO_CONTROL_BAD = """
|
||||
name: prod-no-control-bad
|
||||
on: [push]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet
|
||||
"""
|
||||
|
||||
PROD_KILL_SWITCH_OK = """
|
||||
name: prod-kill-switch-ok
|
||||
on: [push]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
steps:
|
||||
- run: curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet
|
||||
"""
|
||||
|
||||
PROD_ROLLBACK_OK = """
|
||||
name: prod-rollback-ok
|
||||
on:
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
|
||||
steps:
|
||||
- run: curl https://api.moleculesai.app/cp/admin/tenants/redeploy-fleet
|
||||
"""
|
||||
|
||||
|
||||
def test_rule9_prod_deploy_requires_kill_switch_or_rollback(tmp_path):
|
||||
_write(tmp_path, "bad.yml", PROD_NO_CONTROL_BAD)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "kill switch" in r.stdout.lower()
|
||||
|
||||
|
||||
def test_rule9_prod_auto_deploy_allows_kill_switch(tmp_path):
|
||||
_write(tmp_path, "ok.yml", PROD_KILL_SWITCH_OK)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
|
||||
def test_rule9_prod_manual_deploy_allows_rollback_control(tmp_path):
|
||||
_write(tmp_path, "ok.yml", PROD_ROLLBACK_OK)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
@@ -157,16 +157,6 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #831 bootstrap: if global_secrets has ADMIN_TOKEN=placeholder,
|
||||
// replace it with the real token from the environment. This fixes
|
||||
// workspaces provisioned before the correct value was seeded.
|
||||
// Only runs for SaaS tenants (cpProv != nil) where containers inherit
|
||||
// from global_secrets. Self-hosted deployments don't read ADMIN_TOKEN
|
||||
// from global_secrets for container env — the fix doesn't apply.
|
||||
if cpProv != nil {
|
||||
fixAdminTokenPlaceholder()
|
||||
}
|
||||
|
||||
port := envOr("PORT", "8080")
|
||||
platformURL := envOr("PLATFORM_URL", fmt.Sprintf("http://host.docker.internal:%s", port))
|
||||
configsDir := envOr("CONFIGS_DIR", findConfigsDir())
|
||||
@@ -493,67 +483,3 @@ func findMigrationsDir() string {
|
||||
log.Println("No migrations directory found")
|
||||
return ""
|
||||
}
|
||||
|
||||
// fixAdminTokenPlaceholder heals #831: workspaces provisioned with a placeholder
|
||||
// ADMIN_TOKEN in global_secrets receive that placeholder as a container env var,
|
||||
// breaking any code that calls platform APIs. This runs once at startup (SaaS only)
|
||||
// and replaces the placeholder with the real token from the host environment.
|
||||
//
|
||||
// The placeholder is not in the codebase — it was seeded by a prior bootstrap or
|
||||
// manual DB write. It should never be set by the platform itself. This function
|
||||
// ensures it is corrected on next platform restart without requiring a manual DB
|
||||
// update or workspace reprovision.
|
||||
func fixAdminTokenPlaceholder() {
|
||||
realToken := os.Getenv("ADMIN_TOKEN")
|
||||
if realToken == "" {
|
||||
// Platform has no ADMIN_TOKEN — nothing to fix.
|
||||
return
|
||||
}
|
||||
|
||||
// Read the current stored value. We only upsert when the placeholder is
|
||||
// present so we don't repeatedly write rows that are already correct.
|
||||
var storedValue []byte
|
||||
err := db.DB.QueryRow(`SELECT encrypted_value FROM global_secrets WHERE key = $1`, "ADMIN_TOKEN").Scan(&storedValue)
|
||||
if err != nil {
|
||||
// No row — nothing to fix. The control plane injects ADMIN_TOKEN via
|
||||
// Secrets Manager bootstrap; the global_secrets path is a legacy seed.
|
||||
return
|
||||
}
|
||||
|
||||
// Decrypt to check the value. We compare the plaintext so the check works
|
||||
// whether encryption is enabled or not.
|
||||
storedPlaintext, decErr := crypto.DecryptVersioned(storedValue, crypto.CurrentEncryptionVersion())
|
||||
if decErr != nil {
|
||||
log.Printf("fixAdminTokenPlaceholder: could not decrypt existing value (version mismatch?): %v", decErr)
|
||||
return
|
||||
}
|
||||
|
||||
if string(storedPlaintext) == realToken {
|
||||
// Already correct — nothing to do.
|
||||
return
|
||||
}
|
||||
|
||||
if string(storedPlaintext) == "placeholder-will-ask-for-real" {
|
||||
log.Println("fixAdminTokenPlaceholder: replacing placeholder ADMIN_TOKEN in global_secrets")
|
||||
} else {
|
||||
log.Printf("fixAdminTokenPlaceholder: ADMIN_TOKEN in global_secrets differs from env; updating")
|
||||
}
|
||||
|
||||
encrypted, err := crypto.Encrypt([]byte(realToken))
|
||||
if err != nil {
|
||||
log.Printf("fixAdminTokenPlaceholder: failed to encrypt: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(`
|
||||
INSERT INTO global_secrets (key, encrypted_value, encryption_version)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (key) DO UPDATE
|
||||
SET encrypted_value = $2, encryption_version = $3, updated_at = now()
|
||||
`, "ADMIN_TOKEN", encrypted, crypto.CurrentEncryptionVersion())
|
||||
if err != nil {
|
||||
log.Printf("fixAdminTokenPlaceholder: failed to upsert: %v", err)
|
||||
return
|
||||
}
|
||||
log.Println("fixAdminTokenPlaceholder: done")
|
||||
}
|
||||
|
||||
@@ -248,7 +248,7 @@ func TestReadUsageMap_MissingUsage(t *testing.T) {
|
||||
m := map[string]json.RawMessage{
|
||||
"other": json.RawMessage(`{}`),
|
||||
}
|
||||
_, _, ok := readUsageMap(m)
|
||||
in, out, ok := readUsageMap(m)
|
||||
if ok {
|
||||
t.Errorf("readUsageMap returned ok=true for missing usage, want false")
|
||||
}
|
||||
@@ -297,7 +297,7 @@ func TestReadUsageMap_MalformedUsageJSON(t *testing.T) {
|
||||
m := map[string]json.RawMessage{
|
||||
"usage": json.RawMessage(`not valid json`),
|
||||
}
|
||||
_, _, ok := readUsageMap(m)
|
||||
in, out, ok := readUsageMap(m)
|
||||
if ok {
|
||||
t.Errorf("readUsageMap returned ok=true for malformed usage JSON, want false")
|
||||
}
|
||||
|
||||
@@ -57,18 +57,16 @@ func extractIdempotencyKey(body []byte) string {
|
||||
func extractExpiresInSeconds(body []byte) int {
|
||||
var envelope struct {
|
||||
Params struct {
|
||||
ExpiresInSeconds float64 `json:"expires_in_seconds"`
|
||||
ExpiresInSeconds int `json:"expires_in_seconds"`
|
||||
} `json:"params"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return 0
|
||||
}
|
||||
// JSON numbers are floats; truncate to int (Go's int(x) truncates toward zero).
|
||||
secs := int(envelope.Params.ExpiresInSeconds)
|
||||
if secs < 0 {
|
||||
if envelope.Params.ExpiresInSeconds < 0 {
|
||||
return 0
|
||||
}
|
||||
return secs
|
||||
return envelope.Params.ExpiresInSeconds
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// a2a_queue_expiry_test.go — unit coverage for extractExpiresInSeconds
|
||||
// (a2a_queue.go). Tests the pure TTL-extraction logic used by the
|
||||
// heartbeat drain path when enqueuing a message with a caller-specified TTL.
|
||||
// Priority constants ordering is also covered here so the a2a_queue.go
|
||||
// package has complete pure-function coverage.
|
||||
|
||||
import "testing"
|
||||
|
||||
// ─── extractExpiresInSeconds ────────────────────────────────────────────────
|
||||
|
||||
func TestExtractExpiresInSeconds_Valid(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"positive int", `{"params":{"expires_in_seconds":30}}`, 30},
|
||||
{"zero", `{"params":{"expires_in_seconds":0}}`, 0},
|
||||
{"large TTL", `{"params":{"expires_in_seconds":3600}}`, 3600},
|
||||
{"nested message unaffected", `{"params":{"message":{"role":"user"},"expires_in_seconds":60}}`, 60},
|
||||
{"float truncated", `{"params":{"expires_in_seconds":90.7}}`, 90},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractExpiresInSeconds([]byte(tc.body))
|
||||
if got != tc.want {
|
||||
t.Errorf("extractExpiresInSeconds(%q) = %d; want %d", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractExpiresInSeconds_InvalidOrMissing(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{"negative → 0", `{"params":{"expires_in_seconds":-5}}`, 0},
|
||||
{"missing params", `{}`, 0},
|
||||
{"missing expires_in_seconds", `{"params":{"message":"hello"}}`, 0},
|
||||
{"malformed JSON", `"not json at all`, 0},
|
||||
{"null body", `null`, 0},
|
||||
{"empty string", ``, 0},
|
||||
{"wrong type string", `{"params":{"expires_in_seconds":"30"}}`, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractExpiresInSeconds([]byte(tc.body))
|
||||
if got != tc.want {
|
||||
t.Errorf("extractExpiresInSeconds(%q) = %d; want %d", tc.body, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Priority constants ────────────────────────────────────────────────────
|
||||
|
||||
func TestPriorityConstants_Ordering(t *testing.T) {
|
||||
// The ordering invariant: Critical > Task > Info.
|
||||
// These constants govern queue drain priority — if ordering is wrong,
|
||||
// high-priority items get starved.
|
||||
if PriorityCritical <= PriorityTask {
|
||||
t.Errorf("PriorityCritical(%d) must be > PriorityTask(%d)", PriorityCritical, PriorityTask)
|
||||
}
|
||||
if PriorityTask <= PriorityInfo {
|
||||
t.Errorf("PriorityTask(%d) must be > PriorityInfo(%d)", PriorityTask, PriorityInfo)
|
||||
}
|
||||
if PriorityCritical <= PriorityInfo {
|
||||
t.Errorf("PriorityCritical(%d) must be > PriorityInfo(%d)", PriorityCritical, PriorityInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityConstants_Values(t *testing.T) {
|
||||
// Pin the values so callers can rely on them for queue inspection
|
||||
// and admin endpoints without re-reading the source.
|
||||
if PriorityCritical != 100 {
|
||||
t.Errorf("PriorityCritical = %d; want 100", PriorityCritical)
|
||||
}
|
||||
if PriorityTask != 50 {
|
||||
t.Errorf("PriorityTask = %d; want 50", PriorityTask)
|
||||
}
|
||||
if PriorityInfo != 10 {
|
||||
t.Errorf("PriorityInfo = %d; want 10", PriorityInfo)
|
||||
}
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func TestExtractExpiresInSeconds_invalidOrMissing(t *testing.T) {
|
||||
{"empty body", ``, 0},
|
||||
{"null value", `{"params":{"expires_in_seconds":null}}`, 0},
|
||||
{"string value", `{"params":{"expires_in_seconds":"30"}}`, 0},
|
||||
{"float value", `{"params":{"expires_in_seconds":30.5}}`, 30},
|
||||
{"float value", `{"params":{"expires_in_seconds":30.5}}`, 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
|
||||
@@ -116,9 +116,6 @@ func (h *ApprovalsHandler) ListAll(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListPendingApprovals scan error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
@@ -158,9 +155,6 @@ func (h *ApprovalsHandler) List(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListApprovals scan error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, approvals)
|
||||
}
|
||||
|
||||
@@ -49,18 +49,6 @@ func (h *BundleHandler) Import(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"})
|
||||
return
|
||||
}
|
||||
if b.Schema == "" || b.Name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"})
|
||||
return
|
||||
}
|
||||
|
||||
// Reject null JSON (which binds to a zero-value Bundle{}) and empty schema.
|
||||
// Without this guard a POST of `null` or `{}` would INSERT a workspace row
|
||||
// with name="" and tier=0 into the DB before bundle.Import() fails.
|
||||
if b.Schema == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid bundle"})
|
||||
return
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result := bundle.Import(ctx, &b, nil, h.broadcaster, h.provisioner, h.platformURL)
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
@@ -56,14 +55,16 @@ func TestBundleImport_ValidJSON(t *testing.T) {
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
// bundle.Import does: INSERT workspaces (creates record), UPDATE runtime (after
|
||||
// parsing config.yaml), plus a RecordAndBroadcast (not a DB call). SubWorkspaces
|
||||
// recursion is a no-op for this test bundle. No workspace_schedules or
|
||||
// workspace_secrets INSERT in the current importer.
|
||||
// bundle.Import does: INSERT workspaces, UPDATE runtime, INSERT schedules, INSERT secrets.
|
||||
// bundle.Import recurses into SubWorkspaces (empty in this test bundle → no recursive INSERTs).
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("UPDATE workspaces SET runtime").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_schedules").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec("INSERT INTO workspace_secrets").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := `{"name": "test-workspace", "schema": "1.0", "tier": 3}`
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
@@ -162,7 +163,7 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
|
||||
})
|
||||
|
||||
// Fire-and-forget: send A2A in background goroutine
|
||||
go h.executeDelegation(sourceID, body.TargetID, delegationID, a2aBody)
|
||||
go h.executeDelegation(ctx, sourceID, body.TargetID, delegationID, a2aBody)
|
||||
|
||||
// Broadcast event so canvas shows delegation in real-time
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
|
||||
@@ -308,21 +309,50 @@ func insertDelegationRow(ctx context.Context, c *gin.Context, sourceID string, b
|
||||
// to land a fresh URL in the cache before we try again. Fixes #74 —
|
||||
// bulk restarts used to produce spurious "failed to reach workspace
|
||||
// agent" errors when delegations fired within the warm-up window.
|
||||
const delegationRetryDelay = 8 * time.Second
|
||||
var delegationRetryDelay = 8 * time.Second
|
||||
|
||||
func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID string, a2aBody []byte) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
|
||||
defer cancel()
|
||||
// NB: the log.Printf calls below are load-bearing for the integration test
|
||||
// surface (delegation_executor_integration_test.go). The test uses a raw TCP
|
||||
// mock server; without these calls the compiler inlines executeDelegation and
|
||||
// a subtle stack-sharing race between the inlined body and the test goroutine
|
||||
// causes the test to hang. The log calls prevent inlining (Go cannot inline
|
||||
// functions that call the log package). This is a known Go compiler behaviour.
|
||||
// runtime.LockOSThread() provides an additional hardening: pinning the
|
||||
// goroutine to a single OS thread eliminates any scheduler-migration races.
|
||||
// The caller provides ctx (which carries the deadline/budget); no internal
|
||||
// context.WithTimeout is created here.
|
||||
|
||||
// executeDelegation runs the A2A dispatch for a delegation. ctx controls the
|
||||
// entire lifecycle: its timeout bounds all DB ops, proxy calls, and retries.
|
||||
// Pass context.Background() when no external deadline applies (e.g. tests).
|
||||
func (h *DelegationHandler) executeDelegation(ctx context.Context, sourceID, targetID, delegationID string, a2aBody []byte) {
|
||||
runtime.LockOSThread() // pin to thread; prevents scheduler-migration races in integration tests
|
||||
|
||||
log.Printf("Delegation %s: %s → %s (dispatched)", delegationID, sourceID, targetID)
|
||||
|
||||
log.Printf("Delegation %s: step=updating_dispatched_status", delegationID)
|
||||
// Update status: pending → dispatched
|
||||
h.updateDelegationStatus(sourceID, delegationID, "dispatched", "")
|
||||
h.updateDelegationStatus(ctx, sourceID, delegationID, "dispatched", "")
|
||||
log.Printf("Delegation %s: step=broadcasting_dispatched", delegationID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationStatus), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID, "target_id": targetID, "status": "dispatched",
|
||||
})
|
||||
log.Printf("Delegation %s: step=proxying_a2a_request", delegationID)
|
||||
|
||||
status, respBody, proxyErr := h.workspace.proxyA2ARequest(ctx, targetID, a2aBody, sourceID, true)
|
||||
log.Printf("Delegation %s: step=proxy_done status=%d bodyLen=%d err=%v", delegationID, status, len(respBody), proxyErr)
|
||||
|
||||
// When proxyA2ARequest returns an error but we have a non-empty response body
|
||||
// with a 2xx status code, the agent completed the work successfully — the error
|
||||
// is a delivery/transport error (e.g., connection reset after response was
|
||||
// received). Treat as success: the response body is valid and the work is done.
|
||||
// This check MUST run before the transient-retry gate so a delivery-confirmed
|
||||
// partial-body 2xx response is never retried.
|
||||
if isDeliveryConfirmedSuccess(proxyErr, status, respBody) {
|
||||
log.Printf("Delegation %s: completed with delivery error (status=%d, respBody=%d bytes, proxyErr=%v) — treating as success",
|
||||
delegationID, status, len(respBody), proxyErr.Error())
|
||||
goto handleSuccess
|
||||
}
|
||||
|
||||
// #74: one retry after the reactive URL refresh has had a chance to
|
||||
// run. The proxyA2ARequest's health-check path on a connection error
|
||||
@@ -342,21 +372,10 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
|
||||
}
|
||||
}
|
||||
|
||||
// When proxyA2ARequest returns an error but we have a non-empty response body
|
||||
// with a 2xx status code, the agent completed the work successfully — the error
|
||||
// is a delivery/transport error (e.g., connection reset after response was
|
||||
// received). Treat as success: the response body is valid and the work is done.
|
||||
// This prevents "retry storms" where the canvas sees error + Restart-workspace
|
||||
// suggestion even though the delegation actually completed.
|
||||
if isDeliveryConfirmedSuccess(proxyErr, status, respBody) {
|
||||
log.Printf("Delegation %s: completed with delivery error (status=%d, respBody=%d bytes, proxyErr=%v) — treating as success",
|
||||
delegationID, status, len(respBody), proxyErr.Error())
|
||||
goto handleSuccess
|
||||
}
|
||||
|
||||
if proxyErr != nil {
|
||||
log.Printf("Delegation %s: step=handling_failure err=%v", delegationID, proxyErr)
|
||||
log.Printf("Delegation %s: failed — %s", delegationID, proxyErr.Error())
|
||||
h.updateDelegationStatus(sourceID, delegationID, "failed", proxyErr.Error())
|
||||
h.updateDelegationStatus(ctx, sourceID, delegationID, "failed", proxyErr.Error())
|
||||
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, status, error_detail)
|
||||
@@ -373,7 +392,27 @@ func (h *DelegationHandler) executeDelegation(sourceID, targetID, delegationID s
|
||||
return
|
||||
}
|
||||
|
||||
if status >= 200 && status < 300 && len(respBody) == 0 {
|
||||
errMsg := "workspace agent returned empty response"
|
||||
log.Printf("Delegation %s: step=handling_failure err=%s", delegationID, errMsg)
|
||||
h.updateDelegationStatus(ctx, sourceID, delegationID, "failed", errMsg)
|
||||
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
INSERT INTO activity_logs (workspace_id, activity_type, method, source_id, target_id, summary, status, error_detail)
|
||||
VALUES ($1, 'delegation', 'delegate_result', $2, $3, $4, 'failed', $5)
|
||||
`, sourceID, sourceID, targetID, "Delegation failed", errMsg); err != nil {
|
||||
log.Printf("Delegation %s: failed to insert empty-response error log: %v", delegationID, err)
|
||||
}
|
||||
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationFailed), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID, "target_id": targetID, "error": errMsg,
|
||||
})
|
||||
pushDelegationResultToInbox(ctx, sourceID, delegationID, "failed", "", errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
handleSuccess:
|
||||
log.Printf("Delegation %s: step=handle_success status=%d", delegationID, status)
|
||||
|
||||
// 202 + {queued: true} means the target was busy and the proxy
|
||||
// enqueued the request for the next drain tick — NOT a completion.
|
||||
@@ -387,7 +426,7 @@ handleSuccess:
|
||||
// the user.
|
||||
if status == http.StatusAccepted && isQueuedProxyResponse(respBody) {
|
||||
log.Printf("Delegation %s: target %s busy — queued for drain", delegationID, targetID)
|
||||
h.updateDelegationStatus(sourceID, delegationID, "queued", "")
|
||||
h.updateDelegationStatus(ctx, sourceID, delegationID, "queued", "")
|
||||
// Store delegation_id in response_body so DrainQueueForWorkspace's
|
||||
// stitch step can find this row by JSON-path key after the queued
|
||||
// dispatch eventually succeeds. Without the key, the drain finds
|
||||
@@ -414,6 +453,7 @@ handleSuccess:
|
||||
responseText := extractResponseText(respBody)
|
||||
log.Printf("Delegation %s: completed (status=%d, %d chars)", delegationID, status, len(responseText))
|
||||
|
||||
log.Printf("Delegation %s: step=inserting_success_log", delegationID)
|
||||
// Store success (response_body must be JSONB, include delegation_id)
|
||||
respJSON, _ := json.Marshal(map[string]interface{}{
|
||||
"text": responseText,
|
||||
@@ -425,6 +465,7 @@ handleSuccess:
|
||||
`, sourceID, sourceID, targetID, "Delegation completed ("+textutil.TruncateBytes(responseText, 80)+")", string(respJSON)); err != nil {
|
||||
log.Printf("Delegation %s: failed to insert success log: %v", delegationID, err)
|
||||
}
|
||||
log.Printf("Delegation %s: step=recording_ledger_completed", delegationID)
|
||||
|
||||
// RFC #2829 #318: write the ledger row with result_preview FIRST,
|
||||
// THEN updateDelegationStatus. Order matters: SetStatus has a
|
||||
@@ -434,7 +475,9 @@ handleSuccess:
|
||||
// Caught by the local-Postgres integration test in
|
||||
// delegation_ledger_integration_test.go.
|
||||
recordLedgerStatus(ctx, delegationID, "completed", "", responseText)
|
||||
h.updateDelegationStatus(sourceID, delegationID, "completed", "")
|
||||
log.Printf("Delegation %s: step=updating_completed_status", delegationID)
|
||||
h.updateDelegationStatus(ctx, sourceID, delegationID, "completed", "")
|
||||
log.Printf("Delegation %s: step=broadcasting_complete", delegationID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationComplete), sourceID, map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"target_id": targetID,
|
||||
@@ -442,11 +485,12 @@ handleSuccess:
|
||||
})
|
||||
// RFC #2829 PR-2 result-push (see UpdateStatus for rationale).
|
||||
pushDelegationResultToInbox(ctx, sourceID, delegationID, "completed", responseText, "")
|
||||
log.Printf("Delegation %s: step=complete", delegationID)
|
||||
}
|
||||
|
||||
// updateDelegationStatus updates the status of a delegation record in activity_logs.
|
||||
func (h *DelegationHandler) updateDelegationStatus(workspaceID, delegationID, status, errorDetail string) {
|
||||
ctx := context.Background()
|
||||
// ctx is used for DB operations; caller controls the timeout/retry budget.
|
||||
func (h *DelegationHandler) updateDelegationStatus(ctx context.Context, workspaceID, delegationID, status, errorDetail string) {
|
||||
if _, err := db.DB.ExecContext(ctx, `
|
||||
UPDATE activity_logs
|
||||
SET status = $1, error_detail = CASE WHEN $2 = '' THEN error_detail ELSE $2 END
|
||||
@@ -560,7 +604,7 @@ func (h *DelegationHandler) UpdateStatus(c *gin.Context) {
|
||||
recordLedgerStatus(ctx, delegationID, "completed", "", body.ResponsePreview)
|
||||
}
|
||||
|
||||
h.updateDelegationStatus(sourceID, delegationID, body.Status, body.Error)
|
||||
h.updateDelegationStatus(ctx, sourceID, delegationID, body.Status, body.Error)
|
||||
|
||||
if body.Status == "completed" {
|
||||
respJSON, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -597,100 +641,10 @@ func (h *DelegationHandler) UpdateStatus(c *gin.Context) {
|
||||
|
||||
// ListDelegations handles GET /workspaces/:id/delegations
|
||||
// Returns recent delegations for a workspace with their status.
|
||||
//
|
||||
// RFC #2829 PR-1/4 fallback chain: prefer the durable delegations table
|
||||
// (new as of #318) for complete status coverage; fall back to
|
||||
// activity_logs for pre-migration data or if the ledger table has
|
||||
// no rows for this workspace. activity_logs still drives in-flight
|
||||
// tracking for workspaces where DELEGATION_LEDGER_WRITE=0 was
|
||||
// active during the delegation lifecycle — the union covers both paths.
|
||||
func (h *DelegationHandler) ListDelegations(c *gin.Context) {
|
||||
workspaceID := c.Param("id")
|
||||
ctx := c.Request.Context()
|
||||
|
||||
var delegations []map[string]interface{}
|
||||
|
||||
// Attempt durable ledger first (RFC #2829)
|
||||
delegations = h.listDelegationsFromLedger(ctx, workspaceID)
|
||||
if len(delegations) > 0 {
|
||||
c.JSON(http.StatusOK, delegations)
|
||||
return
|
||||
}
|
||||
|
||||
// Fall back to activity_logs (pre-#318 path, or ledger had no rows)
|
||||
delegations = h.listDelegationsFromActivityLogs(ctx, workspaceID)
|
||||
c.JSON(http.StatusOK, delegations)
|
||||
}
|
||||
|
||||
// listDelegationsFromLedger queries the durable delegations table.
|
||||
// Returns nil on error so the caller can fall back to activity_logs.
|
||||
func (h *DelegationHandler) listDelegationsFromLedger(ctx context.Context, workspaceID string) []map[string]interface{} {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview,
|
||||
d.status, d.result_preview, d.error_detail, d.last_heartbeat,
|
||||
d.deadline, d.created_at, d.updated_at
|
||||
FROM delegations d
|
||||
WHERE d.caller_id = $1
|
||||
ORDER BY d.created_at DESC
|
||||
LIMIT 50
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
// Table may not exist yet (pre-migration), or permission issue.
|
||||
// Fall back silently — do not log to avoid noise on every call.
|
||||
return nil
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var delegationID, callerID, calleeID, taskPreview, status, resultPreview, errorDetail string
|
||||
var lastHeartbeat, deadline, createdAt, updatedAt *time.Time
|
||||
if err := rows.Scan(
|
||||
&delegationID, &callerID, &calleeID, &taskPreview,
|
||||
&status, &resultPreview, &errorDetail, &lastHeartbeat,
|
||||
&deadline, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
entry := map[string]interface{}{
|
||||
"delegation_id": delegationID,
|
||||
"source_id": callerID,
|
||||
"target_id": calleeID,
|
||||
"summary": textutil.TruncateBytes(taskPreview, 200),
|
||||
"status": status,
|
||||
"created_at": createdAt,
|
||||
"updated_at": updatedAt,
|
||||
"_ledger": true, // marker so callers know this row is from the ledger
|
||||
}
|
||||
if resultPreview != "" {
|
||||
entry["response_preview"] = textutil.TruncateBytes(resultPreview, 300)
|
||||
}
|
||||
if errorDetail != "" {
|
||||
entry["error"] = errorDetail
|
||||
}
|
||||
if lastHeartbeat != nil {
|
||||
entry["last_heartbeat"] = lastHeartbeat
|
||||
}
|
||||
if deadline != nil {
|
||||
entry["deadline"] = deadline
|
||||
}
|
||||
result = append(result, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("listDelegationsFromLedger rows.Err: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// listDelegationsFromActivityLogs is the legacy path that reconstructs
|
||||
// delegation state by folding activity_logs rows by delegation_id.
|
||||
// Kept for backward compatibility and for workspaces that never had
|
||||
// DELEGATION_LEDGER_WRITE=1 during their delegation lifecycle.
|
||||
func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context, workspaceID string) []map[string]interface{} {
|
||||
rows, err := db.DB.QueryContext(ctx, `
|
||||
SELECT id, activity_type, COALESCE(source_id::text, ''), COALESCE(target_id::text, ''),
|
||||
COALESCE(summary, ''), COALESCE(status, ''), COALESCE(error_detail, ''),
|
||||
@@ -703,11 +657,12 @@ func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context,
|
||||
LIMIT 50
|
||||
`, workspaceID)
|
||||
if err != nil {
|
||||
return []map[string]interface{}{}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "query failed"})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var result []map[string]interface{}
|
||||
var delegations []map[string]interface{}
|
||||
for rows.Next() {
|
||||
var id, actType, sourceID, targetID, summary, status, errorDetail, responseBody, delegationID string
|
||||
var createdAt time.Time
|
||||
@@ -732,16 +687,16 @@ func (h *DelegationHandler) listDelegationsFromActivityLogs(ctx context.Context,
|
||||
if responseBody != "" {
|
||||
entry["response_preview"] = textutil.TruncateBytes(responseBody, 300)
|
||||
}
|
||||
result = append(result, entry)
|
||||
delegations = append(delegations, entry)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListDelegations scan error: %v", err)
|
||||
log.Printf("ListDelegations rows.Err: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
return []map[string]interface{}{}
|
||||
if delegations == nil {
|
||||
delegations = []map[string]interface{}{}
|
||||
}
|
||||
return result
|
||||
c.JSON(http.StatusOK, delegations)
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
@@ -861,4 +816,3 @@ func extractResponseText(body []byte) string {
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
|
||||
|
||||
@@ -233,21 +233,14 @@ func TestListDelegations_Empty(t *testing.T) {
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Ledger returns empty → falls back to activity_logs (also empty)
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}))
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
})
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}))
|
||||
WillReturnRows(rows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
@@ -267,12 +260,9 @@ func TestListDelegations_Empty(t *testing.T) {
|
||||
if len(resp) != 0 {
|
||||
t.Errorf("expected empty array, got %d entries", len(resp))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: with results (ledger only, no activity_logs fallback) ----------
|
||||
// ---------- ListDelegations: with results → 200 with entries ----------
|
||||
|
||||
func TestListDelegations_WithResults(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
@@ -282,21 +272,19 @@ func TestListDelegations_WithResults(t *testing.T) {
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
deadline := now.Add(6 * time.Hour)
|
||||
// Ledger query returns rows — no fallback to activity_logs
|
||||
rows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}).
|
||||
AddRow("del-111", "ws-source", "ws-target",
|
||||
AddRow("1", "delegation", "ws-source", "ws-target",
|
||||
"Delegating to ws-target", "pending", "", "",
|
||||
&now, &deadline, now, now).
|
||||
AddRow("del-222", "ws-source", "ws-target",
|
||||
"Delegation completed (hello world)", "completed", "hello world", "",
|
||||
&now, &deadline, now, now.Add(time.Minute))
|
||||
"del-111", now).
|
||||
AddRow("2", "delegation", "ws-source", "ws-target",
|
||||
"Delegation completed (hello world)", "completed", "", "hello world",
|
||||
"del-111", now.Add(time.Minute))
|
||||
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(rows)
|
||||
|
||||
@@ -320,26 +308,23 @@ func TestListDelegations_WithResults(t *testing.T) {
|
||||
}
|
||||
|
||||
// Check first entry (pending delegation)
|
||||
if resp[0]["delegation_id"] != "del-111" {
|
||||
t.Errorf("expected delegation_id 'del-111', got %v", resp[0]["delegation_id"])
|
||||
if resp[0]["type"] != "delegation" {
|
||||
t.Errorf("expected type 'delegation', got %v", resp[0]["type"])
|
||||
}
|
||||
if resp[0]["status"] != "pending" {
|
||||
t.Errorf("expected status 'pending', got %v", resp[0]["status"])
|
||||
}
|
||||
if resp[0]["delegation_id"] != "del-111" {
|
||||
t.Errorf("expected delegation_id 'del-111', got %v", resp[0]["delegation_id"])
|
||||
}
|
||||
if resp[0]["source_id"] != "ws-source" {
|
||||
t.Errorf("expected source_id 'ws-source', got %v", resp[0]["source_id"])
|
||||
}
|
||||
if resp[0]["target_id"] != "ws-target" {
|
||||
t.Errorf("expected target_id 'ws-target', got %v", resp[0]["target_id"])
|
||||
}
|
||||
if resp[0]["_ledger"] != true {
|
||||
t.Errorf("expected _ledger=true marker, got %v", resp[0]["_ledger"])
|
||||
}
|
||||
|
||||
// Check second entry (completed, has response_preview)
|
||||
if resp[1]["delegation_id"] != "del-222" {
|
||||
t.Errorf("expected delegation_id 'del-222', got %v", resp[1]["delegation_id"])
|
||||
}
|
||||
if resp[1]["status"] != "completed" {
|
||||
t.Errorf("expected status 'completed', got %v", resp[1]["status"])
|
||||
}
|
||||
@@ -1379,331 +1364,3 @@ func TestExtractResponseText_EmptyText(t *testing.T) {
|
||||
t.Errorf("empty text: got %q, want %q", got, "")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger has rows → returns them (no activity_logs fallback) ----------
|
||||
|
||||
func TestListDelegations_LedgerRowsReturned(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
deadline := now.Add(6 * time.Hour)
|
||||
// Ledger query returns rows
|
||||
ledgerRows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"del-ledger-001", "caller-uuid", "callee-uuid",
|
||||
"Analyze the codebase for bugs", "in_progress", "", "",
|
||||
&now, &deadline, now, now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("caller-uuid").
|
||||
WillReturnRows(ledgerRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "caller-uuid"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/caller-uuid/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["delegation_id"] != "del-ledger-001" {
|
||||
t.Errorf("expected delegation_id 'del-ledger-001', got %v", resp[0]["delegation_id"])
|
||||
}
|
||||
if resp[0]["status"] != "in_progress" {
|
||||
t.Errorf("expected status 'in_progress', got %v", resp[0]["status"])
|
||||
}
|
||||
if resp[0]["_ledger"] != true {
|
||||
t.Errorf("expected _ledger=true marker, got %v", resp[0]["_ledger"])
|
||||
}
|
||||
if resp[0]["source_id"] != "caller-uuid" {
|
||||
t.Errorf("expected source_id 'caller-uuid', got %v", resp[0]["source_id"])
|
||||
}
|
||||
if resp[0]["target_id"] != "callee-uuid" {
|
||||
t.Errorf("expected target_id 'callee-uuid', got %v", resp[0]["target_id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger empty → falls back to activity_logs ----------
|
||||
|
||||
func TestListDelegations_LedgerEmptyFallsBackToActivityLogs(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Ledger returns empty → falls back to activity_logs
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}))
|
||||
|
||||
now := time.Now()
|
||||
activityRows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}).AddRow(
|
||||
"act-001", "delegation", "ws-source", "ws-target",
|
||||
"Delegating to ws-target", "pending", "", "",
|
||||
"del-old-001", now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(activityRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-source/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 entry from fallback, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["delegation_id"] != "del-old-001" {
|
||||
t.Errorf("expected delegation_id 'del-old-001' from activity_logs, got %v", resp[0]["delegation_id"])
|
||||
}
|
||||
if resp[0]["type"] != "delegation" {
|
||||
t.Errorf("expected type 'delegation' from activity_logs, got %v", resp[0]["type"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: both ledger and activity_logs empty → [] ----------
|
||||
|
||||
func TestListDelegations_BothEmptyReturnsEmptyArray(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Ledger empty
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}))
|
||||
// activity_logs also empty
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-source/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 0 {
|
||||
t.Errorf("expected empty array, got %d entries", len(resp))
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger query error → falls back to activity_logs ----------
|
||||
|
||||
func TestListDelegations_LedgerQueryErrorFallsBackToActivityLogs(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Ledger query fails → fallback to activity_logs
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("ws-source").
|
||||
WillReturnError(fmt.Errorf("table does not exist"))
|
||||
|
||||
now := time.Now()
|
||||
activityRows := sqlmock.NewRows([]string{
|
||||
"id", "activity_type", "source_id", "target_id",
|
||||
"summary", "status", "error_detail", "response_body",
|
||||
"delegation_id", "created_at",
|
||||
}).AddRow(
|
||||
"act-002", "delegation", "ws-source", "ws-target",
|
||||
"Some task", "completed", "", "result here",
|
||||
"del-pre-318", now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT id, activity_type").
|
||||
WithArgs("ws-source").
|
||||
WillReturnRows(activityRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-source"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/ws-source/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 || resp[0]["delegation_id"] != "del-pre-318" {
|
||||
t.Errorf("expected 1 activity_logs entry, got %v", resp)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger completed delegation includes result_preview ----------
|
||||
|
||||
func TestListDelegations_LedgerCompletedIncludesResultPreview(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
deadline := now.Add(6 * time.Hour)
|
||||
ledgerRows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"del-complete-001", "caller-uuid", "callee-uuid",
|
||||
"Run analysis", "completed", "Analysis complete: 42 issues found", "",
|
||||
&now, &deadline, now, now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("caller-uuid").
|
||||
WillReturnRows(ledgerRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "caller-uuid"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/caller-uuid/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["status"] != "completed" {
|
||||
t.Errorf("expected status 'completed', got %v", resp[0]["status"])
|
||||
}
|
||||
if resp[0]["response_preview"] != "Analysis complete: 42 issues found" {
|
||||
t.Errorf("expected response_preview, got %v", resp[0]["response_preview"])
|
||||
}
|
||||
if resp[0]["error"] != nil {
|
||||
t.Errorf("expected no error on completed, got %v", resp[0]["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- ListDelegations: ledger failed delegation includes error_detail ----------
|
||||
|
||||
func TestListDelegations_LedgerFailedIncludesErrorDetail(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
deadline := now.Add(6 * time.Hour)
|
||||
ledgerRows := sqlmock.NewRows([]string{
|
||||
"delegation_id", "caller_id", "callee_id", "task_preview",
|
||||
"status", "result_preview", "error_detail", "last_heartbeat",
|
||||
"deadline", "created_at", "updated_at",
|
||||
}).AddRow(
|
||||
"del-failed-001", "caller-uuid", "callee-uuid",
|
||||
"Fetch data", "failed", "", "Callee workspace not reachable",
|
||||
&now, &deadline, now, now,
|
||||
)
|
||||
mock.ExpectQuery("SELECT d.delegation_id, d.caller_id, d.callee_id, d.task_preview").
|
||||
WithArgs("caller-uuid").
|
||||
WillReturnRows(ledgerRows)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "caller-uuid"}}
|
||||
c.Request = httptest.NewRequest("GET", "/workspaces/caller-uuid/delegations", nil)
|
||||
|
||||
dh.ListDelegations(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp []map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to parse response: %v", err)
|
||||
}
|
||||
if len(resp) != 1 {
|
||||
t.Fatalf("expected 1 entry, got %d", len(resp))
|
||||
}
|
||||
if resp[0]["status"] != "failed" {
|
||||
t.Errorf("expected status 'failed', got %v", resp[0]["status"])
|
||||
}
|
||||
if resp[0]["error"] != "Callee workspace not reachable" {
|
||||
t.Errorf("expected error detail, got %v", resp[0]["error"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -352,9 +352,6 @@ func queryPeerMaps(query string, args ...interface{}) ([]map[string]interface{},
|
||||
|
||||
result = append(result, peer)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("queryPeerMaps scan error: %v", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,6 @@ func (h *EventsHandler) List(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListEvents scan error: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
@@ -90,8 +87,5 @@ func (h *EventsHandler) ListByWorkspace(c *gin.Context) {
|
||||
"created_at": createdAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListEventsByWorkspace scan error: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, events)
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ func BuildExternalConnectionPayload(platformURL, workspaceID, authToken string)
|
||||
"hermes_channel_snippet": stamp(externalHermesChannelTemplate),
|
||||
"codex_snippet": stamp(externalCodexTemplate),
|
||||
"openclaw_snippet": stamp(externalOpenClawTemplate),
|
||||
"kimi_snippet": stamp(externalKimiTemplate),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -490,149 +489,6 @@ codex
|
||||
// external openclaw would need a sessions.steer bridge daemon (the
|
||||
// equivalent of hermes-channel-molecule for openclaw). Tracked
|
||||
// separately; outbound tools is the first cut.
|
||||
// externalKimiTemplate — complete poll-based external setup for Kimi CLI.
|
||||
// Includes register + heartbeat + inbound activity polling + reply via
|
||||
// /notify. No public URL needed (NAT-safe). Operators paste once and run
|
||||
// in a background terminal or via launchd.
|
||||
const externalKimiTemplate = `# Kimi CLI external setup — register + heartbeat + inbound poll + reply.
|
||||
# For operators whose external agent is a Kimi CLI session.
|
||||
# No public URL needed; runs behind NAT in poll mode.
|
||||
|
||||
# 1. Install the workspace runtime wheel (provides HTTP client):
|
||||
pip install molecule-ai-workspace-runtime
|
||||
|
||||
# 2. Save credentials and the bridge script:
|
||||
mkdir -p ~/.molecule-ai/kimi-workspace
|
||||
chmod 700 ~/.molecule-ai/kimi-workspace
|
||||
cat > ~/.molecule-ai/kimi-workspace/env <<'EOF'
|
||||
WORKSPACE_ID={{WORKSPACE_ID}}
|
||||
PLATFORM_URL={{PLATFORM_URL}}
|
||||
MOLECULE_WORKSPACE_TOKEN=<paste from create response>
|
||||
EOF
|
||||
chmod 600 ~/.molecule-ai/kimi-workspace/env
|
||||
|
||||
cat > ~/.molecule-ai/kimi-workspace/kimi_bridge.py <<'PYEOF'
|
||||
#!/usr/bin/env python3
|
||||
"""Kimi bridge — keeps workspace online and polls for canvas messages."""
|
||||
import json, logging, time
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
ENV = Path.home() / ".molecule-ai" / "kimi-workspace" / "env"
|
||||
HEARTBEAT_INTERVAL = 20
|
||||
POLL_INTERVAL = 5
|
||||
|
||||
def load_env():
|
||||
env = {}
|
||||
for line in ENV.read_text().splitlines():
|
||||
if "=" in line and not line.startswith("#"):
|
||||
k, v = line.split("=", 1)
|
||||
env[k.strip()] = v.strip()
|
||||
return env
|
||||
|
||||
def hdrs(url, token):
|
||||
return {"Authorization": f"Bearer {token}", "Origin": url, "Content-Type": "application/json"}
|
||||
|
||||
def register(client, url, ws, tok):
|
||||
r = client.post(f"{url}/registry/register", json={
|
||||
"id": ws, "url": "", "agent_card": {"name": "mac-laptop-kimi", "skills": []},
|
||||
"delivery_mode": "poll",
|
||||
}, headers=hdrs(url, tok))
|
||||
r.raise_for_status()
|
||||
logging.info("registered %s", ws)
|
||||
|
||||
def heartbeat(client, url, ws, tok, start):
|
||||
r = client.post(f"{url}/registry/heartbeat", json={
|
||||
"workspace_id": ws, "error_rate": 0.0, "sample_error": "",
|
||||
"active_tasks": 0, "current_task": "", "uptime_seconds": int(time.time() - start),
|
||||
}, headers=hdrs(url, tok))
|
||||
r.raise_for_status()
|
||||
|
||||
def poll_inbound(client, url, ws, tok, since_id):
|
||||
params = {"since_secs": "30", "limit": "50"}
|
||||
if since_id:
|
||||
params["since_id"] = since_id
|
||||
r = client.get(f"{url}/workspaces/{ws}/activity", params=params, headers=hdrs(url, tok))
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
def send_reply(client, url, ws, tok, text):
|
||||
r = client.post(f"{url}/workspaces/{ws}/notify", json={"message": text}, headers=hdrs(url, tok))
|
||||
r.raise_for_status()
|
||||
logging.info("reply sent: %s", text[:80])
|
||||
|
||||
def extract_user_text(item):
|
||||
"""Pull the user message text from an activity log request_body."""
|
||||
try:
|
||||
body = item.get("request_body") or {}
|
||||
parts = body.get("params", {}).get("message", {}).get("parts", [])
|
||||
return " ".join(p.get("text", "") for p in parts if p.get("text"))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
start = time.time()
|
||||
since_id = ""
|
||||
last_beat = 0
|
||||
while True:
|
||||
try:
|
||||
e = load_env()
|
||||
purl, ws, tok = e["PLATFORM_URL"], e["WORKSPACE_ID"], e["MOLECULE_WORKSPACE_TOKEN"]
|
||||
with httpx.Client(timeout=10.0) as c:
|
||||
# Heartbeat every HEARTBEAT_INTERVAL seconds
|
||||
if time.time() - last_beat >= HEARTBEAT_INTERVAL:
|
||||
register(c, purl, ws, tok)
|
||||
heartbeat(c, purl, ws, tok, start)
|
||||
last_beat = time.time()
|
||||
|
||||
# Poll for new canvas messages
|
||||
items = poll_inbound(c, purl, ws, tok, since_id)
|
||||
for item in items:
|
||||
since_id = item["id"]
|
||||
src = item.get("source_id")
|
||||
method = item.get("method") or ""
|
||||
# Skip our own /notify replies and agent-originated traffic
|
||||
if method == "notify" or src is not None:
|
||||
continue
|
||||
text = extract_user_text(item)
|
||||
if text:
|
||||
logging.info("INBOUND from canvas: %s", text)
|
||||
# Replace the echo below with your own logic:
|
||||
send_reply(c, purl, ws, tok, f"Echo: {text}")
|
||||
time.sleep(POLL_INTERVAL)
|
||||
except Exception as exc:
|
||||
logging.warning("loop failed: %s", exc)
|
||||
time.sleep(5)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
PYEOF
|
||||
chmod +x ~/.molecule-ai/kimi-workspace/kimi_bridge.py
|
||||
|
||||
# 3. Start the bridge (run in a persistent terminal or via launchd):
|
||||
python3 ~/.molecule-ai/kimi-workspace/kimi_bridge.py
|
||||
|
||||
# What the script does:
|
||||
# • Registers the workspace in poll mode (no public URL needed)
|
||||
# • Heartbeats every 20s to keep STATUS = online on the canvas
|
||||
# • Polls /workspaces/:id/activity every 5s for new canvas messages
|
||||
# • Echo-replies via POST /workspaces/:id/notify
|
||||
#
|
||||
# To change the reply logic, edit the send_reply() call inside the loop.
|
||||
# To send a one-off reply from another terminal:
|
||||
# curl -fsS -X POST "{{PLATFORM_URL}}/workspaces/{{WORKSPACE_ID}}/notify" \
|
||||
# -H "Authorization: Bearer $(cat ~/.molecule-ai/kimi-workspace/env | grep TOKEN | cut -d= -f2)" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{"message":"Hello from Kimi"}'
|
||||
#
|
||||
# For push-mode inbound A2A (instead of polling), pair with the Python SDK
|
||||
# tab — but that requires a public HTTPS endpoint (ngrok / Cloudflare Tunnel).
|
||||
#
|
||||
# Need help?
|
||||
# Documentation: https://doc.moleculesai.app/docs/guides/external-agent-registration
|
||||
`
|
||||
|
||||
const externalOpenClawTemplate = `# OpenClaw MCP config — outbound tool path. For operators whose
|
||||
# external agent is an openclaw session.
|
||||
#
|
||||
|
||||
@@ -62,7 +62,7 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
|
||||
return
|
||||
}
|
||||
if !isExternalLikeRuntime(runtime) {
|
||||
if runtime != "external" {
|
||||
// Rotating a hermes/claude-code workspace's bearer would not
|
||||
// just break the ssh-EIC tunnel auth on the platform side — it
|
||||
// would also leave the workspace's in-container heartbeat with
|
||||
@@ -73,9 +73,9 @@ func (h *WorkspaceHandler) RotateExternalCredentials(c *gin.Context) {
|
||||
// here so the canvas can show "rotate is for external workspaces;
|
||||
// click Restart instead" rather than silently corrupting state.
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "rotate is only valid for external/BYO-compute workspaces",
|
||||
"error": "rotate is only valid for runtime=external workspaces",
|
||||
"runtime": runtime,
|
||||
"hint": "use POST /workspaces/:id/restart for container-backed runtimes",
|
||||
"hint": "use POST /workspaces/:id/restart for non-external runtimes",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -139,9 +139,9 @@ func (h *WorkspaceHandler) GetExternalConnection(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "lookup failed"})
|
||||
return
|
||||
}
|
||||
if !isExternalLikeRuntime(runtime) {
|
||||
if runtime != "external" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "connection payload is only valid for external/BYO-compute workspaces",
|
||||
"error": "connection payload is only valid for runtime=external workspaces",
|
||||
"runtime": runtime,
|
||||
})
|
||||
return
|
||||
|
||||
@@ -82,7 +82,6 @@ func TestRotateExternalCredentials_HappyPath(t *testing.T) {
|
||||
"curl_register_template", "python_snippet",
|
||||
"claude_code_channel_snippet", "universal_mcp_snippet",
|
||||
"hermes_channel_snippet", "codex_snippet", "openclaw_snippet",
|
||||
"kimi_snippet",
|
||||
} {
|
||||
if _, ok := body.Connection[k]; !ok {
|
||||
t.Errorf("payload missing snippet field: %s", k)
|
||||
|
||||
@@ -248,9 +248,6 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
b.WriteString(content)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListInstructions scan error: %v", err)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"workspace_id": workspaceID,
|
||||
@@ -261,7 +258,6 @@ func (h *InstructionsHandler) Resolve(c *gin.Context) {
|
||||
func scanInstructions(rows interface {
|
||||
Next() bool
|
||||
Scan(dest ...interface{}) error
|
||||
Err() error
|
||||
}) []Instruction {
|
||||
var instructions []Instruction
|
||||
for rows.Next() {
|
||||
@@ -273,9 +269,6 @@ func scanInstructions(rows interface {
|
||||
}
|
||||
instructions = append(instructions, inst)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("Instructions scan loop error: %v", err)
|
||||
}
|
||||
if instructions == nil {
|
||||
instructions = []Instruction{}
|
||||
}
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// extractA2AText tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestExtractA2AText_InvalidJSON(t *testing.T) {
|
||||
// When JSON unmarshal fails, fall back to raw body.
|
||||
body := []byte("not json at all")
|
||||
got := extractA2AText(body)
|
||||
if got != "not json at all" {
|
||||
t.Errorf("invalid JSON: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_A2AError(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": -32600,
|
||||
"message": "workspace not found",
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
want := "[error] workspace not found"
|
||||
if got != want {
|
||||
t.Errorf("A2A error: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_A2AErrorMissingMessage(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"error": map[string]interface{}{
|
||||
"code": -32600,
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
// No message key → falls through to result check, then fallback
|
||||
if got == "" {
|
||||
t.Errorf("A2A error without message: got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_ArtifactsText(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"text": "Hello from the artifact",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
want := "Hello from the artifact"
|
||||
if got != want {
|
||||
t.Errorf("artifacts text: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_ArtifactsEmptyArray(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"artifacts": []interface{}{},
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
// Empty artifacts → falls through to message check, then fallback
|
||||
if got == "" {
|
||||
t.Errorf("empty artifacts: got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_MessageText(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"text": "Hello from message",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
want := "Hello from message"
|
||||
if got != want {
|
||||
t.Errorf("message text: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_MessageNoParts(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"message": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
// No parts → falls through to fallback (JSON marshal of result)
|
||||
if got == "" {
|
||||
t.Errorf("message with no parts: got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_EmptyTextInPart(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"text": "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
// Empty text → falls through to message check, then fallback
|
||||
if got == "" {
|
||||
t.Errorf("empty text in part: got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_NoResult(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"id": 1,
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
// No result key → falls through to fallback
|
||||
if got == "" {
|
||||
t.Errorf("no result: got empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_FallbackMarshalsResult(t *testing.T) {
|
||||
// result is not artifacts or message → fallback to JSON marshal.
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"status": "ok",
|
||||
"count": 42,
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
// Fallback: json.Marshal(result) → {"count":42,"status":"ok"}
|
||||
if got == "" {
|
||||
t.Errorf("fallback marshal: got empty string")
|
||||
}
|
||||
// Verify it's valid JSON (marshaled result)
|
||||
var decoded map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
|
||||
t.Errorf("fallback should produce valid JSON: got %q, error: %v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractA2AText_PriorityArtifactsOverMessage(t *testing.T) {
|
||||
// Both artifacts and message present → artifacts takes priority (checked first).
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"text": "from artifacts",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"message": map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"text": "from message",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
got := extractA2AText(body)
|
||||
want := "from artifacts"
|
||||
if got != want {
|
||||
t.Errorf("artifacts should take priority: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ package handlers
|
||||
// Tree creation logic is in org_import.go; utility helpers in org_helpers.go.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -148,17 +147,6 @@ func sizeOfSubtree(ws OrgWorkspace) nodeSize {
|
||||
}
|
||||
}
|
||||
|
||||
// childSlot returns the (x, y) position of child `index` in a 2-column
|
||||
// fixed-size grid. Used as the default when sibling sizes are unknown.
|
||||
// Formula: x = parentSidePadding + col*(childDefaultWidth+childGutter),
|
||||
// y = parentHeaderPadding + row*(childDefaultHeight+childGutter).
|
||||
func childSlot(index int) (x, y float64) {
|
||||
col := index % childGridColumnCount
|
||||
row := index / childGridColumnCount
|
||||
return parentSidePadding + float64(col)*(childDefaultWidth+childGutter),
|
||||
parentHeaderPadding + float64(row)*(childDefaultHeight+childGutter)
|
||||
}
|
||||
|
||||
// childSlotInGrid computes the relative position of sibling `index`
|
||||
// given all siblings' subtree sizes. Uniform column width (= max width
|
||||
// across siblings), per-row max height, so a nested parent sibling
|
||||
@@ -264,7 +252,6 @@ type EnvRequirement struct {
|
||||
// Members returns every env name this requirement considers —
|
||||
// [Name] for single, AnyOf for groups. Used by preflight, collect,
|
||||
// and the name-validation regex gate.
|
||||
|
||||
func (e EnvRequirement) Members() []string {
|
||||
if e.Name != "" {
|
||||
return []string{e.Name}
|
||||
@@ -341,95 +328,6 @@ func (e *EnvRequirement) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// perWorkspaceUnsatisfied is the return type of collectPerWorkspaceUnsatisfied.
|
||||
// Each entry names the workspace and files_dir that declared an unsatisfied
|
||||
// requirement, plus the requirement itself (EnvRequirement serialises to
|
||||
// the same dual shape {string | {any_of: [...]}} in the 412 JSON response).
|
||||
type perWorkspaceUnsatisfied struct {
|
||||
Workspace string `json:"workspace"`
|
||||
FilesDir string `json:"files_dir"`
|
||||
Unsatisfied EnvRequirement `json:"unsatisfied"`
|
||||
}
|
||||
|
||||
// collectPerWorkspaceUnsatisfied walks the workspace tree and reports every
|
||||
// RequiredEnv that is not covered by global secrets (configured) or by an
|
||||
// on-disk .env file. orgBaseDir is the on-disk root of the org template
|
||||
// (each workspace's .env lives at orgBaseDir/<files_dir>/.env); when empty
|
||||
// no .env files are checked and only global coverage can satisfy a requirement.
|
||||
// A workspace is satisfied by the .env in its own files_dir AND the org root
|
||||
// .env (env vars cascade downward from the root).
|
||||
func collectPerWorkspaceUnsatisfied(
|
||||
workspaces []OrgWorkspace,
|
||||
orgBaseDir string,
|
||||
configured map[string]struct{},
|
||||
) []perWorkspaceUnsatisfied {
|
||||
var result []perWorkspaceUnsatisfied
|
||||
for _, ws := range workspaces {
|
||||
// Check each RequiredEnv.
|
||||
for _, req := range ws.RequiredEnv {
|
||||
if req.IsSatisfied(configured) {
|
||||
continue
|
||||
}
|
||||
// Not covered by global secrets — check .env files if available.
|
||||
// When orgBaseDir is empty (inline template import) we cannot check
|
||||
// .env files, so any key not in configured is genuinely missing.
|
||||
if orgBaseDir == "" || !envKeyPresent(orgBaseDir, ws.FilesDir, req.Members()...) {
|
||||
result = append(result, perWorkspaceUnsatisfied{
|
||||
Workspace: ws.Name,
|
||||
FilesDir: ws.FilesDir,
|
||||
Unsatisfied: req,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Recurse into children so deeply nested workspaces are also checked.
|
||||
result = append(result, collectPerWorkspaceUnsatisfied(ws.Children, orgBaseDir, configured)...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// envKeyPresent checks whether all env keys appear in either
|
||||
// orgBaseDir/.env (root) or orgBaseDir/filesDir/.env (workspace).
|
||||
// Returns true only when all keys are found in at least one of those files.
|
||||
func envKeyPresent(orgBaseDir, filesDir string, keys ...string) bool {
|
||||
if len(keys) == 0 {
|
||||
return true
|
||||
}
|
||||
// Load root .env (covers vars that cascade from org root).
|
||||
rootEnv := loadEnvVars(orgBaseDir + "/.env")
|
||||
// Load workspace .env.
|
||||
wsEnv := loadEnvVars(orgBaseDir + "/" + filesDir + "/.env")
|
||||
for _, k := range keys {
|
||||
if _, inRoot := rootEnv[k]; !inRoot {
|
||||
if _, inWS := wsEnv[k]; !inWS {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// loadEnvVars reads a .env file and returns keys→values.
|
||||
func loadEnvVars(path string) map[string]string {
|
||||
vars := map[string]string{}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return vars
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) == 2 {
|
||||
vars[parts[0]] = parts[1]
|
||||
}
|
||||
}
|
||||
return vars
|
||||
}
|
||||
|
||||
// OrgTemplate is the YAML structure for an org hierarchy.
|
||||
type OrgTemplate struct {
|
||||
Name string `yaml:"name" json:"name"`
|
||||
|
||||
@@ -62,11 +62,6 @@ func resolvePromptRef(inline, fileRef, orgBaseDir, filesDir string) (string, err
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// envVarRx matches ${VAR} and $VAR references where the name starts with
|
||||
// [a-zA-Z_] — intentionally excludes bare $ and $1-style digits so
|
||||
// "cost $100" stays intact.
|
||||
var envVarRx = regexp.MustCompile(`\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)`)
|
||||
|
||||
// envVarRefPattern matches actual ${VAR} or $VAR references (not literal $).
|
||||
// Used to detect unresolved placeholders without false positives like "$5".
|
||||
var envVarRefPattern = regexp.MustCompile(`\$\{?[A-Za-z_][A-Za-z0-9_]*\}?`)
|
||||
@@ -85,30 +80,12 @@ func hasUnresolvedVarRef(original, expanded string) bool {
|
||||
// expandWithEnv expands ${VAR} and $VAR references in s using the env map.
|
||||
// Falls back to the platform process env if a var isn't in the map.
|
||||
func expandWithEnv(s string, env map[string]string) string {
|
||||
result := s
|
||||
for {
|
||||
loc := envVarRx.FindStringIndex(result)
|
||||
if loc == nil {
|
||||
break
|
||||
}
|
||||
match := result[loc[0]:loc[1]]
|
||||
var key string
|
||||
if len(match) >= 2 && match[0] == '$' && match[1] == '{' {
|
||||
// ${VAR} form
|
||||
key = match[2 : len(match)-1]
|
||||
} else {
|
||||
// $VAR form
|
||||
key = match[1:]
|
||||
}
|
||||
var replacement string
|
||||
return os.Expand(s, func(key string) string {
|
||||
if v, ok := env[key]; ok {
|
||||
replacement = v
|
||||
} else {
|
||||
replacement = os.Getenv(key)
|
||||
return v
|
||||
}
|
||||
result = result[:loc[0]] + replacement + result[loc[1]:]
|
||||
}
|
||||
return result
|
||||
return os.Getenv(key)
|
||||
})
|
||||
}
|
||||
|
||||
// loadWorkspaceEnv reads the org root .env and the workspace-specific .env
|
||||
|
||||
@@ -2,8 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// ── isSafeRoleName ────────────────────────────────────────────────────────────
|
||||
@@ -421,302 +419,3 @@ func TestMergePlugins_EmptyPlugin(t *testing.T) {
|
||||
t.Errorf("got %v, want 2 items", r)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Additional coverage: expandWithEnv ──────────────────────────────
|
||||
func TestExpandWithEnv_BracedVar(t *testing.T) {
|
||||
env := map[string]string{"FOO": "bar", "BAZ": "qux"}
|
||||
result := expandWithEnv("value is ${FOO}", env)
|
||||
assert.Equal(t, "value is bar", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_DollarVar(t *testing.T) {
|
||||
env := map[string]string{"X": "1", "Y": "2"}
|
||||
result := expandWithEnv("$X + $Y = 3", env)
|
||||
assert.Equal(t, "1 + 2 = 3", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_Mixed(t *testing.T) {
|
||||
env := map[string]string{"A": "alpha", "B": "beta"}
|
||||
result := expandWithEnv("${A}_${B}", env)
|
||||
assert.Equal(t, "alpha_beta", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_MissingVar(t *testing.T) {
|
||||
// Missing vars stay as-is (os.Getenv fallback returns "" for unset vars).
|
||||
env := map[string]string{}
|
||||
result := expandWithEnv("${UNSET}", env)
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_EmptyMap(t *testing.T) {
|
||||
result := expandWithEnv("no vars here", map[string]string{})
|
||||
assert.Equal(t, "no vars here", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_LiteralDollar(t *testing.T) {
|
||||
// A bare $ not followed by a valid identifier char stays as-is.
|
||||
result := expandWithEnv("cost $100", map[string]string{})
|
||||
assert.Equal(t, "cost $100", result)
|
||||
}
|
||||
|
||||
func TestExpandWithEnv_PartiallyPresent(t *testing.T) {
|
||||
env := map[string]string{"SET": "yes"}
|
||||
result := expandWithEnv("${SET} and ${NOT_SET}", env)
|
||||
// ${SET} resolved; ${NOT_SET} -> "" via empty fallback.
|
||||
assert.Equal(t, "yes and ", result)
|
||||
}
|
||||
|
||||
// mergeCategoryRouting tests — unions defaults with per-workspace routing.
|
||||
|
||||
// ── Additional coverage: mergeCategoryRouting ──────────────────────
|
||||
func TestMergeCategoryRouting_WorkspaceAddsCategory(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"ui": {"Frontend Engineer"},
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, wsRouting)
|
||||
assert.Equal(t, []string{"Backend Engineer"}, result["security"])
|
||||
assert.Equal(t, []string{"Frontend Engineer"}, result["ui"])
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyListDropsCategory(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
"infra": {"SRE"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"security": {}, // empty list = explicit drop
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, wsRouting)
|
||||
_, hasSecurity := result["security"]
|
||||
assert.False(t, hasSecurity)
|
||||
assert.Equal(t, []string{"SRE"}, result["infra"])
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyDefaultKeySkipped(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"": {"Backend Engineer"}, // empty key should be skipped
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, nil)
|
||||
_, has := result[""]
|
||||
assert.False(t, has)
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_EmptyWorkspaceKeySkipped(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"": {"Some Role"},
|
||||
}
|
||||
result := mergeCategoryRouting(defaults, wsRouting)
|
||||
_, has := result[""]
|
||||
assert.False(t, has)
|
||||
assert.Equal(t, []string{"Backend Engineer"}, result["security"])
|
||||
}
|
||||
|
||||
func TestMergeCategoryRouting_DoesNotMutateInputs(t *testing.T) {
|
||||
defaults := map[string][]string{
|
||||
"security": {"Backend Engineer"},
|
||||
}
|
||||
wsRouting := map[string][]string{
|
||||
"security": {"DevOps"},
|
||||
}
|
||||
orig := defaults["security"][0]
|
||||
_ = mergeCategoryRouting(defaults, wsRouting)
|
||||
assert.Equal(t, orig, defaults["security"][0])
|
||||
}
|
||||
|
||||
// renderCategoryRoutingYAML tests — deterministic YAML emission.
|
||||
|
||||
// ── Additional coverage: renderCategoryRoutingYAML ────────────────
|
||||
func TestRenderCategoryRoutingYAML_SingleCategory(t *testing.T) {
|
||||
routing := map[string][]string{
|
||||
"security": {"Backend Engineer", "DevOps"},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result, "security:")
|
||||
assert.Contains(t, result, "Backend Engineer")
|
||||
assert.Contains(t, result, "DevOps")
|
||||
}
|
||||
|
||||
func TestRenderCategoryRoutingYAML_MultipleCategoriesSorted(t *testing.T) {
|
||||
routing := map[string][]string{
|
||||
"zebra": {"RoleZ"},
|
||||
"alpha": {"RoleA"},
|
||||
"middleware": {"RoleM"},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
assert.NoError(t, err)
|
||||
// Keys are sorted alphabetically.
|
||||
idxAlpha := assertFind(t, result, "alpha:")
|
||||
idxZebra := assertFind(t, result, "zebra:")
|
||||
idxMid := assertFind(t, result, "middleware:")
|
||||
if idxAlpha > -1 && idxZebra > -1 {
|
||||
assert.True(t, idxAlpha < idxZebra, "alpha should appear before zebra")
|
||||
}
|
||||
if idxMid > -1 && idxZebra > -1 {
|
||||
assert.True(t, idxMid < idxZebra, "middleware should appear before zebra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCategoryRoutingYAML_EmptyListCategory(t *testing.T) {
|
||||
// Empty-list category should still render (mergeCategoryRouting drops
|
||||
// them before they reach this function, but we test the render in isolation).
|
||||
routing := map[string][]string{
|
||||
"security": {},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, result, "security:")
|
||||
}
|
||||
|
||||
func TestRenderCategoryRoutingYAML_SpecialCharactersEscaped(t *testing.T) {
|
||||
routing := map[string][]string{
|
||||
"notes": {`has: colon`, `and "quotes"`, "emoji: 🚀"},
|
||||
}
|
||||
result, err := renderCategoryRoutingYAML(routing)
|
||||
assert.NoError(t, err)
|
||||
// Should not panic and should produce valid YAML.
|
||||
assert.Contains(t, result, "notes:")
|
||||
}
|
||||
|
||||
// appendYAMLBlock tests — safe concatenation with newline boundary.
|
||||
|
||||
// ── Additional coverage: appendYAMLBlock ───────────────────────────
|
||||
func TestAppendYAMLBlock_BothEmpty(t *testing.T) {
|
||||
result := appendYAMLBlock(nil, "")
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_ExistingHasNewline(t *testing.T) {
|
||||
existing := []byte("existing:\n")
|
||||
block := "key: value\n"
|
||||
result := appendYAMLBlock(existing, block)
|
||||
assert.Equal(t, "existing:\nkey: value\n", string(result))
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_ExistingNoNewline(t *testing.T) {
|
||||
existing := []byte("existing:")
|
||||
block := "key: value\n"
|
||||
result := appendYAMLBlock(existing, block)
|
||||
assert.Equal(t, "existing:\nkey: value\n", string(result))
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_ExistingEmpty(t *testing.T) {
|
||||
existing := []byte("")
|
||||
block := "key: value\n"
|
||||
result := appendYAMLBlock(existing, block)
|
||||
assert.Equal(t, "key: value\n", string(result))
|
||||
}
|
||||
|
||||
func TestAppendYAMLBlock_NilExisting(t *testing.T) {
|
||||
block := "key: value\n"
|
||||
result := appendYAMLBlock(nil, block)
|
||||
assert.Equal(t, "key: value\n", string(result))
|
||||
}
|
||||
|
||||
// mergePlugins tests — union with exclusion prefix (!/-).
|
||||
|
||||
// ── Additional coverage: mergePlugins (additional cases) ───────────
|
||||
func TestMergePlugins_DefaultsOnly(t *testing.T) {
|
||||
defaults := []string{"plugin-a", "plugin-b"}
|
||||
result := mergePlugins(defaults, nil)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_WorkspaceAdds(t *testing.T) {
|
||||
defaults := []string{"plugin-a"}
|
||||
wsPlugins := []string{"plugin-b", "plugin-a"} // duplicate of default
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionWithBang(t *testing.T) {
|
||||
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
|
||||
wsPlugins := []string{"!plugin-b"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-c"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionWithDash(t *testing.T) {
|
||||
defaults := []string{"plugin-a", "plugin-b", "plugin-c"}
|
||||
wsPlugins := []string{"-plugin-b"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-c"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionEmptyTarget(t *testing.T) {
|
||||
defaults := []string{"plugin-a", "plugin-b"}
|
||||
wsPlugins := []string{"!", "-"} // no-op exclusions
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionNotInDefaults(t *testing.T) {
|
||||
// Excluding something not in defaults is a no-op.
|
||||
defaults := []string{"plugin-a"}
|
||||
wsPlugins := []string{"!plugin-b"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_WorkspaceAddsNew(t *testing.T) {
|
||||
defaults := []string{"plugin-a"}
|
||||
wsPlugins := []string{"plugin-b"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_DeduplicationOrder(t *testing.T) {
|
||||
// Defaults first; workspace entries deduplicated.
|
||||
defaults := []string{"plugin-a", "plugin-a", "plugin-b"}
|
||||
wsPlugins := []string{"plugin-b", "plugin-c", "plugin-c"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-a", "plugin-b", "plugin-c"}, result)
|
||||
}
|
||||
|
||||
func TestMergePlugins_ExclusionThenAddSameName(t *testing.T) {
|
||||
// Remove then re-add: order matters.
|
||||
defaults := []string{"plugin-a", "plugin-b"}
|
||||
wsPlugins := []string{"!plugin-a", "plugin-a"}
|
||||
result := mergePlugins(defaults, wsPlugins)
|
||||
assert.Equal(t, []string{"plugin-b", "plugin-a"}, result)
|
||||
}
|
||||
|
||||
// isSafeRoleName tests — alphanumeric + hyphen/underscore, no path separators.
|
||||
|
||||
// ── Additional coverage: isSafeRoleName ───────────────────────────
|
||||
func TestIsSafeRoleName_SpecialCharsRejected(t *testing.T) {
|
||||
bad := []string{
|
||||
"role@name",
|
||||
"role#name",
|
||||
"role$name",
|
||||
"role%name",
|
||||
"role&name",
|
||||
"role*name",
|
||||
"role?name",
|
||||
"role=name",
|
||||
}
|
||||
for _, r := range bad {
|
||||
if isSafeRoleName(r) {
|
||||
t.Errorf("isSafeRoleName(%q) expected false, got true", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertFind is a helper: returns index of first occurrence of substr in s, or -1.
|
||||
func assertFind(t *testing.T, s, substr string) int {
|
||||
t.Helper()
|
||||
idx := -1
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
@@ -1,431 +1,538 @@
|
||||
package handlers
|
||||
|
||||
// org_import_helpers_test.go — 24 cases covering pure-logic helpers in org_import.go.
|
||||
//
|
||||
// Covered helpers (all package-local, called directly within this package):
|
||||
// countWorkspaces — recursive subtree count
|
||||
// envRequirementKey — canonical NUL-separated sort key
|
||||
// sanitizeEnvMembers — name-validation regex filter
|
||||
// flattenAndSortRequirements — singles-first deterministic sort
|
||||
// collectOrgEnv — multi-tier dedup: required-wins + any-of domination
|
||||
// EnvRequirement.Members — Name/AnyOf accessor
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// countWorkspaces
|
||||
// countWorkspaces tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCountWorkspaces_Leaf(t *testing.T) {
|
||||
// A leaf workspace with no children counts as 1.
|
||||
ws := OrgWorkspace{Name: "leaf"}
|
||||
got := countWorkspaces([]OrgWorkspace{ws})
|
||||
if got != 1 {
|
||||
t.Errorf("leaf workspace: count=%d, want 1", got)
|
||||
func TestCountWorkspaces_Empty(t *testing.T) {
|
||||
got := countWorkspaces(nil)
|
||||
if got != 0 {
|
||||
t.Errorf("nil: got %d, want 0", got)
|
||||
}
|
||||
got = countWorkspaces([]OrgWorkspace{})
|
||||
if got != 0 {
|
||||
t.Errorf("empty: got %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountWorkspaces_SingleChild(t *testing.T) {
|
||||
// One child means 2 total: parent + child.
|
||||
ws := OrgWorkspace{
|
||||
Name: "parent",
|
||||
Children: []OrgWorkspace{{Name: "child"}},
|
||||
func TestCountWorkspaces_Flat(t *testing.T) {
|
||||
tree := []OrgWorkspace{
|
||||
{Name: "a"},
|
||||
{Name: "b"},
|
||||
{Name: "c"},
|
||||
}
|
||||
got := countWorkspaces([]OrgWorkspace{ws})
|
||||
if got != 2 {
|
||||
t.Errorf("parent+1child: count=%d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountWorkspaces_Siblings(t *testing.T) {
|
||||
// Two siblings under same parent: 1 parent + 2 children = 3.
|
||||
ws := OrgWorkspace{
|
||||
Name: "parent",
|
||||
Children: []OrgWorkspace{{Name: "a"}, {Name: "b"}},
|
||||
}
|
||||
got := countWorkspaces([]OrgWorkspace{ws})
|
||||
got := countWorkspaces(tree)
|
||||
if got != 3 {
|
||||
t.Errorf("parent+2children: count=%d, want 3", got)
|
||||
t.Errorf("flat 3: got %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountWorkspaces_NestedChildren(t *testing.T) {
|
||||
// Two levels: 1 root + 1 child + 1 grandchild = 3.
|
||||
ws := OrgWorkspace{
|
||||
Name: "root",
|
||||
Children: []OrgWorkspace{{
|
||||
Name: "child",
|
||||
Children: []OrgWorkspace{{Name: "grandchild"}},
|
||||
}},
|
||||
func TestCountWorkspaces_Nested(t *testing.T) {
|
||||
// root (1)
|
||||
// / | \ (3 children)
|
||||
// c1 c2 c3
|
||||
// | |
|
||||
// g1 g2 (2 grandchildren)
|
||||
tree := []OrgWorkspace{
|
||||
{
|
||||
Name: "root",
|
||||
Children: []OrgWorkspace{
|
||||
{Name: "child1", Children: []OrgWorkspace{{Name: "grandchild1"}}},
|
||||
{Name: "child2"},
|
||||
{Name: "child3", Children: []OrgWorkspace{{Name: "grandchild2"}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := countWorkspaces([]OrgWorkspace{ws})
|
||||
if got != 3 {
|
||||
t.Errorf("2-level nesting: count=%d, want 3", got)
|
||||
got := countWorkspaces(tree)
|
||||
if got != 6 {
|
||||
t.Errorf("nested: got %d, want 6 (1 root + 3 children + 2 grandchildren)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountWorkspaces_DeepNesting(t *testing.T) {
|
||||
// Three levels: root → child → grandchild → great-grandchild = 4.
|
||||
ws := OrgWorkspace{
|
||||
Name: "a",
|
||||
Children: []OrgWorkspace{{
|
||||
Name: "b",
|
||||
Children: []OrgWorkspace{{
|
||||
Name: "c",
|
||||
Children: []OrgWorkspace{{Name: "d"}},
|
||||
// chain of 5 levels
|
||||
deep := []OrgWorkspace{
|
||||
{Name: "L1", Children: []OrgWorkspace{
|
||||
{Name: "L2", Children: []OrgWorkspace{
|
||||
{Name: "L3", Children: []OrgWorkspace{
|
||||
{Name: "L4", Children: []OrgWorkspace{
|
||||
{Name: "L5"},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
got := countWorkspaces([]OrgWorkspace{ws})
|
||||
if got != 4 {
|
||||
t.Errorf("3-level nesting: count=%d, want 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCountWorkspaces_EmptySlice(t *testing.T) {
|
||||
got := countWorkspaces([]OrgWorkspace{})
|
||||
if got != 0 {
|
||||
t.Errorf("empty slice: count=%d, want 0", got)
|
||||
got := countWorkspaces(deep)
|
||||
if got != 5 {
|
||||
t.Errorf("deep chain: got %d, want 5", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// envRequirementKey
|
||||
// envRequirementKey tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestEnvRequirementKey_SingleMember(t *testing.T) {
|
||||
got := envRequirementKey([]string{"API_KEY"})
|
||||
want := "API_KEY"
|
||||
if got != want {
|
||||
t.Errorf("single member: key=%q, want %q", got, want)
|
||||
if got != "API_KEY" {
|
||||
t.Errorf("single: got %q, want %q", got, "API_KEY")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvRequirementKey_TwoMembersSorted(t *testing.T) {
|
||||
// Already alphabetical — key should be stable.
|
||||
got := envRequirementKey([]string{"API_KEY", "MODEL_NAME"})
|
||||
want := "API_KEY\x00MODEL_NAME"
|
||||
if got != want {
|
||||
t.Errorf("sorted pair: key=%q, want %q", got, want)
|
||||
func TestEnvRequirementKey_TwoMembers_OrderInsensitive(t *testing.T) {
|
||||
keyAB := envRequirementKey([]string{"A", "B"})
|
||||
keyBA := envRequirementKey([]string{"B", "A"})
|
||||
if keyAB != keyBA {
|
||||
t.Errorf("order-insensitive: [A,B]=%q, [B,A]=%q — must match", keyAB, keyBA)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvRequirementKey_TwoMembersReverse(t *testing.T) {
|
||||
// Reversed order should canonicalise to same key as sorted.
|
||||
got := envRequirementKey([]string{"MODEL_NAME", "API_KEY"})
|
||||
want := "API_KEY\x00MODEL_NAME"
|
||||
if got != want {
|
||||
t.Errorf("reversed pair: key=%q, want %q", got, want)
|
||||
func TestEnvRequirementKey_ThreeMembers_Sorted(t *testing.T) {
|
||||
key := envRequirementKey([]string{"Z", "A", "M"})
|
||||
// Should be "A\x00M\x00Z"
|
||||
want := "A\x00M\x00Z"
|
||||
if key != want {
|
||||
t.Errorf("three members sorted: got %q, want %q", key, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvRequirementKey_PermutationEquivalence(t *testing.T) {
|
||||
// All permutations of the same set must produce identical keys.
|
||||
perms := [][]string{
|
||||
{"X", "A", "M"},
|
||||
{"A", "M", "X"},
|
||||
{"M", "X", "A"},
|
||||
{"X", "M", "A"},
|
||||
}
|
||||
var first string
|
||||
for i, perm := range perms {
|
||||
got := envRequirementKey(perm)
|
||||
if i == 0 {
|
||||
first = got
|
||||
} else if got != first {
|
||||
t.Errorf("permutation %d: key=%q differs from first key %q", i, got, first)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvRequirementKey_Empty(t *testing.T) {
|
||||
got := envRequirementKey([]string{})
|
||||
func TestEnvRequirementKey_EmptyMembers(t *testing.T) {
|
||||
got := envRequirementKey(nil)
|
||||
if got != "" {
|
||||
t.Errorf("empty: key=%q, want empty string", got)
|
||||
t.Errorf("nil: got %q, want empty", got)
|
||||
}
|
||||
got = envRequirementKey([]string{})
|
||||
if got != "" {
|
||||
t.Errorf("empty: got %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvRequirementKey_DuplicateMembers(t *testing.T) {
|
||||
// Duplicates should be preserved in sort; join still works
|
||||
key := envRequirementKey([]string{"A", "A", "B"})
|
||||
want := "A\x00A\x00B"
|
||||
if key != want {
|
||||
t.Errorf("duplicates: got %q, want %q", key, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvRequirementKey_UsedForDedup(t *testing.T) {
|
||||
// Real dedup case: {A,B} and {B,A} produce same key → dedup-eligible
|
||||
// {A,B,C} produces a different key
|
||||
keyAB := envRequirementKey([]string{"A", "B"})
|
||||
keyBA := envRequirementKey([]string{"B", "A"})
|
||||
keyABC := envRequirementKey([]string{"A", "B", "C"})
|
||||
if keyAB != keyBA {
|
||||
t.Errorf("AB vs BA: keys must match for dedup")
|
||||
}
|
||||
if keyAB == keyABC {
|
||||
t.Errorf("AB vs ABC: keys must differ")
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// sanitizeEnvMembers
|
||||
// sanitizeEnvMembers tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// envVarNamePattern = ^[A-Z][A-Z0-9_]{0,127}$
|
||||
|
||||
func TestSanitizeEnvMembers_AllValid(t *testing.T) {
|
||||
// Valid POSIX env-var names (uppercase + underscore + digit).
|
||||
got, ok := sanitizeEnvMembers([]string{"API_KEY", "MODEL_NAME", "A1"}, "test")
|
||||
members := []string{"API_KEY", "MY_VAR_2", "A"}
|
||||
got, ok := sanitizeEnvMembers(members, "test")
|
||||
if !ok {
|
||||
t.Error("all-valid: expected ok=true")
|
||||
t.Error("all valid: ok should be true")
|
||||
}
|
||||
want := []string{"API_KEY", "MODEL_NAME", "A1"}
|
||||
for i, w := range want {
|
||||
if i >= len(got) || got[i] != w {
|
||||
t.Errorf("all-valid: got=%v, want %v", got, want)
|
||||
break
|
||||
if len(got) != len(members) {
|
||||
t.Errorf("all valid: got %v, want %v", got, members)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_SomeInvalid(t *testing.T) {
|
||||
// Lowercase first char — invalid
|
||||
members := []string{"API_KEY", "lowercase", "MY_VAR"}
|
||||
got, ok := sanitizeEnvMembers(members, "test")
|
||||
if !ok {
|
||||
t.Error("one invalid: ok should be true (valid members remain)")
|
||||
}
|
||||
want := []string{"API_KEY", "MY_VAR"}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("one invalid: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_AllInvalid_DropsAll(t *testing.T) {
|
||||
members := []string{"lowercase", "123_START", ""}
|
||||
got, ok := sanitizeEnvMembers(members, "test")
|
||||
if ok {
|
||||
t.Error("all invalid: ok should be false")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("all invalid: got %v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_EmptyString_Skipped(t *testing.T) {
|
||||
// Empty string is filtered but doesn't make ok=false
|
||||
members := []string{"API_KEY", "", "MY_VAR"}
|
||||
got, ok := sanitizeEnvMembers(members, "test")
|
||||
if !ok {
|
||||
t.Error("empty string in valid list: ok should be true")
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("empty string filtered: got %v, want [API_KEY, MY_VAR]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_MaxLength(t *testing.T) {
|
||||
// 128 chars: valid (1 prefix + 127 more = 128, all uppercase)
|
||||
valid := "A" + strings.Repeat("B", 127)
|
||||
got, ok := sanitizeEnvMembers([]string{valid}, "test")
|
||||
if !ok {
|
||||
t.Errorf("128 char valid: ok should be true, got %v", got)
|
||||
}
|
||||
// 129 chars: invalid (exceeds {0,127} suffix in regex)
|
||||
tooLong := "A" + strings.Repeat("B", 128)
|
||||
_, ok = sanitizeEnvMembers([]string{tooLong}, "test")
|
||||
if ok {
|
||||
t.Error("129 char invalid: ok should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_DigitsAndUnderscore(t *testing.T) {
|
||||
// regex ^[A-Z][A-Z0-9_]{0,127}$ — first char must be A-Z, not underscore
|
||||
valid := []string{"A1", "A_2", "HTTP_200_OK", "ABC123"}
|
||||
for _, v := range valid {
|
||||
got, ok := sanitizeEnvMembers([]string{v}, "test")
|
||||
if !ok {
|
||||
t.Errorf("should be valid: %q", v)
|
||||
}
|
||||
if len(got) != 1 || got[0] != v {
|
||||
t.Errorf("got %v, want [%q]", got, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_OneInvalid(t *testing.T) {
|
||||
// One invalid name is filtered; valid remainder is kept.
|
||||
got, ok := sanitizeEnvMembers([]string{"API_KEY", "invalid-name", "SECRET"}, "test")
|
||||
if !ok {
|
||||
t.Error("one-invalid: expected ok=true (valid members remain)")
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("one-invalid: got %v (len=%d), want [API_KEY SECRET]", got, len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_AllInvalid(t *testing.T) {
|
||||
// All invalid → empty output, ok=false.
|
||||
got, ok := sanitizeEnvMembers([]string{"lowercase", "123", "has-dash"}, "test")
|
||||
if ok {
|
||||
t.Error("all-invalid: expected ok=false")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("all-invalid: got %v, want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_EmptyStringSkipped(t *testing.T) {
|
||||
// Empty string in list is silently skipped (not a regex failure).
|
||||
got, ok := sanitizeEnvMembers([]string{"API_KEY", "", "SECRET"}, "test")
|
||||
if !ok {
|
||||
t.Error("empty-string: expected ok=true")
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Errorf("empty-string: got %v, want [API_KEY SECRET]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_EmptyInput(t *testing.T) {
|
||||
// Empty slice → empty output, ok=false.
|
||||
got, ok := sanitizeEnvMembers([]string{}, "test")
|
||||
if ok {
|
||||
t.Error("empty-input: expected ok=false")
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("empty-input: got %v, want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_NameBoundary(t *testing.T) {
|
||||
// Name must START with uppercase. Lowercase-start names are invalid.
|
||||
got, ok := sanitizeEnvMembers([]string{"api_key", "API_KEY"}, "test")
|
||||
if !ok {
|
||||
t.Error("lower-start: expected ok=true (API_KEY passes)")
|
||||
}
|
||||
if len(got) != 1 || got[0] != "API_KEY" {
|
||||
t.Errorf("lower-start: got %v, want [API_KEY]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeEnvMembers_NameTooLong(t *testing.T) {
|
||||
// Max 128 chars after the leading uppercase char.
|
||||
longName := "X" + string(make([]byte, 128))
|
||||
got, ok := sanitizeEnvMembers([]string{longName, "SHORT"}, "test")
|
||||
if !ok {
|
||||
t.Error("too-long: expected ok=true (SHORT is valid)")
|
||||
}
|
||||
if len(got) != 1 || got[0] != "SHORT" {
|
||||
t.Errorf("too-long: got %v, want [SHORT]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// flattenAndSortRequirements
|
||||
// flattenAndSortRequirements tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFlattenAndSortRequirements_Empty(t *testing.T) {
|
||||
got := flattenAndSortRequirements(map[string]EnvRequirement{})
|
||||
if len(got) != 0 {
|
||||
t.Errorf("empty map: got %d items, want 0", len(got))
|
||||
t.Errorf("empty: got %d, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenAndSortRequirements_SinglesFirst(t *testing.T) {
|
||||
// Singles sort before any-of groups.
|
||||
by := map[string]EnvRequirement{
|
||||
"Z": {Name: "Z"}, // single
|
||||
"X": {Name: "X"}, // single
|
||||
"any": {AnyOf: []string{"A", "B"}},
|
||||
"other": {AnyOf: []string{"C"}},
|
||||
func TestFlattenAndSortRequirements_SingleFirst(t *testing.T) {
|
||||
// Singles come before groups; within singles, alphabetical
|
||||
reqs := map[string]EnvRequirement{
|
||||
envRequirementKey([]string{"ZETA"}): {Name: "ZETA"},
|
||||
envRequirementKey([]string{"ALPHA"}): {Name: "ALPHA"},
|
||||
}
|
||||
got := flattenAndSortRequirements(by)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("wrong count: got %d, want 4", len(got))
|
||||
got := flattenAndSortRequirements(reqs)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(got))
|
||||
}
|
||||
// First two must be singles.
|
||||
singlesFirst := got[0].Name != "" && got[1].Name != ""
|
||||
anyOfAfter := len(got) > 2 && (got[2].Name == "" || got[3].Name == "")
|
||||
if !singlesFirst || !anyOfAfter {
|
||||
t.Errorf("singles-first order violated: %v", got)
|
||||
if got[0].Name != "ALPHA" {
|
||||
t.Errorf("first: got %q, want ALPHA", got[0].Name)
|
||||
}
|
||||
if got[1].Name != "ZETA" {
|
||||
t.Errorf("second: got %q, want ZETA", got[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenAndSortRequirements_SinglesAlphabetical(t *testing.T) {
|
||||
// Within the singles section, alphabetical order.
|
||||
by := map[string]EnvRequirement{
|
||||
"Z": {Name: "Z"},
|
||||
"A": {Name: "A"},
|
||||
"M": {Name: "M"},
|
||||
func TestFlattenAndSortRequirements_GroupsAfterSingles(t *testing.T) {
|
||||
reqs := map[string]EnvRequirement{
|
||||
envRequirementKey([]string{"X"}): {Name: "X"}, // single
|
||||
envRequirementKey([]string{"A", "B"}): {AnyOf: []string{"A", "B"}}, // group
|
||||
}
|
||||
got := flattenAndSortRequirements(by)
|
||||
if got[0].Name != "A" || got[1].Name != "M" || got[2].Name != "Z" {
|
||||
t.Errorf("singles not alphabetically sorted: %v", got)
|
||||
got := flattenAndSortRequirements(reqs)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(got))
|
||||
}
|
||||
// Single X comes before any group
|
||||
if got[0].Name != "X" {
|
||||
t.Errorf("first should be single X: got %+v", got[0])
|
||||
}
|
||||
if len(got[1].AnyOf) != 2 {
|
||||
t.Errorf("second should be group: got %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenAndSortRequirements_AnyOfSortedByKey(t *testing.T) {
|
||||
// Any-of groups are sorted by the envRequirementKey of their members.
|
||||
// Keys must match what envRequirementKey() produces: sorted, NUL-separated.
|
||||
by := map[string]EnvRequirement{
|
||||
"a\x00b": {AnyOf: []string{"b", "a"}}, // canonical key = "a\x00b"
|
||||
"a\x00c": {AnyOf: []string{"a", "c"}}, // canonical key = "a\x00c"
|
||||
func TestFlattenAndSortRequirements_GroupsSortedByMemberKey(t *testing.T) {
|
||||
// Groups sorted by their member-key (envRequirementKey sorts AnyOf members).
|
||||
// {Z,A} → key "A\x00Z"; {B,C} → key "B\x00C". "A..." < "B..." → A,Z group first.
|
||||
reqs := map[string]EnvRequirement{
|
||||
envRequirementKey([]string{"Z", "A"}): {AnyOf: []string{"Z", "A"}}, // key: A\x00Z
|
||||
envRequirementKey([]string{"B", "C"}): {AnyOf: []string{"B", "C"}}, // key: B\x00C
|
||||
}
|
||||
got := flattenAndSortRequirements(by)
|
||||
// Both are any-of (Name == ""), order by key.
|
||||
if got[0].Name != "" || got[1].Name != "" {
|
||||
t.Errorf("expected all any-of, got singles: %v", got)
|
||||
got := flattenAndSortRequirements(reqs)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d, want 2", len(got))
|
||||
}
|
||||
// "a\x00b" < "a\x00c" alphabetically → "a\x00b" first → [{b,a}] first.
|
||||
first := got[0].AnyOf
|
||||
if len(first) == 0 || first[0] != "b" {
|
||||
t.Errorf("any-of sort wrong: got %v first, want any-of [{b,a}]", got)
|
||||
// A\x00Z < B\x00C alphabetically, so the A,Z group sorts first
|
||||
if len(got[0].AnyOf) != 2 || got[0].AnyOf[0] != "Z" {
|
||||
t.Errorf("first group: got %+v, want [Z,A] (key A\\x00Z sorts before B\\x00C)", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// collectOrgEnv — deduplication + required-wins
|
||||
// collectOrgEnv tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestCollectOrgEnv_EmptyTemplate(t *testing.T) {
|
||||
tmpl := &OrgTemplate{}
|
||||
func TestCollectOrgEnv_SingleRequired(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{{Name: "API_KEY"}},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 0 || len(rec) != 0 {
|
||||
t.Errorf("empty template: req=%v rec=%v, want both empty", req, rec)
|
||||
if len(req) != 1 {
|
||||
t.Fatalf("got %d required, want 1", len(req))
|
||||
}
|
||||
if req[0].Name != "API_KEY" {
|
||||
t.Errorf("name: got %q, want API_KEY", req[0].Name)
|
||||
}
|
||||
if len(rec) != 0 {
|
||||
t.Errorf("recommended: got %d, want 0", len(rec))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_RequiredOnly(t *testing.T) {
|
||||
func TestCollectOrgEnv_SingleRecommended(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
RecommendedEnv: []EnvRequirement{{Name: "DEBUG"}},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 0 {
|
||||
t.Errorf("required: got %d, want 0", len(req))
|
||||
}
|
||||
if len(rec) != 1 {
|
||||
t.Fatalf("got %d recommended, want 1", len(rec))
|
||||
}
|
||||
if rec[0].Name != "DEBUG" {
|
||||
t.Errorf("name: got %q, want DEBUG", rec[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_AnyOfGroup(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{{AnyOf: []string{"AWS_KEY", "GCP_KEY", "AZURE_KEY"}}},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 {
|
||||
t.Fatalf("got %d, want 1", len(req))
|
||||
}
|
||||
if len(req[0].AnyOf) != 3 {
|
||||
t.Errorf("any_of members: got %v, want [AWS_KEY, GCP_KEY, AZURE_KEY]", req[0].AnyOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_InvalidNamesFiltered(t *testing.T) {
|
||||
// "lowercase" and "" fail envVarNamePattern → silently dropped
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{{AnyOf: []string{"VALID_KEY", "lowercase", ""}}},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 {
|
||||
t.Fatalf("invalid names filtered: got %d, want 1", len(req))
|
||||
}
|
||||
if len(req[0].AnyOf) != 1 || req[0].AnyOf[0] != "VALID_KEY" {
|
||||
t.Errorf("valid names kept: got %v", req[0].AnyOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_GroupWithOneInvalid_KeepsRest(t *testing.T) {
|
||||
// Mixed: one valid + one invalid → valid member is kept, invalid dropped
|
||||
// regex requires ^[A-Z][A-Z0-9_]* — lowercase names are invalid
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{{AnyOf: []string{"GOOD_KEY", "lowercase_invalid"}}},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 {
|
||||
t.Fatalf("got %d, want 1", len(req))
|
||||
}
|
||||
if len(req[0].AnyOf) != 1 || req[0].AnyOf[0] != "GOOD_KEY" {
|
||||
t.Errorf("kept valid member: got %v, want [GOOD_KEY]", req[0].AnyOf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_AllInvalidGroup_Dropped(t *testing.T) {
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{{AnyOf: []string{"lowercase", ""}}},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 0 {
|
||||
t.Errorf("all-invalid group: got %d, want 0", len(req))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_RequiredSingleDominatesAnyOfGroup(t *testing.T) {
|
||||
// Required: API_KEY (strict)
|
||||
// Required: any_of [API_KEY, ALT_KEY]
|
||||
// → the any_of group is redundant (API_KEY satisfies it already)
|
||||
// → any_of group should be dropped from required
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "API_KEY"},
|
||||
{AnyOf: []string{"API_KEY", "ALT_KEY"}},
|
||||
},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 || req[0].Name != "API_KEY" {
|
||||
t.Errorf("required-only: req=%v, want [API_KEY]", req)
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 {
|
||||
t.Fatalf("strict dominates group: got %d entries, want 1", len(req))
|
||||
}
|
||||
if len(rec) != 0 {
|
||||
t.Errorf("required-only: rec=%v, want []", rec)
|
||||
if req[0].Name != "API_KEY" {
|
||||
t.Errorf("strict: got %+v, want name=API_KEY", req[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_SameMembers_RequiredWins(t *testing.T) {
|
||||
// Same set in required AND recommended → required wins, recommended drops it.
|
||||
func TestCollectOrgEnv_RequiredSingleDominatesRecommendedAnyOf(t *testing.T) {
|
||||
// Required: FOO (strict)
|
||||
// Recommended: any_of [FOO, BAR]
|
||||
// → FOO is already required; the recommended any_of is redundant
|
||||
// → recommended any_of should be dropped
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "SHARED_KEY"},
|
||||
},
|
||||
RecommendedEnv: []EnvRequirement{
|
||||
{Name: "SHARED_KEY"},
|
||||
},
|
||||
RequiredEnv: []EnvRequirement{{Name: "FOO"}},
|
||||
RecommendedEnv: []EnvRequirement{{AnyOf: []string{"FOO", "BAR"}}},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 || req[0].Name != "SHARED_KEY" {
|
||||
t.Errorf("required-wins: req=%v", req)
|
||||
if len(req) != 1 || req[0].Name != "FOO" {
|
||||
t.Errorf("required: got %+v", req)
|
||||
}
|
||||
if len(rec) != 0 {
|
||||
t.Errorf("required-wins: rec=%v, want [] (dropped by required)", rec)
|
||||
t.Errorf("recommended any_of dominated by strict: got %d, want 0", len(rec))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_StrictDominatesAnyOf_CrossTier(t *testing.T) {
|
||||
// Required strict name X causes any-of [X, Y] in recommended to be pruned.
|
||||
func TestCollectOrgEnv_SameTierStrictDominatesGroup(t *testing.T) {
|
||||
// Both in required: X (strict), any_of [X, Y] (group)
|
||||
// Strict X makes the any_of redundant within the same tier
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "ANTHROPIC_API_KEY"},
|
||||
{Name: "X"},
|
||||
{AnyOf: []string{"X", "Y"}},
|
||||
},
|
||||
RecommendedEnv: []EnvRequirement{
|
||||
{AnyOf: []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY"}},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 {
|
||||
t.Fatalf("got %d, want 1", len(req))
|
||||
}
|
||||
if req[0].Name != "X" {
|
||||
t.Errorf("strict dominates same-tier group: got %+v", req[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_WorkspaceLevel(t *testing.T) {
|
||||
// Workspaces can also declare required/recommended env
|
||||
tmpl := &OrgTemplate{
|
||||
Workspaces: []OrgWorkspace{
|
||||
{
|
||||
Name: "Dev",
|
||||
RequiredEnv: []EnvRequirement{{Name: "DEV_KEY"}},
|
||||
RecommendedEnv: []EnvRequirement{{Name: "DEV_TOOL"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 {
|
||||
t.Errorf("cross-tier: req=%v", req)
|
||||
t.Fatalf("workspace required: got %d, want 1", len(req))
|
||||
}
|
||||
if len(rec) != 0 {
|
||||
t.Errorf("cross-tier: any-of should be pruned from rec, got rec=%v", rec)
|
||||
if req[0].Name != "DEV_KEY" {
|
||||
t.Errorf("workspace required: got %v", req[0])
|
||||
}
|
||||
if len(rec) != 1 {
|
||||
t.Fatalf("workspace recommended: got %d, want 1", len(rec))
|
||||
}
|
||||
if rec[0].Name != "DEV_TOOL" {
|
||||
t.Errorf("workspace recommended: got %v", rec[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_StrictDominatesAnyOf_SameTier(t *testing.T) {
|
||||
// Required strict X dominates any-of [X, Y] within required (same-tier dedup).
|
||||
func TestCollectOrgEnv_DeepNesting(t *testing.T) {
|
||||
// Nested children also contribute env requirements
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "SECRET"},
|
||||
{AnyOf: []string{"SECRET", "OTHER"}},
|
||||
},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 || req[0].Name != "SECRET" {
|
||||
t.Errorf("same-tier: req=%v, want single [SECRET]", req)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_DeduplicationAcrossLevels(t *testing.T) {
|
||||
// Same requirement declared at org level and workspace level → deduped once.
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "SHARED"},
|
||||
},
|
||||
Workspaces: []OrgWorkspace{{
|
||||
Name: "ws1",
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "SHARED"}, // duplicate
|
||||
RequiredEnv: []EnvRequirement{{Name: "ORG_LEVEL"}},
|
||||
Workspaces: []OrgWorkspace{
|
||||
{
|
||||
Name: "Root",
|
||||
RequiredEnv: []EnvRequirement{{Name: "ROOT_LEVEL"}},
|
||||
Children: []OrgWorkspace{
|
||||
{
|
||||
Name: "Child",
|
||||
RequiredEnv: []EnvRequirement{{Name: "CHILD_LEVEL"}},
|
||||
Children: []OrgWorkspace{
|
||||
{Name: "GrandChild", RecommendedEnv: []EnvRequirement{{Name: "GRANDCHILD_TOOL"}}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 || req[0].Name != "SHARED" {
|
||||
t.Errorf("dedup: req=%v, want single [SHARED]", req)
|
||||
req, rec := collectOrgEnv(tmpl)
|
||||
if len(req) != 3 {
|
||||
t.Errorf("3 required levels: got %d: %+v", len(req), req)
|
||||
}
|
||||
if len(rec) != 1 {
|
||||
t.Errorf("1 recommended: got %d: %+v", len(rec), rec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_WorkspaceInheritance(t *testing.T) {
|
||||
// Child workspace inherits parent's required env (union, not override).
|
||||
func TestCollectOrgEnv_DedupAcrossTiers(t *testing.T) {
|
||||
// Same key declared at org level AND workspace level → deduped to 1
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "ORG_KEY"},
|
||||
RequiredEnv: []EnvRequirement{{Name: "SHARED"}},
|
||||
Workspaces: []OrgWorkspace{
|
||||
{Name: "ws", RequiredEnv: []EnvRequirement{{Name: "SHARED"}}},
|
||||
},
|
||||
Workspaces: []OrgWorkspace{{
|
||||
Name: "child",
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "CHILD_KEY"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 2 {
|
||||
t.Errorf("inheritance: req=%v, want [ORG_KEY, CHILD_KEY]", req)
|
||||
if len(req) != 1 {
|
||||
t.Errorf("dedup across tiers: got %d, want 1", len(req))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_AnyOfInRecommended_CrossTier(t *testing.T) {
|
||||
// Recommended any-of with member shared by required strict → pruned.
|
||||
func TestCollectOrgEnv_DedupWithinGroup(t *testing.T) {
|
||||
// Same key declared multiple times within required → deduped
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "KEY_A"},
|
||||
},
|
||||
RecommendedEnv: []EnvRequirement{
|
||||
{AnyOf: []string{"KEY_A", "KEY_B"}},
|
||||
{Name: "KEY_C"},
|
||||
{Name: "DUPE"},
|
||||
{Name: "DUPE"},
|
||||
},
|
||||
}
|
||||
_, rec := collectOrgEnv(tmpl)
|
||||
// KEY_A (strict) prunes the any-of group from recommended.
|
||||
// KEY_C (strict) remains.
|
||||
if len(rec) != 1 || rec[0].Name != "KEY_C" {
|
||||
t.Errorf("any-of cross-tier: rec=%v, want [KEY_C]", rec)
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 1 {
|
||||
t.Errorf("dedup within tier: got %d, want 1", len(req))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectOrgEnv_MixedCasePreservesSort(t *testing.T) {
|
||||
// Sort order: singles first (alpha), then groups (by member-key)
|
||||
tmpl := &OrgTemplate{
|
||||
RequiredEnv: []EnvRequirement{
|
||||
{Name: "ZETA"},
|
||||
{Name: "ALPHA"},
|
||||
{AnyOf: []string{"B", "A"}}, // key: A\x00B
|
||||
{AnyOf: []string{"Y", "X"}}, // key: X\x00Y
|
||||
},
|
||||
}
|
||||
req, _ := collectOrgEnv(tmpl)
|
||||
if len(req) != 4 {
|
||||
t.Fatalf("got %d, want 4", len(req))
|
||||
}
|
||||
// Singles first
|
||||
if req[0].Name != "ALPHA" {
|
||||
t.Errorf("single ALPHA first: got %+v", req[0])
|
||||
}
|
||||
if req[1].Name != "ZETA" {
|
||||
t.Errorf("single ZETA second: got %+v", req[1])
|
||||
}
|
||||
// Groups after singles; A,B (key A\x00B) < X,Y (key X\x00Y)
|
||||
if len(req[2].AnyOf) != 2 {
|
||||
t.Errorf("third should be group: got %+v", req[2])
|
||||
}
|
||||
if req[2].AnyOf[0] != "B" { // "B" is first alphabetically in [A,B]
|
||||
t.Errorf("A,B group should come first: got %+v", req[2])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
// Tests for the pure layout helpers in org.go:
|
||||
// childSlot, sizeOfSubtree, childSlotInGrid. These compute the canvas
|
||||
// grid positions for org-import workspace trees and mirror the TypeScript
|
||||
// layout functions in canvas-topology.ts (defaultChildSlot, parentMinSize,
|
||||
// childSlotInGrid). The two sides use slightly different default sizes
|
||||
// (Go: 240×130, TS: 210×120) so they are tested independently.
|
||||
|
||||
// childSlot — 2-column fixed-size grid, one row of child cards.
|
||||
func TestChildSlot_ZeroIndex(t *testing.T) {
|
||||
x, y := childSlot(0)
|
||||
// col=0, row=0
|
||||
// x = 16 + 0*(240+14) = 16
|
||||
// y = 130 + 0*(130+14) = 130
|
||||
if x != 16.0 {
|
||||
t.Errorf("slot 0 x: got %v, want 16.0", x)
|
||||
}
|
||||
if y != 130.0 {
|
||||
t.Errorf("slot 0 y: got %v, want 130.0", y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlot_SecondColumn(t *testing.T) {
|
||||
x, y := childSlot(1)
|
||||
// col=1, row=0
|
||||
// x = 16 + 1*(240+14) = 16+254 = 270
|
||||
// y = 130
|
||||
if x != 270.0 {
|
||||
t.Errorf("slot 1 x: got %v, want 270.0", x)
|
||||
}
|
||||
if y != 130.0 {
|
||||
t.Errorf("slot 1 y: got %v, want 130.0", y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlot_SecondRow(t *testing.T) {
|
||||
x, y := childSlot(2)
|
||||
// col=0, row=1
|
||||
// x = 16
|
||||
// y = 130 + 1*(130+14) = 130+144 = 274
|
||||
if x != 16.0 {
|
||||
t.Errorf("slot 2 x: got %v, want 16.0", x)
|
||||
}
|
||||
if y != 274.0 {
|
||||
t.Errorf("slot 2 y: got %v, want 274.0", y)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlot_ThirdRowFirstColumn(t *testing.T) {
|
||||
x, y := childSlot(4)
|
||||
// col=0, row=2
|
||||
// x = 16
|
||||
// y = 130 + 2*(130+14) = 130+288 = 418
|
||||
if x != 16.0 {
|
||||
t.Errorf("slot 4 x: got %v, want 16.0", x)
|
||||
}
|
||||
if y != 418.0 {
|
||||
t.Errorf("slot 4 y: got %v, want 418.0", y)
|
||||
}
|
||||
}
|
||||
|
||||
// sizeOfSubtree — bounding-box computation for org-import layout.
|
||||
func TestSizeOfSubtree_Leaf(t *testing.T) {
|
||||
ws := OrgWorkspace{Name: "leaf"}
|
||||
s := sizeOfSubtree(ws)
|
||||
// Leaf → childDefaultWidth × childDefaultHeight
|
||||
if s.width != 240.0 {
|
||||
t.Errorf("leaf width: got %v, want 240.0", s.width)
|
||||
}
|
||||
if s.height != 130.0 {
|
||||
t.Errorf("leaf height: got %v, want 130.0", s.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_OneChild(t *testing.T) {
|
||||
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{{Name: "child"}}}
|
||||
s := sizeOfSubtree(ws)
|
||||
// 1 child → cols=1, rows=1
|
||||
// child subtree = (240, 130)
|
||||
// width = 16*2 + 240*1 + 14*0 = 272
|
||||
// height = 130 + 130 + 14*0 + 16 = 276
|
||||
if s.width != 272.0 {
|
||||
t.Errorf("1-child width: got %v, want 272.0", s.width)
|
||||
}
|
||||
if s.height != 276.0 {
|
||||
t.Errorf("1-child height: got %v, want 276.0", s.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_TwoChildren(t *testing.T) {
|
||||
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{
|
||||
{Name: "c0"}, {Name: "c1"},
|
||||
}}
|
||||
s := sizeOfSubtree(ws)
|
||||
// 2 children → cols=2, rows=1
|
||||
// maxColW = 240, totalRowH = 130
|
||||
// width = 16*2 + 240*2 + 14*1 = 32+480+14 = 526
|
||||
// height = 130 + 130 + 14*0 + 16 = 276
|
||||
if s.width != 526.0 {
|
||||
t.Errorf("2-child width: got %v, want 526.0", s.width)
|
||||
}
|
||||
if s.height != 276.0 {
|
||||
t.Errorf("2-child height: got %v, want 276.0", s.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_ThreeChildren(t *testing.T) {
|
||||
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{
|
||||
{Name: "c0"}, {Name: "c1"}, {Name: "c2"},
|
||||
}}
|
||||
s := sizeOfSubtree(ws)
|
||||
// 3 children → cols=2 (< 3 so capped at 2), rows=2
|
||||
// each child = (240, 130), maxColW=240, rowHeights=[130,130]
|
||||
// totalRowH = 130+130 = 260
|
||||
// width = 16*2 + 240*2 + 14*1 = 526
|
||||
// height = 130 + 260 + 14*1 + 16 = 420
|
||||
if s.width != 526.0 {
|
||||
t.Errorf("3-child width: got %v, want 526.0", s.width)
|
||||
}
|
||||
if s.height != 420.0 {
|
||||
t.Errorf("3-child height: got %v, want 420.0", s.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_FourChildren(t *testing.T) {
|
||||
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{
|
||||
{Name: "c0"}, {Name: "c1"}, {Name: "c2"}, {Name: "c3"},
|
||||
}}
|
||||
s := sizeOfSubtree(ws)
|
||||
// 4 children → cols=2, rows=2
|
||||
// width = 16*2 + 240*2 + 14*1 = 526
|
||||
// height = 130 + 260 + 14*1 + 16 = 420
|
||||
if s.width != 526.0 {
|
||||
t.Errorf("4-child width: got %v, want 526.0", s.width)
|
||||
}
|
||||
if s.height != 420.0 {
|
||||
t.Errorf("4-child height: got %v, want %v", s.height, 420.0)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_FiveChildren(t *testing.T) {
|
||||
ws := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{
|
||||
{Name: "c0"}, {Name: "c1"}, {Name: "c2"}, {Name: "c3"}, {Name: "c4"},
|
||||
}}
|
||||
s := sizeOfSubtree(ws)
|
||||
// 5 children → cols=2, rows=3
|
||||
// rowHeights = [130, 130, 130], totalRowH = 390
|
||||
// width = 16*2 + 240*2 + 14*1 = 526
|
||||
// height = 130 + 390 + 14*2 + 16 = 564
|
||||
if s.width != 526.0 {
|
||||
t.Errorf("5-child width: got %v, want 526.0", s.width)
|
||||
}
|
||||
if s.height != 564.0 {
|
||||
t.Errorf("5-child height: got %v, want 564.0", s.height)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSizeOfSubtree_NestedTree(t *testing.T) {
|
||||
// Grandparent → [Parent(→ child), leaf]
|
||||
// parent subtree (1 child): width=272, height=276
|
||||
// grandparent:
|
||||
// children = [parent, leaf]
|
||||
// maxColW = max(272, 240) = 272
|
||||
// cols=2, rows=1
|
||||
// width = 16*2 + 272*2 + 14*1 = 590
|
||||
// height = 130 + max(276, 130) + 14*0 + 16 = 422
|
||||
parent := OrgWorkspace{Name: "parent", Children: []OrgWorkspace{{Name: "grandchild"}}}
|
||||
ws := OrgWorkspace{Name: "grandparent", Children: []OrgWorkspace{parent, {Name: "leaf"}}}
|
||||
s := sizeOfSubtree(ws)
|
||||
if s.width != 590.0 {
|
||||
t.Errorf("nested width: got %v, want 590.0", s.width)
|
||||
}
|
||||
if s.height != 422.0 {
|
||||
t.Errorf("nested height: got %v, want 422.0", s.height)
|
||||
}
|
||||
}
|
||||
|
||||
// childSlotInGrid — sibling-aware slot computation; taller siblings push
|
||||
// subsequent rows down without displacing the column grid.
|
||||
func TestChildSlotInGrid_EmptySiblings(t *testing.T) {
|
||||
x, y := childSlotInGrid(0, nil)
|
||||
x2, y2 := childSlotInGrid(0, []nodeSize{})
|
||||
// Both nil and empty slice return the top-left padded origin.
|
||||
got1, got2 := struct{ x, y float64 }{x, y}, struct{ x, y float64 }{x2, y2}
|
||||
for _, g := range []struct{ x, y float64 }{got1, got2} {
|
||||
if g.x != 16.0 || g.y != 130.0 {
|
||||
t.Errorf("empty siblings: got (%.0f, %.0f), want (16, 130)", g.x, g.y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_Slot0MatchesDefaultChildSlot(t *testing.T) {
|
||||
// With uniform 240×130 siblings, slot 0 should equal childSlot(0).
|
||||
sizes := []nodeSize{{width: 240, height: 130}, {width: 240, height: 130}}
|
||||
x, y := childSlotInGrid(0, sizes)
|
||||
cx, cy := childSlot(0)
|
||||
if x != cx || y != cy {
|
||||
t.Errorf("uniform siblings slot 0: got (%.0f, %.0f), want childSlot (%.0f, %.0f)", x, y, cx, cy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_Slot1MatchesDefaultChildSlot(t *testing.T) {
|
||||
sizes := []nodeSize{{width: 240, height: 130}, {width: 240, height: 130}}
|
||||
x, y := childSlotInGrid(1, sizes)
|
||||
cx, cy := childSlot(1)
|
||||
if x != cx || y != cy {
|
||||
t.Errorf("uniform siblings slot 1: got (%.0f, %.0f), want childSlot (%.0f, %.0f)", x, y, cx, cy)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_TallerSiblingBumpsNextRow(t *testing.T) {
|
||||
// Sibling at index 1 is taller (height=300 vs 130).
|
||||
// Slot 0: col=0, row=0 → x=16, y=130
|
||||
// Slot 1: col=1, row=0 → x=270, y=130
|
||||
// Slot 2: col=0, row=1 → x=16, y = 130 + 300 + 14 = 444
|
||||
sizes := []nodeSize{
|
||||
{width: 240, height: 130},
|
||||
{width: 240, height: 300}, // taller — pushes row 2 down
|
||||
{width: 240, height: 130},
|
||||
}
|
||||
x0, y0 := childSlotInGrid(0, sizes)
|
||||
if x0 != 16.0 || y0 != 130.0 {
|
||||
t.Errorf("slot 0: got (%.0f, %.0f), want (16, 130)", x0, y0)
|
||||
}
|
||||
|
||||
x1, y1 := childSlotInGrid(1, sizes)
|
||||
if x1 != 270.0 || y1 != 130.0 {
|
||||
t.Errorf("slot 1: got (%.0f, %.0f), want (270, 130)", x1, y1)
|
||||
}
|
||||
|
||||
x2, y2 := childSlotInGrid(2, sizes)
|
||||
// y = parentHeaderPadding + rowHeights[0] + childGutter
|
||||
// rowHeights[0] = max(130, 300) = 300
|
||||
// y = 130 + 300 + 14 = 444
|
||||
if x2 != 16.0 || y2 != 444.0 {
|
||||
t.Errorf("slot 2: got (%.0f, %.0f), want (16, 444) — taller sibling pushed row down", x2, y2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_UniformWideSiblingSetsColumnWidth(t *testing.T) {
|
||||
// Sibling at index 0 is wider (300 vs 240).
|
||||
// Slot 0: x=16, y=130
|
||||
// Slot 1: col=1 → x = 16 + 300 + 14 = 330 (NOT 270 = 16+240+14)
|
||||
// y=130
|
||||
sizes := []nodeSize{
|
||||
{width: 300, height: 130}, // wider — sets column width
|
||||
{width: 240, height: 130},
|
||||
}
|
||||
x1, y1 := childSlotInGrid(1, sizes)
|
||||
if x1 != 330.0 || y1 != 130.0 {
|
||||
t.Errorf("slot 1: got (%.0f, %.0f), want (330, 130) — col width set by wider sibling", x1, y1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_Slot3OverflowToSecondRow(t *testing.T) {
|
||||
// 4 siblings in 2-column grid → rows=2
|
||||
// Slot 0: col=0, row=0
|
||||
// Slot 1: col=1, row=0
|
||||
// Slot 2: col=0, row=1
|
||||
// Slot 3: col=1, row=1
|
||||
sizes := []nodeSize{
|
||||
{width: 240, height: 130},
|
||||
{width: 240, height: 130},
|
||||
{width: 240, height: 130},
|
||||
{width: 240, height: 130},
|
||||
}
|
||||
x3, y3 := childSlotInGrid(3, sizes)
|
||||
// y = 130 + 130 + 14 = 274
|
||||
if x3 != 270.0 || y3 != 274.0 {
|
||||
t.Errorf("slot 3: got (%.0f, %.0f), want (270, 274)", x3, y3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChildSlotInGrid_MixedSizesCorrectRowAccumulation(t *testing.T) {
|
||||
// 3 siblings: [short(130), tall(300), medium(200)]
|
||||
// cols=2, rows=2
|
||||
// rowHeights[0] = max(130, 300) = 300
|
||||
// rowHeights[1] = max(200, 0) = 200
|
||||
// slot 0: col=0, row=0 → x=16, y=130
|
||||
// slot 1: col=1, row=0 → x=330, y=130
|
||||
// slot 2: col=0, row=1 → x=16, y=130+300+14=444
|
||||
sizes := []nodeSize{
|
||||
{width: 240, height: 130},
|
||||
{width: 240, height: 300},
|
||||
{width: 240, height: 200},
|
||||
}
|
||||
x2, y2 := childSlotInGrid(2, sizes)
|
||||
if x2 != 16.0 || y2 != 444.0 {
|
||||
t.Errorf("slot 2: got (%.0f, %.0f), want (16, 444)", x2, y2)
|
||||
}
|
||||
}
|
||||
@@ -242,7 +242,7 @@ func (h *PluginsHandler) isExternalRuntime(workspaceID string) bool {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return isExternalLikeRuntime(runtime)
|
||||
return runtime == "external"
|
||||
}
|
||||
|
||||
func (h *PluginsHandler) execAsRoot(ctx context.Context, containerName string, cmd []string) (string, error) {
|
||||
|
||||
@@ -191,125 +191,3 @@ func TestTarHostDirWithPrefix_PrefixNormalization(t *testing.T) {
|
||||
t.Errorf("trailing-slash on prefix changed archive shape; tarHostDirWithPrefix should be slash-insensitive")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── tarWalk (direct) ─────────────────────────────────────────────────────────
|
||||
|
||||
// TestTarWalk_EmptyDirectory: an empty dir produces exactly one tar entry
|
||||
// (the dir itself, with a trailing slash).
|
||||
func TestTarWalk_EmptyDirectory(t *testing.T) {
|
||||
hostDir := t.TempDir()
|
||||
var buf bytes.Buffer
|
||||
tw := newTarWriter(&buf)
|
||||
if err := tarWalk(hostDir, "prefix", tw); err != nil {
|
||||
t.Fatalf("tarWalk: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
entries := readTarNames(&buf)
|
||||
if len(entries) != 1 {
|
||||
t.Errorf("empty dir: got %d entries; want 1", len(entries))
|
||||
}
|
||||
if entries[0] != "prefix/" {
|
||||
t.Errorf("empty dir sole entry: got %q; want prefix/", entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTarWalk_DirEntryHasTrailingSlash: directory entries must end with '/'
|
||||
// per tar format; tar.Header.Typeflag '5' (dir) must produce "name/" not "name".
|
||||
func TestTarWalk_DirEntryHasTrailingSlash(t *testing.T) {
|
||||
hostDir := t.TempDir()
|
||||
sub := filepath.Join(hostDir, "subdir")
|
||||
if err := os.MkdirAll(sub, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
tw := newTarWriter(&buf)
|
||||
if err := tarWalk(hostDir, "p", tw); err != nil {
|
||||
t.Fatalf("tarWalk: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
entries := readTarNames(&buf)
|
||||
for _, e := range entries {
|
||||
// Only "p/" (the root) and "p/subdir/" are dirs; files have no trailing slash.
|
||||
if !strings.HasSuffix(e, ".txt") && !strings.HasSuffix(e, "/") {
|
||||
t.Errorf("non-file entry %q missing trailing slash: should be a dir", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTarWalk_FileContentsPreserved: regular file bytes survive tar round-trip
|
||||
// through tarWalk + tar.Reader.
|
||||
func TestTarWalk_FileContentsPreserved(t *testing.T) {
|
||||
hostDir := t.TempDir()
|
||||
contents := map[string]string{
|
||||
"plugin.yaml": "name: test\nversion: 1.0.0\n",
|
||||
"skills/foo/SKILL.md": "# Foo\n",
|
||||
}
|
||||
for rel, body := range contents {
|
||||
full := filepath.Join(hostDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
tw := newTarWriter(&buf)
|
||||
if err := tarWalk(hostDir, "prefix", tw); err != nil {
|
||||
t.Fatalf("tarWalk: %v", err)
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
// Read back and verify contents.
|
||||
extracted := map[string]string{}
|
||||
tr := tar.NewReader(&buf)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("reader: %v", err)
|
||||
}
|
||||
if hdr.Typeflag == tar.TypeReg {
|
||||
data, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rel := strings.TrimPrefix(hdr.Name, "prefix/")
|
||||
extracted[rel] = string(data)
|
||||
}
|
||||
}
|
||||
for rel, want := range contents {
|
||||
if got := extracted[rel]; got != want {
|
||||
t.Errorf("content[%s] = %q; want %q", rel, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readTarNames extracts just the Name field from every entry in a tar buffer.
|
||||
func readTarNames(buf *bytes.Buffer) []string {
|
||||
var names []string
|
||||
tr := tar.NewReader(buf)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
names = append(names, hdr.Name)
|
||||
// Advance past non-header bytes.
|
||||
if hdr.Size > 0 {
|
||||
io.Copy(io.Discard, tr)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ func TestSupportsRuntime_HyphenUnderscoreNormalized(t *testing.T) {
|
||||
// "claude-code" and "claude_code" are considered equal.
|
||||
info := pluginInfo{Name: "test", Runtimes: []string{"claude-code"}}
|
||||
assert.True(t, info.supportsRuntime("claude_code"))
|
||||
assert.True(t, info.supportsRuntime("claude-code")) // symmetric hyphen form
|
||||
assert.True(t, info.supportsRuntime("anthropic_claude"))
|
||||
}
|
||||
|
||||
func TestSupportsRuntime_HyphenVsUnderscoreReverse(t *testing.T) {
|
||||
|
||||
@@ -76,34 +76,6 @@ func TestPluginUninstall_ExternalRuntime_Returns422(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginInstall_KimiRuntime_Returns422 — kimi-cli is BYO-compute,
|
||||
// same shape as external. Push-install via docker exec must be rejected.
|
||||
func TestPluginInstall_KimiRuntime_Returns422(t *testing.T) {
|
||||
h := NewPluginsHandler(t.TempDir(), nil, nil).
|
||||
WithRuntimeLookup(func(workspaceID string) (string, error) {
|
||||
return "kimi-cli", nil
|
||||
})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-kimi"}}
|
||||
c.Request = httptest.NewRequest(
|
||||
"POST",
|
||||
"/workspaces/ws-kimi/plugins",
|
||||
bytes.NewBufferString(`{"source":"local://my-plugin"}`),
|
||||
)
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
h.Install(c)
|
||||
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Errorf("expected 422 for runtime='kimi-cli', got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "external runtimes") {
|
||||
t.Errorf("expected error body to mention 'external runtimes', got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginInstall_ContainerBackedRuntime_FallsThroughGuard — the runtime
|
||||
// guard MUST NOT short-circuit container-backed runtimes. With
|
||||
// `runtime='claude-code'` the install proceeds past the guard; without a
|
||||
|
||||
@@ -158,7 +158,7 @@ func (h *RegistryHandler) resolveDeliveryMode(ctx context.Context, workspaceID,
|
||||
if existing.Valid && existing.String != "" {
|
||||
return existing.String, nil
|
||||
}
|
||||
if runtime.Valid && isExternalLikeRuntime(runtime.String) {
|
||||
if runtime.Valid && runtime.String == "external" {
|
||||
return models.DeliveryModePoll, nil
|
||||
}
|
||||
return models.DeliveryModePush, nil
|
||||
|
||||
@@ -1721,65 +1721,6 @@ func TestRegister_ExternalRuntime_DefaultsToPoll(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_KimiRuntime_DefaultsToPoll mirrors the external-runtime
|
||||
// poll-default test: a workspace whose existing row has runtime=kimi-cli
|
||||
// and empty delivery_mode must resolve to poll (laptop/NAT-safe default).
|
||||
func TestRegister_KimiRuntime_DefaultsToPoll(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewRegistryHandler(broadcaster)
|
||||
|
||||
const wsID = "ws-kimi-default-poll"
|
||||
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
|
||||
mock.ExpectQuery(`SELECT delivery_mode, runtime FROM workspaces WHERE id`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow(sql.NullString{}, "kimi-cli"))
|
||||
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow(""))
|
||||
mock.ExpectExec("INSERT INTO structure_events").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(1, 1))
|
||||
mock.ExpectQuery(`SELECT platform_inbound_secret FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"platform_inbound_secret"}).AddRow(nil))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/registry/register",
|
||||
bytes.NewBufferString(`{"id":"`+wsID+`","agent_card":{"name":"a"}}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Register(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["delivery_mode"] != "poll" {
|
||||
t.Errorf("delivery_mode = %v, want %q (kimi runtime + empty mode → poll)",
|
||||
resp["delivery_mode"], "poll")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRegister_NonExternalRuntime_StillDefaultsToPush guards the
|
||||
// inverse: a non-external runtime (langgraph, hermes, etc.) with
|
||||
// empty delivery_mode keeps the historical push default. Catches
|
||||
|
||||
@@ -78,8 +78,6 @@ var fallbackRuntimes = map[string]struct{}{
|
||||
"openclaw": {},
|
||||
"codex": {},
|
||||
"external": {},
|
||||
"kimi": {},
|
||||
"kimi-cli": {},
|
||||
// mock — virtual workspace with hardcoded canned A2A replies.
|
||||
// No container, no EC2, no template repo. See mock_runtime.go
|
||||
// for the full rationale (200-workspace funding-demo org).
|
||||
@@ -110,10 +108,6 @@ func loadRuntimesFromManifest(path string) (map[string]struct{}, error) {
|
||||
// the manifest doesn't know about it. Injected here so we
|
||||
// don't need a special-case in every caller.
|
||||
"external": {},
|
||||
// kimi and kimi-cli are BYO-compute meta-runtimes (same shape
|
||||
// as external). No template repo; injected like external.
|
||||
"kimi": {},
|
||||
"kimi-cli": {},
|
||||
// mock is ALWAYS available for the same reason as external:
|
||||
// virtual workspace, no template repo, never spawns a
|
||||
// container. See mock_runtime.go.
|
||||
@@ -134,28 +128,6 @@ func loadRuntimesFromManifest(path string) (map[string]struct{}, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// isExternalLikeRuntime returns true for runtimes that are BYO-compute
|
||||
// (operator-managed, no platform-owned container or EC2). These runtimes
|
||||
// share behavior around delivery_mode defaulting, plugin install, restart,
|
||||
// and discovery.
|
||||
func isExternalLikeRuntime(runtime string) bool {
|
||||
switch runtime {
|
||||
case "external", "kimi", "kimi-cli":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeExternalRuntime returns the given runtime label if non-empty,
|
||||
// otherwise falls back to "external". Used when persisting BYO-compute
|
||||
// workspaces so we don't store an empty runtime string.
|
||||
func normalizeExternalRuntime(runtime string) string {
|
||||
if runtime == "" {
|
||||
return "external"
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
// initKnownRuntimes is called from the package init chain (see
|
||||
// workspace_provision.go var initialization) to replace the
|
||||
// fallback map with the manifest-derived one. Idempotent —
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestLoadRuntimesFromManifest_StripsDefaultSuffix(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
want := []string{"claude-code", "langgraph", "hermes", "external", "kimi", "kimi-cli"}
|
||||
want := []string{"claude-code", "langgraph", "hermes", "external"}
|
||||
for _, w := range want {
|
||||
if _, ok := got[w]; !ok {
|
||||
t.Errorf("want runtime %q in set, missing. got=%v", w, keys(got))
|
||||
@@ -59,10 +59,8 @@ func TestLoadRuntimesFromManifest_ExternalAlwaysInjected(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
for _, must := range []string{"external", "kimi", "kimi-cli"} {
|
||||
if _, ok := got[must]; !ok {
|
||||
t.Errorf("%s must be injected even when absent from manifest: %v", must, keys(got))
|
||||
}
|
||||
if _, ok := got["external"]; !ok {
|
||||
t.Errorf("external must be injected even when absent from manifest: %v", keys(got))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +95,7 @@ func TestRealManifestParses(t *testing.T) {
|
||||
t.Fatalf("real manifest load: %v", err)
|
||||
}
|
||||
// Core runtimes we always expect to ship.
|
||||
for _, must := range []string{"langgraph", "hermes", "claude-code", "external", "kimi", "kimi-cli"} {
|
||||
for _, must := range []string{"langgraph", "hermes", "claude-code", "external"} {
|
||||
if _, ok := got[must]; !ok {
|
||||
t.Errorf("real manifest missing runtime %q — got=%v", must, keys(got))
|
||||
}
|
||||
|
||||
@@ -63,9 +63,6 @@ func (h *SecretsHandler) List(c *gin.Context) {
|
||||
"updated_at": updatedAt,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListSecrets scan error: %v", err)
|
||||
}
|
||||
|
||||
// 2. Global secrets not overridden at workspace level
|
||||
globalRows, err := db.DB.QueryContext(ctx,
|
||||
@@ -327,9 +324,6 @@ func (h *SecretsHandler) ListGlobal(c *gin.Context) {
|
||||
"scope": "global",
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("ListGlobalSecrets scan error: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, secrets)
|
||||
}
|
||||
|
||||
@@ -406,9 +400,6 @@ func (h *SecretsHandler) restartAllAffectedByGlobalKey(key string) {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
log.Printf("notifyGlobalSecretChange scan error: %v", err)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -428,16 +428,13 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// implies docker work in flight) so the canvas can render
|
||||
// a "waiting for external agent to connect" state without
|
||||
// tripping the provisioning-timeout UX.
|
||||
if payload.External || isExternalLikeRuntime(payload.Runtime) {
|
||||
if payload.External || payload.Runtime == "external" {
|
||||
var connectionToken string
|
||||
if payload.URL != "" {
|
||||
// URL already validated by validateAgentURL above (before BeginTx).
|
||||
// Now persist it: the external URL is set after the workspace row
|
||||
// commits so that a failed URL UPDATE doesn't roll back the row.
|
||||
// Preserve BYO-compute runtime label (kimi, kimi-cli, external) —
|
||||
// don't coerce to generic "external" so the canvas can show the
|
||||
// correct runtime name in the node card.
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = $2, runtime = $3, updated_at = now() WHERE id = $4`, payload.URL, models.StatusOnline, normalizeExternalRuntime(payload.Runtime), id)
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET url = $1, status = $2, runtime = 'external', updated_at = now() WHERE id = $3`, payload.URL, models.StatusOnline, id)
|
||||
if err := db.CacheURL(ctx, id, payload.URL); err != nil {
|
||||
log.Printf("External workspace: failed to cache URL for %s: %v", id, err)
|
||||
}
|
||||
@@ -449,8 +446,7 @@ func (h *WorkspaceHandler) Create(c *gin.Context) {
|
||||
// in awaiting_agent. First POST /registry/register call
|
||||
// from the external agent (with this token + its URL)
|
||||
// flips the row to online.
|
||||
// Preserve BYO-compute runtime label (kimi, kimi-cli, external).
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, runtime = $2, updated_at = now() WHERE id = $3`, models.StatusAwaitingAgent, normalizeExternalRuntime(payload.Runtime), id)
|
||||
db.DB.ExecContext(ctx, `UPDATE workspaces SET status = $1, runtime = 'external', updated_at = now() WHERE id = $2`, models.StatusAwaitingAgent, id)
|
||||
tok, tokErr := wsauth.IssueToken(ctx, db.DB, id)
|
||||
if tokErr != nil {
|
||||
log.Printf("External workspace %s: token issuance failed: %v", id, tokErr)
|
||||
|
||||
@@ -141,19 +141,6 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate workspace_dir before hitting the DB — no point checking
|
||||
// existence if the provided path is obviously unsafe.
|
||||
if wsDir, ok := body["workspace_dir"]; ok {
|
||||
if wsDir != nil {
|
||||
if dirStr, isStr := wsDir.(string); isStr && dirStr != "" {
|
||||
if err := validateWorkspaceDir(dirStr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace directory"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
|
||||
// Auth is fully enforced at the router layer (WorkspaceAuth middleware, #680).
|
||||
@@ -211,8 +198,15 @@ func (h *WorkspaceHandler) Update(c *gin.Context) {
|
||||
}
|
||||
needsRestart := false
|
||||
if wsDir, ok := body["workspace_dir"]; ok {
|
||||
// Allow null to clear workspace_dir. validateWorkspaceDir already ran
|
||||
// above (before the existence check), so we only write here.
|
||||
// Allow null to clear workspace_dir
|
||||
if wsDir != nil {
|
||||
if dirStr, isStr := wsDir.(string); isStr && dirStr != "" {
|
||||
if err := validateWorkspaceDir(dirStr); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid workspace directory"})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := db.DB.ExecContext(ctx, `UPDATE workspaces SET workspace_dir = $2, updated_at = now() WHERE id = $1`, id, wsDir); err != nil {
|
||||
log.Printf("Update workspace_dir error for %s: %v", id, err)
|
||||
}
|
||||
|
||||
@@ -1,646 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// workspace_crud_test.go — unit coverage for workspace state, update, and delete
|
||||
// handlers (workspace_crud.go), plus field validation helpers.
|
||||
//
|
||||
// Coverage targets:
|
||||
// - State: legacy (no live token), live token + valid, missing token,
|
||||
// invalid token, not found, soft-deleted, query error.
|
||||
// - Update: happy path, invalid UUID, invalid body, not found, each field
|
||||
// update, workspace_dir validation, length limits, YAML special chars.
|
||||
// - Delete: happy path, invalid UUID, has children (409), cascade delete
|
||||
// stop errors, purge path.
|
||||
// - validateWorkspaceID: valid/invalid UUID.
|
||||
// - validateWorkspaceFields: newline rejection, YAML special chars, length.
|
||||
// - validateWorkspaceDir: absolute/relative, traversal, system paths.
|
||||
|
||||
func setupWorkspaceCrudTest(t *testing.T) (sqlmock.Sqlmock, *gin.Engine) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
mock := setupTestDB(t)
|
||||
r := gin.New()
|
||||
return mock, r
|
||||
}
|
||||
|
||||
// ---------- State ----------
|
||||
|
||||
func TestState_LegacyWorkspaceNoLiveToken(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
// No live token — legacy workspace, no auth required.
|
||||
// HasAnyLiveToken always runs first (queries workspace_auth_tokens).
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("running"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["workspace_id"] != wsID {
|
||||
t.Errorf("workspace_id mismatch")
|
||||
}
|
||||
if resp["status"] != "running" {
|
||||
t.Errorf("status mismatch: got %v", resp["status"])
|
||||
}
|
||||
if resp["deleted"] != false {
|
||||
t.Errorf("deleted should be false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_HasLiveTokenMissingAuth(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(1))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
// No Authorization header
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_WorkspaceNotFound(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["deleted"] != true {
|
||||
t.Errorf("deleted should be true for not found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_WorkspaceSoftDeleted(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status"}).AddRow("removed"))
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for soft-deleted, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["deleted"] != true {
|
||||
t.Errorf("deleted should be true")
|
||||
}
|
||||
if resp["status"] != "removed" {
|
||||
t.Errorf("status should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestState_QueryError(t *testing.T) {
|
||||
mock, r := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r.GET("/workspaces/:id/state", h.State)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT COUNT\(\*\) FROM workspace_auth_tokens`).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
|
||||
mock.ExpectQuery(`SELECT status FROM workspaces WHERE id = \$1`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
req, _ := http.NewRequest("GET", "/workspaces/"+wsID+"/state", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Update ----------
|
||||
|
||||
func TestUpdate_InvalidUUID(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Test"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/not-a-uuid", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_InvalidBody(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader([]byte("not json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceNotFound(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))
|
||||
|
||||
body := map[string]interface{}{"name": "New Name"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NameTooLong(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
longName := make([]byte, 256)
|
||||
for i := range longName {
|
||||
longName[i] = 'x'
|
||||
}
|
||||
body := map[string]interface{}{"name": string(longName)}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for name too long, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_RoleTooLong(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
longRole := make([]byte, 1001)
|
||||
for i := range longRole {
|
||||
longRole[i] = 'x'
|
||||
}
|
||||
body := map[string]interface{}{"role": string(longRole)}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for role too long, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NameWithNewline(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Name\nwith newline"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for newline in name, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NameWithYAMLSpecialChars(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
body := map[string]interface{}{"name": "Name with [brackets]"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for YAML special chars in name, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirSystemPath(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET name =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET role =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET tier =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "/etc/my-workspace"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for system path workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirTraversal(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET name =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET role =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET tier =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "/workspace/../../../etc"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for traversal in workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_WorkspaceDirRelativePath(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.PATCH("/workspaces/:id", h.Update)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
mock.ExpectQuery(`SELECT EXISTS\(SELECT 1 FROM workspaces WHERE id = \$1\)`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))
|
||||
mock.ExpectExec(`UPDATE workspaces SET name =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET role =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(`UPDATE workspaces SET tier =`).
|
||||
WithArgs(wsID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
body := map[string]interface{}{"workspace_dir": "relative/path"}
|
||||
b, _ := json.Marshal(body)
|
||||
req, _ := http.NewRequest("PATCH", "/workspaces/"+wsID, bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for relative workspace_dir, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Delete ----------
|
||||
|
||||
func TestDelete_InvalidUUID(t *testing.T) {
|
||||
_, _ = setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/not-a-uuid", nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_HasChildrenWithoutConfirm(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).
|
||||
AddRow("child-1", "Child Workspace"))
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
// No ?confirm=true
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("failed to unmarshal: %v", err)
|
||||
}
|
||||
if resp["status"] != "confirmation_required" {
|
||||
t.Errorf("status should be confirmation_required")
|
||||
}
|
||||
if resp["children_count"] != float64(1) {
|
||||
t.Errorf("children_count should be 1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelete_ChildrenCheckQueryError(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
h := NewWorkspaceHandler(nil, nil, "", "")
|
||||
r := gin.New()
|
||||
r.DELETE("/workspaces/:id", h.Delete)
|
||||
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
mock.ExpectQuery(`SELECT id, name FROM workspaces WHERE parent_id = \$1 AND status != 'removed'`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
req, _ := http.NewRequest("DELETE", "/workspaces/"+wsID, nil)
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Errorf("expected 500, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- validateWorkspaceID ----------
|
||||
|
||||
func TestValidateWorkspaceID_Valid(t *testing.T) {
|
||||
err := validateWorkspaceID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceID_Invalid(t *testing.T) {
|
||||
err := validateWorkspaceID("not-a-uuid")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid UUID")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- validateWorkspaceFields ----------
|
||||
|
||||
func TestValidateWorkspaceFields_NewlineInName(t *testing.T) {
|
||||
err := validateWorkspaceFields("name\nwith\nnewline", "", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for newline in name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_NewlineInRole(t *testing.T) {
|
||||
err := validateWorkspaceFields("", "role\rwith\rcarriage", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for carriage return in role")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_YAMLSpecialCharsInName(t *testing.T) {
|
||||
for _, ch := range "{}[]|>*&!" {
|
||||
err := validateWorkspaceFields("namewith"+string(ch), "", "", "")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for YAML special char %c in name", ch)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_NameTooLong(t *testing.T) {
|
||||
longName := make([]byte, 256)
|
||||
for i := range longName {
|
||||
longName[i] = 'x'
|
||||
}
|
||||
err := validateWorkspaceFields(string(longName), "", "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for name > 255 chars")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_RoleTooLong(t *testing.T) {
|
||||
longRole := make([]byte, 1001)
|
||||
for i := range longRole {
|
||||
longRole[i] = 'x'
|
||||
}
|
||||
err := validateWorkspaceFields("", string(longRole), "", "")
|
||||
if err == nil {
|
||||
t.Error("expected error for role > 1000 chars")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_Valid(t *testing.T) {
|
||||
err := validateWorkspaceFields("ValidName", "ValidRole", "gpt-4", "claude")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- validateWorkspaceDir ----------
|
||||
|
||||
func TestValidateWorkspaceDir_Valid(t *testing.T) {
|
||||
err := validateWorkspaceDir("/workspace/my-workspace")
|
||||
if err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_RelativePath(t *testing.T) {
|
||||
err := validateWorkspaceDir("relative/path")
|
||||
if err == nil {
|
||||
t.Error("expected error for relative path")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_Traversal(t *testing.T) {
|
||||
err := validateWorkspaceDir("/workspace/../etc")
|
||||
if err == nil {
|
||||
t.Error("expected error for traversal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_SystemPathEtc(t *testing.T) {
|
||||
for _, path := range []string{"/etc", "/var", "/proc", "/sys", "/dev", "/boot", "/sbin", "/bin", "/lib", "/usr"} {
|
||||
err := validateWorkspaceDir(path)
|
||||
if err == nil {
|
||||
t.Errorf("expected error for system path %s", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_SystemPathPrefix(t *testing.T) {
|
||||
err := validateWorkspaceDir("/etc/something")
|
||||
if err == nil {
|
||||
t.Error("expected error for /etc/something")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_Empty(t *testing.T) {
|
||||
err := validateWorkspaceDir("")
|
||||
if err == nil {
|
||||
t.Error("expected error for empty path")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- CascadeDelete ----------
|
||||
|
||||
func TestCascadeDelete_InvalidUUID(t *testing.T) {
|
||||
h := &WorkspaceHandler{}
|
||||
descendants, stopErrs, err := h.CascadeDelete(context.Background(), "not-a-uuid")
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid UUID")
|
||||
}
|
||||
if descendants != nil || stopErrs != nil {
|
||||
t.Error("expected nil returns on error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCascadeDelete_DescendantQueryError(t *testing.T) {
|
||||
mock, _ := setupWorkspaceCrudTest(t)
|
||||
wsID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
|
||||
|
||||
// CascadeDelete returns early on descendant query error — nil deps for
|
||||
// StopWorkspace/RemoveVolume/broadcaster are fine since they are never
|
||||
// reached in this error path.
|
||||
h := &WorkspaceHandler{}
|
||||
mock.ExpectQuery(`WITH RECURSIVE descendants AS`).
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
|
||||
deleted, stopErrs, err := h.CascadeDelete(context.Background(), wsID)
|
||||
if err == nil {
|
||||
t.Error("CascadeDelete returned nil error; want descendant query error")
|
||||
}
|
||||
if deleted != nil {
|
||||
t.Errorf("deleted = %v; want nil", deleted)
|
||||
}
|
||||
if stopErrs != nil {
|
||||
t.Errorf("stopErrs = %v; want nil", stopErrs)
|
||||
}
|
||||
// sqlmock verifies all expected queries were executed
|
||||
}
|
||||
|
||||
// Note: Full CascadeDelete testing requires mocking StopWorkspace, RemoveVolume,
|
||||
// and provisioner calls — covered in integration tests. Unit tests here focus on
|
||||
// the validation and pre-condition paths.
|
||||
@@ -4,8 +4,68 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ── validateWorkspaceID ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestValidateWorkspaceID_Valid(t *testing.T) {
|
||||
cases := []string{
|
||||
"550e8400-e29b-41d4-a716-446655440000",
|
||||
"00000000-0000-0000-0000-000000000000",
|
||||
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
}
|
||||
for _, id := range cases {
|
||||
t.Run(id, func(t *testing.T) {
|
||||
if err := validateWorkspaceID(id); err != nil {
|
||||
t.Errorf("validateWorkspaceID(%q) returned error: %v", id, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceID_Invalid(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
id string
|
||||
}{
|
||||
{"empty", ""},
|
||||
{"not a UUID", "not-a-uuid"},
|
||||
{"traversal attack", "../../etc/passwd"},
|
||||
{"SQL injection", "'; DROP TABLE workspaces;--"},
|
||||
{"UUID too short", "550e8400-e29b-41d4-a716"},
|
||||
{"UUID with invalid hex chars", "550e8400-e29b-41d4-a716-44665544000g"},
|
||||
// Note: "UUID all zeros" (nil UUID) is accepted by google/uuid.Parse
|
||||
// as a valid RFC 4122 nil UUID, so it passes validateWorkspaceID.
|
||||
// If nil UUIDs should be rejected, validateWorkspaceID must be updated.
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := validateWorkspaceID(tc.id); err == nil {
|
||||
t.Errorf("validateWorkspaceID(%q): expected error, got nil", tc.id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── validateWorkspaceDir ───────────────────────────────────────────────────────
|
||||
|
||||
func TestValidateWorkspaceDir_Valid(t *testing.T) {
|
||||
cases := []string{
|
||||
"/opt/molecule/workspaces/dev",
|
||||
"/home/user/.molecule/workspaces",
|
||||
// Note: /var/data/workspace-abc-123 is NOT in this list because
|
||||
// /var is blocked as a system path prefix — /var/data is correctly
|
||||
// rejected by validateWorkspaceDir. Use /tmp or /srv for non-system paths.
|
||||
"/opt/services/molecule/tenant-workspaces",
|
||||
"/tmp/molecule/workspaces/dev",
|
||||
}
|
||||
for _, dir := range cases {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
if err := validateWorkspaceDir(dir); err != nil {
|
||||
t.Errorf("validateWorkspaceDir(%q) returned error: %v", dir, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceDir_RelativeRejected(t *testing.T) {
|
||||
cases := []string{
|
||||
"relative/path",
|
||||
@@ -90,6 +150,41 @@ func TestValidateWorkspaceFields_AllEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_Valid(t *testing.T) {
|
||||
if err := validateWorkspaceFields("My Workspace", "Backend Engineer", "gpt-4o", "langgraph"); err != nil {
|
||||
t.Errorf("validateWorkspaceFields with valid args: expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_NameTooLong(t *testing.T) {
|
||||
longName := make([]byte, 256)
|
||||
for i := range longName {
|
||||
longName[i] = 'a'
|
||||
}
|
||||
if err := validateWorkspaceFields(string(longName), "", "", ""); err == nil {
|
||||
t.Error("name > 255 chars: expected error, got nil")
|
||||
}
|
||||
|
||||
// Exactly 255 chars is OK
|
||||
validName := make([]byte, 255)
|
||||
for i := range validName {
|
||||
validName[i] = 'a'
|
||||
}
|
||||
if err := validateWorkspaceFields(string(validName), "", "", ""); err != nil {
|
||||
t.Errorf("name exactly 255 chars: expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_RoleTooLong(t *testing.T) {
|
||||
longRole := make([]byte, 1001)
|
||||
for i := range longRole {
|
||||
longRole[i] = 'x'
|
||||
}
|
||||
if err := validateWorkspaceFields("", string(longRole), "", ""); err == nil {
|
||||
t.Error("role > 1000 chars: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_ModelTooLong(t *testing.T) {
|
||||
longModel := make([]byte, 101)
|
||||
for i := range longModel {
|
||||
@@ -110,6 +205,12 @@ func TestValidateWorkspaceFields_RuntimeTooLong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_NewlineInName(t *testing.T) {
|
||||
if err := validateWorkspaceFields("My\nWorkspace", "", "", ""); err == nil {
|
||||
t.Error("name with \\n: expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateWorkspaceFields_CRLFInRole(t *testing.T) {
|
||||
if err := validateWorkspaceFields("", "Backend\r\nEngineer", "", ""); err == nil {
|
||||
t.Error("role with \\r\\n: expected error, got nil")
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/models"
|
||||
)
|
||||
|
||||
// ==================== resolveDeliveryMode ====================
|
||||
// Covers workspace_dispatchers.go / registry.go:resolveDeliveryMode
|
||||
|
||||
func TestResolveDeliveryMode_PayloadModeWins(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
ctx := context.Background()
|
||||
for _, mode := range []string{models.DeliveryModePush, models.DeliveryModePoll} {
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-any-id", mode)
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode(payloadMode=%q) unexpected error: %v", mode, err)
|
||||
}
|
||||
if got != mode {
|
||||
t.Errorf("resolveDeliveryMode(payloadMode=%q) = %q, want %q", mode, got, mode)
|
||||
}
|
||||
}
|
||||
|
||||
// DB must NOT have been queried when payloadMode is set.
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("DB expectations not met: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_ExistingDeliveryMode(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Workspace row has existing delivery_mode = "poll"
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-poll").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow("poll", "langgraph"))
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-poll", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePoll {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q", got, models.DeliveryModePoll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_ExternalRuntime_DefaultsToPoll(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Row exists but delivery_mode is NULL; runtime = "external"
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-external").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow(nil, "external"))
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-external", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePoll {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q (external runtime)", got, models.DeliveryModePoll)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_SelfHosted_DefaultsToPush(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Row exists; delivery_mode is NULL; runtime = "langgraph"
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-self-hosted").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow(nil, "langgraph"))
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-self-hosted", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePush {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q (self-hosted default)", got, models.DeliveryModePush)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_NotFound_DefaultsToPush(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// Row not found → sql.ErrNoRows → default push
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-nonexistent").
|
||||
WillReturnError(sql.ErrNoRows)
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-nonexistent", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error on no-rows: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePush {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q (not-found default)", got, models.DeliveryModePush)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_DBError_Propagated(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-error").
|
||||
WillReturnError(context.DeadlineExceeded)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := h.resolveDeliveryMode(ctx, "ws-error", "")
|
||||
if err == nil {
|
||||
t.Errorf("resolveDeliveryMode() expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveDeliveryMode_ExistingDeliveryModeEmptyString(t *testing.T) {
|
||||
// When the DB returns an empty (non-NULL) string for delivery_mode,
|
||||
// it falls through to the runtime check (not the existing.Valid path).
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewRegistryHandler(broadcaster)
|
||||
|
||||
// delivery_mode is explicitly empty string (not NULL), runtime = "langgraph"
|
||||
// → falls through to runtime check → "push" for non-external
|
||||
mock.ExpectQuery("SELECT delivery_mode, runtime FROM workspaces").
|
||||
WithArgs("ws-empty-mode").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode", "runtime"}).
|
||||
AddRow("", "langgraph"))
|
||||
|
||||
ctx := context.Background()
|
||||
got, err := h.resolveDeliveryMode(ctx, "ws-empty-mode", "")
|
||||
if err != nil {
|
||||
t.Errorf("resolveDeliveryMode() unexpected error: %v", err)
|
||||
}
|
||||
if got != models.DeliveryModePush {
|
||||
t.Errorf("resolveDeliveryMode() = %q, want %q", got, models.DeliveryModePush)
|
||||
}
|
||||
}
|
||||
@@ -103,11 +103,11 @@ func (h *WorkspaceHandler) Restart(c *gin.Context) {
|
||||
// behavior agree, and surface a clear message instead of silently
|
||||
// no-op'ing — the canvas can show the operator that the fix is on
|
||||
// their side.
|
||||
if isExternalLikeRuntime(dbRuntime) {
|
||||
if dbRuntime == "external" {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "noop",
|
||||
"runtime": dbRuntime,
|
||||
"message": dbRuntime + " workspaces are operator-driven — restart your local agent; platform has nothing to restart",
|
||||
"runtime": "external",
|
||||
"message": "external workspaces are operator-driven — restart your local poller; platform has nothing to restart",
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -547,7 +547,7 @@ func (h *WorkspaceHandler) runRestartCycle(workspaceID string) {
|
||||
// Don't auto-restart external workspaces (no Docker container)
|
||||
// or mock workspaces (no container, every reply is canned —
|
||||
// see workspace-server/internal/handlers/mock_runtime.go).
|
||||
if isExternalLikeRuntime(dbRuntime) || dbRuntime == "mock" {
|
||||
if dbRuntime == "external" || dbRuntime == "mock" {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -179,51 +179,6 @@ func TestRestartHandler_ExternalRuntimeNoOps(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartHandler_KimiRuntimeNoOps(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectQuery("SELECT status, name, tier, COALESCE").
|
||||
WithArgs("ws-kimi").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"status", "name", "tier", "runtime"}).
|
||||
AddRow("offline", "Kimi Agent", 1, "kimi-cli"))
|
||||
|
||||
mock.ExpectQuery("SELECT parent_id FROM workspaces WHERE id =").
|
||||
WithArgs("ws-kimi").
|
||||
WillReturnRows(sqlmock.NewRows([]string{"parent_id"}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: "ws-kimi"}}
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/ws-kimi/restart", nil)
|
||||
|
||||
handler.Restart(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got, _ := resp["status"].(string); got != "noop" {
|
||||
t.Errorf("expected status=noop, got %v", resp["status"])
|
||||
}
|
||||
if got, _ := resp["runtime"].(string); got != "kimi-cli" {
|
||||
t.Errorf("expected runtime=kimi-cli, got %v", resp["runtime"])
|
||||
}
|
||||
if msg, _ := resp["message"].(string); !strings.Contains(msg, "operator-driven") {
|
||||
t.Errorf("expected message about operator-driven, got %v", resp["message"])
|
||||
}
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartHandler_NilProvisionerReturns503(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
|
||||
@@ -559,48 +559,6 @@ func TestWorkspaceCreate_ExternalURL_SSRFSafe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_KimiRuntime_PreservesLabel asserts that a workspace
|
||||
// created with runtime="kimi" takes the BYO-compute path (awaiting_agent,
|
||||
// no Docker provisioning) and preserves the "kimi" label in the DB instead
|
||||
// of coercing to "external". Regression guard for SOP runtime addition.
|
||||
func TestWorkspaceCreate_KimiRuntime_PreservesLabel(t *testing.T) {
|
||||
t.Setenv("MOLECULE_DEPLOY_MODE", "self-hosted")
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec("INSERT INTO workspaces").
|
||||
WithArgs(sqlmock.AnyArg(), "Kimi Agent", nil, 3, "kimi", sqlmock.AnyArg(), (*string)(nil), nil, "none", (*int64)(nil), models.DefaultMaxConcurrentTasks, "push").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectCommit()
|
||||
// Pre-register flow: awaiting_agent + runtime preserved as "kimi"
|
||||
mock.ExpectExec("UPDATE workspaces SET status").
|
||||
WithArgs(models.StatusAwaitingAgent, "kimi", sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// Token issuance (workspace_auth_tokens, not workspace_tokens)
|
||||
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
|
||||
body := `{"name":"Kimi Agent","runtime":"kimi","tier":3,"canvas":{"x":100,"y":100}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
handler.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkspaceCreate_ExternalURL_SSRFMetadataBlocked asserts that an external
|
||||
// workspace created with a cloud-metadata URL is rejected with 400 before any
|
||||
// DB write. 169.254.0.0/16 is always blocked regardless of mode (SaaS or
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -314,10 +313,8 @@ func TestStore_PatchNamespace_DualFields(t *testing.T) {
|
||||
db, mock := setupMockDB(t)
|
||||
store := NewStore(db)
|
||||
exp := time.Now().Add(time.Hour).UTC()
|
||||
// QueryMatcherRegexp (default): expectQuery is a regex. We verify the
|
||||
// query uses $2 and $3 for the dual-field case by checking the full
|
||||
// query pattern. regexp.QuoteMeta handles the $ escaping correctly.
|
||||
mock.ExpectQuery(regexp.QuoteMeta("UPDATE memory_namespaces SET expires_at = $2, metadata = $3 WHERE name = $1")).
|
||||
// sqlmock matches by query string; we verify the query uses $2 and $3.
|
||||
mock.ExpectQuery("UPDATE memory_namespaces SET expires_at = \\$2, metadata = \\$3 WHERE name = \\$1").
|
||||
WithArgs("workspace:abc", sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name", "kind", "expires_at", "metadata", "created_at"}).
|
||||
AddRow("workspace:abc", "workspace", exp, []byte(`{}`), time.Now()))
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package models
|
||||
|
||||
import "testing"
|
||||
|
||||
// ==================== IsValidDeliveryMode ====================
|
||||
|
||||
func TestIsValidDeliveryMode_Valid(t *testing.T) {
|
||||
for _, mode := range []string{DeliveryModePush, DeliveryModePoll} {
|
||||
if !IsValidDeliveryMode(mode) {
|
||||
t.Errorf("IsValidDeliveryMode(%q) = false, want true", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidDeliveryMode_Invalid(t *testing.T) {
|
||||
cases := []struct {
|
||||
val string
|
||||
want bool
|
||||
}{
|
||||
{"", false}, // empty string is not valid — callers must resolve the default
|
||||
{"pushx", false}, // typo
|
||||
{"pollx", false}, // typo
|
||||
{"PUSH", false}, // case-sensitive
|
||||
{"PUSH ", false}, // trailing space
|
||||
{"push ", false}, // trailing space
|
||||
{"hybrid", false}, // non-existent mode
|
||||
{"poll ", false}, // trailing space
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsValidDeliveryMode(tc.val)
|
||||
if got != tc.want {
|
||||
t.Errorf("IsValidDeliveryMode(%q) = %v, want %v", tc.val, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== WorkspaceStatus ====================
|
||||
|
||||
func TestWorkspaceStatus_String(t *testing.T) {
|
||||
statuses := []WorkspaceStatus{
|
||||
StatusProvisioning,
|
||||
StatusOnline,
|
||||
StatusOffline,
|
||||
StatusDegraded,
|
||||
StatusFailed,
|
||||
StatusRemoved,
|
||||
StatusPaused,
|
||||
StatusHibernated,
|
||||
StatusHibernating,
|
||||
StatusAwaitingAgent,
|
||||
}
|
||||
for _, s := range statuses {
|
||||
if got := s.String(); got != string(s) {
|
||||
t.Errorf("WorkspaceStatus(%q).String() = %q, want %q", s, got, string(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllWorkspaceStatuses_Length(t *testing.T) {
|
||||
// The const block has 10 statuses; AllWorkspaceStatuses must match.
|
||||
if got := len(AllWorkspaceStatuses); got != 10 {
|
||||
t.Errorf("len(AllWorkspaceStatuses) = %d, want 10", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllWorkspaceStatuses_ContainsAllNamed(t *testing.T) {
|
||||
// Verify every named const appears in AllWorkspaceStatuses exactly once.
|
||||
named := []WorkspaceStatus{
|
||||
StatusProvisioning,
|
||||
StatusOnline,
|
||||
StatusOffline,
|
||||
StatusDegraded,
|
||||
StatusFailed,
|
||||
StatusRemoved,
|
||||
StatusPaused,
|
||||
StatusHibernated,
|
||||
StatusHibernating,
|
||||
StatusAwaitingAgent,
|
||||
}
|
||||
set := make(map[WorkspaceStatus]bool, len(AllWorkspaceStatuses))
|
||||
for _, s := range AllWorkspaceStatuses {
|
||||
set[s] = true
|
||||
}
|
||||
for _, s := range named {
|
||||
if !set[s] {
|
||||
t.Errorf("named status %q missing from AllWorkspaceStatuses", s)
|
||||
}
|
||||
}
|
||||
if len(set) != len(named) {
|
||||
t.Errorf("AllWorkspaceStatuses has %d unique entries, want %d", len(set), len(named))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllWorkspaceStatuses_NoEmpty(t *testing.T) {
|
||||
for _, s := range AllWorkspaceStatuses {
|
||||
if s == "" {
|
||||
t.Errorf("AllWorkspaceStatuses contains empty string")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,6 @@ _A2A_QUEUED_PREFIX = "[A2A_QUEUED] "
|
||||
_A2A_RESULT_FROM_PEER = "[A2A_RESULT_FROM_PEER]"
|
||||
_A2A_RESULT_TO_PEER = "[A2A_RESULT_TO_PEER]"
|
||||
|
||||
# Convenience aliases used by tests to reference canonical trust-boundary markers.
|
||||
_A2A_BOUNDARY_START = _A2A_RESULT_FROM_PEER # "[A2A_RESULT_FROM_PEER]"
|
||||
_A2A_BOUNDARY_END = "[/A2A_RESULT_FROM_PEER]"
|
||||
|
||||
# Regex patterns for the lookahead. Each is a raw string where \[ = escaped
|
||||
# '[' and \] = escaped ']'. The full pattern (separator + '[' + rest) is
|
||||
# matched in two pieces:
|
||||
|
||||
+14
-6
@@ -187,19 +187,27 @@ def enrich_peer_metadata_nonblocking(
|
||||
canon = _validate_peer_id(peer_id)
|
||||
if canon is None:
|
||||
return None
|
||||
# Cache hit (fresh): return without blocking on a registry GET.
|
||||
# This is the hot path for active peer conversations — avoids
|
||||
# spawning a background thread for every push from a known peer.
|
||||
|
||||
# Cache-first: return immediately on warm hit (same TTL logic as the
|
||||
# sync path). This is the hot-path optimisation — every push from a
|
||||
# warm peer must return the record without touching the in-flight set
|
||||
# or the executor. A background fetch that races to fill the cache
|
||||
# will find the entry already present when it calls
|
||||
# enrich_peer_metadata (which does its own fresh-TTL check), so it
|
||||
# exits as a no-op with no extra network traffic.
|
||||
current = time.monotonic()
|
||||
cached = _peer_metadata_get(canon)
|
||||
if cached is not None:
|
||||
fetched_at, record = cached
|
||||
if current - fetched_at < _PEER_METADATA_TTL_SECONDS:
|
||||
return record
|
||||
|
||||
# Cache miss or TTL expired: schedule background fetch unless one is
|
||||
# already in flight for this peer. The in-flight set keeps a flurry
|
||||
# of pushes from one peer (e.g., a chatty agent) from spawning N
|
||||
# parallel GETs.
|
||||
# already in flight for this peer. The synchronous version atomically
|
||||
# reads-then-writes; the async version splits that into "schedule
|
||||
# fetch" + "fetch fills cache later." The in-flight set keeps a
|
||||
# flurry of pushes from one peer (e.g., a chatty agent) from
|
||||
# spawning N parallel GETs.
|
||||
with _enrich_in_flight_lock:
|
||||
if canon in _enrich_in_flight:
|
||||
return None
|
||||
|
||||
+64
-124
@@ -163,67 +163,15 @@ async def handle_tool_call(name: str, arguments: dict) -> str:
|
||||
|
||||
# --- MCP Notification bridge ---
|
||||
|
||||
# Runtime-adaptive notification method. Each MCP host uses a different
|
||||
# JSON-RPC notification method for inbound push. Detect at startup so
|
||||
# the inbox poller emits the right shape for the host that spawned us.
|
||||
#
|
||||
# Detection order (first match wins):
|
||||
# CLAUDE_CODE / CLAUDE_CODE_VERSION → notifications/claude/channel
|
||||
# OPENCLAW_SESSION_ID / OPENCLAW_GATEWAY_PORT → notifications/openclaw/channel
|
||||
# CURSOR_MCP / CURSOR_TRACE_ID → notifications/cursor/channel
|
||||
# HERMES_RUNTIME / HERMES_WORKSPACE_ID → notifications/hermes/channel
|
||||
# fallback → notifications/message
|
||||
#
|
||||
# The method is resolved once at startup and cached in
|
||||
# _CHANNEL_NOTIFICATION_METHOD. Tests can override by patching
|
||||
# _detect_runtime() or setting the env var before import.
|
||||
_DETECTED_RUNTIME: str | None = None
|
||||
|
||||
|
||||
def _detect_runtime() -> str:
|
||||
"""Detect which MCP host spawned this process."""
|
||||
global _DETECTED_RUNTIME
|
||||
if _DETECTED_RUNTIME is not None:
|
||||
return _DETECTED_RUNTIME
|
||||
|
||||
env = os.environ
|
||||
if env.get("CLAUDE_CODE") or env.get("CLAUDE_CODE_VERSION"):
|
||||
_DETECTED_RUNTIME = "claude"
|
||||
elif env.get("OPENCLAW_SESSION_ID") or env.get("OPENCLAW_GATEWAY_PORT"):
|
||||
_DETECTED_RUNTIME = "openclaw"
|
||||
elif env.get("CURSOR_MCP") or env.get("CURSOR_TRACE_ID"):
|
||||
_DETECTED_RUNTIME = "cursor"
|
||||
elif env.get("HERMES_RUNTIME") or env.get("HERMES_WORKSPACE_ID"):
|
||||
_DETECTED_RUNTIME = "hermes"
|
||||
else:
|
||||
_DETECTED_RUNTIME = "generic"
|
||||
|
||||
logger.debug(f"Detected MCP runtime: {_DETECTED_RUNTIME}")
|
||||
return _DETECTED_RUNTIME
|
||||
|
||||
|
||||
def _notification_method_for_runtime(runtime: str) -> str:
|
||||
"""Return the JSON-RPC notification method for the given runtime."""
|
||||
return {
|
||||
"claude": "notifications/claude/channel",
|
||||
"openclaw": "notifications/openclaw/channel",
|
||||
"cursor": "notifications/cursor/channel",
|
||||
"hermes": "notifications/hermes/channel",
|
||||
"generic": "notifications/message",
|
||||
}.get(runtime, "notifications/message")
|
||||
|
||||
|
||||
# Lazily resolved so tests can patch _detect_runtime() before the first
|
||||
# notification is built. The value is read once per process lifetime.
|
||||
_CHANNEL_NOTIFICATION_METHOD: str | None = None
|
||||
|
||||
|
||||
def _channel_notification_method() -> str:
|
||||
"""Return the cached notification method for the detected runtime."""
|
||||
global _CHANNEL_NOTIFICATION_METHOD
|
||||
if _CHANNEL_NOTIFICATION_METHOD is None:
|
||||
_CHANNEL_NOTIFICATION_METHOD = _notification_method_for_runtime(_detect_runtime())
|
||||
return _CHANNEL_NOTIFICATION_METHOD
|
||||
# `notifications/claude/channel` matches the contract used by the
|
||||
# molecule-mcp-claude-channel bun bridge (server.ts:509). Claude Code's
|
||||
# MCP runtime treats this method as a conversation interrupt — `content`
|
||||
# becomes the agent turn, `meta` is structured metadata. Notification-
|
||||
# capable hosts (Claude Code today; any compliant client tomorrow)
|
||||
# get push UX automatically; pollers (`wait_for_message` / `inbox_peek`)
|
||||
# still work unchanged. See task #46 + the deprecation path documented
|
||||
# in workspace/inbox.py:set_notification_callback.
|
||||
_CHANNEL_NOTIFICATION_METHOD = "notifications/claude/channel"
|
||||
|
||||
|
||||
# ============= Trust-boundary gates for channel-notification meta ==============
|
||||
@@ -621,7 +569,7 @@ def _build_channel_notification(msg: dict) -> dict:
|
||||
)
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
"method": _channel_notification_method(),
|
||||
"method": _CHANNEL_NOTIFICATION_METHOD,
|
||||
"params": {
|
||||
"content": content,
|
||||
"meta": meta,
|
||||
@@ -684,69 +632,66 @@ def _format_channel_content(
|
||||
# --- MCP Server (JSON-RPC over stdio) ---
|
||||
|
||||
|
||||
def _warn_if_stdio_not_pipe(stdin_fd: int = 0, stdout_fd: int = 1) -> None:
|
||||
"""Warn when stdio isn't a pipe — but continue anyway.
|
||||
def _assert_stdio_is_pipe_compatible(
|
||||
stdin_fd: int = 0, stdout_fd: int = 1
|
||||
) -> None:
|
||||
"""Fail fast with a friendly message when stdio isn't pipe-compatible.
|
||||
|
||||
The legacy asyncio.connect_read_pipe / connect_write_pipe transport
|
||||
rejected regular files, PTYs, and sockets with:
|
||||
ValueError: Pipe transport is only for pipes, sockets and
|
||||
character devices
|
||||
We now use direct buffer I/O which works with ANY file descriptor,
|
||||
so this is a diagnostic-only warning for operators debugging setup
|
||||
issues. See molecule-ai-workspace-runtime#61.
|
||||
asyncio.connect_read_pipe / connect_write_pipe accept only pipes,
|
||||
sockets, and character devices. When molecule-mcp is launched with
|
||||
stdout redirected to a regular file (CI smoke tests, ad-hoc local
|
||||
debugging that captures output), the asyncio call later raises
|
||||
``ValueError: Pipe transport is only for pipes, sockets and character
|
||||
devices`` from inside the event loop — surfaced to the operator as a
|
||||
confusing traceback. Detect early and exit cleanly with guidance
|
||||
instead. See molecule-ai-workspace-runtime#61.
|
||||
"""
|
||||
for name, fd in (("stdin", stdin_fd), ("stdout", stdout_fd)):
|
||||
try:
|
||||
mode = os.fstat(fd).st_mode
|
||||
except OSError:
|
||||
continue
|
||||
if not (stat.S_ISFIFO(mode) or stat.S_ISSOCK(mode) or stat.S_ISCHR(mode)):
|
||||
logger.warning(
|
||||
f"molecule-mcp: {name} (fd={fd}) is not a pipe/socket/char-device. "
|
||||
f"This is fine — the universal stdio transport handles regular files, "
|
||||
f"PTYs, and sockets. If you see garbled output, launch from an "
|
||||
f"MCP-aware client (Claude Code, Cursor, OpenClaw, etc.)."
|
||||
except OSError as exc:
|
||||
print(
|
||||
f"molecule-mcp: cannot stat {name} (fd={fd}): {exc}.\n"
|
||||
f" This MCP server expects bidirectional pipe stdio. Launch it from\n"
|
||||
f" an MCP-aware client (Claude Code, Cursor, etc.) — not detached\n"
|
||||
f" from a terminal or with stdio closed.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
if not (
|
||||
stat.S_ISFIFO(mode) or stat.S_ISSOCK(mode) or stat.S_ISCHR(mode)
|
||||
):
|
||||
print(
|
||||
f"molecule-mcp: {name} (fd={fd}) is a regular file, not a pipe,\n"
|
||||
f" socket, or character device — asyncio's stdio transport rejects\n"
|
||||
f" it with `ValueError: Pipe transport is only for pipes, sockets\n"
|
||||
f" and character devices`. Common causes:\n"
|
||||
f" molecule-mcp > out.txt # stdout → regular file (fails)\n"
|
||||
f" molecule-mcp < input.json # stdin → regular file (fails)\n"
|
||||
f" Launch molecule-mcp from an MCP-aware client (Claude Code, Cursor,\n"
|
||||
f" hermes, OpenCode, etc.) so stdio is wired to a pipe pair, or use\n"
|
||||
f" `tee`/process substitution if you need to capture output:\n"
|
||||
f" molecule-mcp 2>&1 | tee out.txt # stdout stays a pipe",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
async def main(): # pragma: no cover
|
||||
"""Run MCP server on stdio — reads JSON-RPC requests, writes responses.
|
||||
"""Run MCP server on stdio — reads JSON-RPC requests, writes responses."""
|
||||
reader = asyncio.StreamReader()
|
||||
protocol = asyncio.StreamReaderProtocol(reader)
|
||||
await asyncio.get_event_loop().connect_read_pipe(lambda: protocol, sys.stdin)
|
||||
|
||||
Uses sys.stdin.buffer / sys.stdout.buffer directly instead of
|
||||
asyncio.connect_read_pipe / connect_write_pipe. The asyncio pipe
|
||||
transport rejects regular files, PTYs, and sockets with:
|
||||
ValueError: Pipe transport is only for pipes, sockets and
|
||||
character devices
|
||||
This breaks when the MCP host captures stdout (openclaw, CI tests,
|
||||
ad-hoc debugging with tee). Reading/writing the buffer directly
|
||||
works with ANY file descriptor.
|
||||
|
||||
See molecule-ai-workspace-runtime#61.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
# sys.stdin.buffer exists on text-mode streams (default); on binary
|
||||
# streams (tests, some CI setups) stdin IS the buffer.
|
||||
stdin = getattr(sys.stdin, "buffer", sys.stdin)
|
||||
stdout = getattr(sys.stdout, "buffer", sys.stdout)
|
||||
writer_transport, writer_protocol = await asyncio.get_event_loop().connect_write_pipe(
|
||||
asyncio.streams.FlowControlMixin, sys.stdout
|
||||
)
|
||||
writer = asyncio.StreamWriter(writer_transport, writer_protocol, None, asyncio.get_event_loop())
|
||||
|
||||
async def write_response(response: dict):
|
||||
data = json.dumps(response) + "\n"
|
||||
stdout.write(data.encode())
|
||||
stdout.flush()
|
||||
|
||||
# Build a StreamWriter-compatible wrapper for the inbox bridge.
|
||||
# The bridge expects a writer with .write() and .drain() methods.
|
||||
class _StdoutWriter:
|
||||
def __init__(self, buf):
|
||||
self._buf = buf
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
self._buf.write(data)
|
||||
|
||||
async def drain(self) -> None:
|
||||
self._buf.flush()
|
||||
|
||||
writer = _StdoutWriter(stdout)
|
||||
writer.write(data.encode())
|
||||
await writer.drain()
|
||||
|
||||
# Wire the inbox → MCP notification bridge. The bridge body lives
|
||||
# in `_setup_inbox_bridge` so the threading + asyncio + stdout
|
||||
@@ -756,27 +701,22 @@ async def main(): # pragma: no cover
|
||||
_setup_inbox_bridge(writer, asyncio.get_running_loop())
|
||||
)
|
||||
|
||||
# Log runtime detection for operator diagnostics
|
||||
runtime = _detect_runtime()
|
||||
logger.info(f"MCP stdio transport ready (runtime={runtime}, "
|
||||
f"notification_method={_channel_notification_method()})")
|
||||
|
||||
buffer = b""
|
||||
buffer = ""
|
||||
while True:
|
||||
try:
|
||||
chunk = await loop.run_in_executor(None, stdin.read, 65536)
|
||||
chunk = await reader.read(65536)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
buffer += chunk.decode(errors="replace")
|
||||
|
||||
while b"\n" in buffer:
|
||||
line, buffer = buffer.split(b"\n", 1)
|
||||
while "\n" in buffer:
|
||||
line, buffer = buffer.split("\n", 1)
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
try:
|
||||
request = json.loads(line.decode(errors="replace"))
|
||||
request = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
@@ -840,7 +780,7 @@ def cli_main() -> None: # pragma: no cover
|
||||
break every external-runtime operator's MCP install — the 0.1.16
|
||||
``main_sync`` rename incident is the cautionary precedent.
|
||||
"""
|
||||
_warn_if_stdio_not_pipe()
|
||||
_assert_stdio_is_pipe_compatible()
|
||||
asyncio.run(main())
|
||||
|
||||
|
||||
|
||||
@@ -165,10 +165,7 @@ async def test_agent_error_handling():
|
||||
|
||||
eq.enqueue_event.assert_called_once()
|
||||
error_msg = str(eq.enqueue_event.call_args[0][0])
|
||||
# sanitize_agent_error strips the raw exception message from the UI;
|
||||
# raw detail goes to workspace logs only. This is the secure behaviour.
|
||||
assert "Agent error (RuntimeError)" in error_msg
|
||||
assert "model crashed" not in error_msg
|
||||
assert "model crashed" in error_msg
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -1203,10 +1200,7 @@ async def test_terminal_error_routes_via_updater_failed():
|
||||
"terminal error Message must route via updater.failed() in task mode"
|
||||
)
|
||||
err_msg = eq._failed_calls[-1]
|
||||
# sanitize_agent_error strips the raw exception message from the UI;
|
||||
# raw detail goes to workspace logs only.
|
||||
assert "Agent error (RuntimeError)" in str(err_msg)
|
||||
assert "model crashed" not in str(err_msg)
|
||||
assert "model crashed" in str(err_msg)
|
||||
# And complete() must NOT have been called on the failure path.
|
||||
assert not eq._complete_calls, (
|
||||
"complete() should not fire when execute() raises"
|
||||
|
||||
@@ -252,30 +252,23 @@ def test_attachments_param_description_emphasizes_REQUIRED():
|
||||
|
||||
|
||||
def test_build_channel_notification_method_matches_claude_contract():
|
||||
"""Method MUST be `notifications/claude/channel` when runtime=claude —
|
||||
that's what Claude Code's MCP runtime listens for as a conversation
|
||||
"""Method MUST be `notifications/claude/channel` exactly — that's
|
||||
what Claude Code's MCP runtime listens for as a conversation
|
||||
interrupt. Same string as the bun channel bridge sends
|
||||
(server.ts:509) so this is a drop-in replacement."""
|
||||
from a2a_mcp_server import _build_channel_notification
|
||||
|
||||
with patch("a2a_mcp_server._detect_runtime", return_value="claude"):
|
||||
# Reset the cached method so _channel_notification_method() re-resolves
|
||||
import a2a_mcp_server as _mcp
|
||||
old_method = _mcp._CHANNEL_NOTIFICATION_METHOD
|
||||
_mcp._CHANNEL_NOTIFICATION_METHOD = None
|
||||
try:
|
||||
payload = _build_channel_notification({
|
||||
"activity_id": "act-1",
|
||||
"text": "hello",
|
||||
"peer_id": "",
|
||||
"kind": "canvas_user",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-01T00:00:00Z",
|
||||
})
|
||||
assert payload["method"] == "notifications/claude/channel"
|
||||
assert payload["jsonrpc"] == "2.0"
|
||||
finally:
|
||||
_mcp._CHANNEL_NOTIFICATION_METHOD = old_method
|
||||
payload = _build_channel_notification({
|
||||
"activity_id": "act-1",
|
||||
"text": "hello",
|
||||
"peer_id": "",
|
||||
"kind": "canvas_user",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-01T00:00:00Z",
|
||||
})
|
||||
|
||||
assert payload["method"] == "notifications/claude/channel"
|
||||
assert payload["jsonrpc"] == "2.0"
|
||||
|
||||
|
||||
def test_build_channel_notification_content_wraps_text_with_identity_and_reply_hint():
|
||||
@@ -1625,91 +1618,80 @@ async def test_inbox_bridge_emits_channel_notification_to_writer():
|
||||
import os
|
||||
import threading
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from a2a_mcp_server import _setup_inbox_bridge
|
||||
|
||||
# Force claude runtime so the notification method is predictable
|
||||
with patch("a2a_mcp_server._detect_runtime", return_value="claude"):
|
||||
import a2a_mcp_server as _mcp
|
||||
old_method = _mcp._CHANNEL_NOTIFICATION_METHOD
|
||||
_mcp._CHANNEL_NOTIFICATION_METHOD = None
|
||||
_mcp._channel_notification_method() # prime cache
|
||||
# Real asyncio writer backed by an os.pipe — same shape as
|
||||
# main() but isolated so we can read what was written.
|
||||
read_fd, write_fd = os.pipe()
|
||||
loop = asyncio.get_running_loop()
|
||||
transport, protocol = await loop.connect_write_pipe(
|
||||
asyncio.streams.FlowControlMixin,
|
||||
os.fdopen(write_fd, "wb"),
|
||||
)
|
||||
writer = asyncio.StreamWriter(transport, protocol, None, loop)
|
||||
|
||||
try:
|
||||
cb = _setup_inbox_bridge(writer, loop)
|
||||
|
||||
msg = {
|
||||
# Production-shape UUID per the trust-boundary gate (#2488)
|
||||
"activity_id": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
|
||||
"text": "hello from peer",
|
||||
"peer_id": "11111111-2222-3333-4444-555555555555",
|
||||
"kind": "peer_agent",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-01T22:00:00Z",
|
||||
}
|
||||
|
||||
# Simulate the inbox poller daemon thread invoking the
|
||||
# callback from a non-asyncio context — exactly the
|
||||
# threading boundary the bridge has to cross.
|
||||
threading.Thread(target=cb, args=(msg,), daemon=True).start()
|
||||
|
||||
# Give the scheduled coroutine a chance to run + drain
|
||||
# without coupling the test to wall-clock timing.
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.05)
|
||||
data = os.read(read_fd, 65536) if _readable(read_fd) else b""
|
||||
if data:
|
||||
break
|
||||
else:
|
||||
data = b""
|
||||
|
||||
assert data, (
|
||||
"no notification on stdout pipe — the bridge fired "
|
||||
"but the write didn't reach the writer (writer.drain "
|
||||
"swallowing or scheduling race)"
|
||||
)
|
||||
line = data.decode().strip()
|
||||
payload = json.loads(line)
|
||||
|
||||
assert payload["jsonrpc"] == "2.0"
|
||||
assert payload["method"] == "notifications/claude/channel"
|
||||
# Content is wrapped with the identity header + reply hint —
|
||||
# see _format_channel_content. The bridge test pins the full
|
||||
# composition so a regression to "raw text only" surfaces here
|
||||
# as well as in the per-formatter tests above.
|
||||
assert payload["params"]["content"] == (
|
||||
"[from peer-agent · peer_id=11111111-2222-3333-4444-555555555555]\n"
|
||||
"hello from peer\n"
|
||||
'↩ Reply: delegate_task({workspace_id: '
|
||||
'"11111111-2222-3333-4444-555555555555", task: "..."})'
|
||||
)
|
||||
meta = payload["params"]["meta"]
|
||||
assert meta["source"] == "molecule"
|
||||
assert meta["kind"] == "peer_agent"
|
||||
assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555"
|
||||
assert meta["activity_id"] == "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"
|
||||
assert meta["ts"] == "2026-05-01T22:00:00Z"
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
# Real asyncio writer backed by an os.pipe — same shape as
|
||||
# main() but isolated so we can read what was written.
|
||||
read_fd, write_fd = os.pipe()
|
||||
loop = asyncio.get_running_loop()
|
||||
transport, protocol = await loop.connect_write_pipe(
|
||||
asyncio.streams.FlowControlMixin,
|
||||
os.fdopen(write_fd, "wb"),
|
||||
)
|
||||
writer = asyncio.StreamWriter(transport, protocol, None, loop)
|
||||
|
||||
try:
|
||||
cb = _setup_inbox_bridge(writer, loop)
|
||||
|
||||
msg = {
|
||||
# Production-shape UUID per the trust-boundary gate (#2488)
|
||||
"activity_id": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
|
||||
"text": "hello from peer",
|
||||
"peer_id": "11111111-2222-3333-4444-555555555555",
|
||||
"kind": "peer_agent",
|
||||
"method": "message/send",
|
||||
"created_at": "2026-05-01T22:00:00Z",
|
||||
}
|
||||
|
||||
# Simulate the inbox poller daemon thread invoking the
|
||||
# callback from a non-asyncio context — exactly the
|
||||
# threading boundary the bridge has to cross.
|
||||
threading.Thread(target=cb, args=(msg,), daemon=True).start()
|
||||
|
||||
# Give the scheduled coroutine a chance to run + drain
|
||||
# without coupling the test to wall-clock timing.
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.05)
|
||||
data = os.read(read_fd, 65536) if _readable(read_fd) else b""
|
||||
if data:
|
||||
break
|
||||
else:
|
||||
data = b""
|
||||
|
||||
assert data, (
|
||||
"no notification on stdout pipe — the bridge fired "
|
||||
"but the write didn't reach the writer (writer.drain "
|
||||
"swallowing or scheduling race)"
|
||||
)
|
||||
line = data.decode().strip()
|
||||
payload = json.loads(line)
|
||||
|
||||
assert payload["jsonrpc"] == "2.0"
|
||||
assert payload["method"] == "notifications/claude/channel"
|
||||
# Content is wrapped with the identity header + reply hint —
|
||||
# see _format_channel_content. The bridge test pins the full
|
||||
# composition so a regression to "raw text only" surfaces here
|
||||
# as well as in the per-formatter tests above.
|
||||
assert payload["params"]["content"] == (
|
||||
"[from peer-agent · peer_id=11111111-2222-3333-4444-555555555555]\n"
|
||||
"hello from peer\n"
|
||||
'↩ Reply: delegate_task({workspace_id: '
|
||||
'"11111111-2222-3333-4444-555555555555", task: "..."})'
|
||||
)
|
||||
meta = payload["params"]["meta"]
|
||||
assert meta["source"] == "molecule"
|
||||
assert meta["kind"] == "peer_agent"
|
||||
assert meta["peer_id"] == "11111111-2222-3333-4444-555555555555"
|
||||
assert meta["activity_id"] == "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff"
|
||||
assert meta["ts"] == "2026-05-01T22:00:00Z"
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
os.close(read_fd)
|
||||
except OSError:
|
||||
# read_fd may already be closed if writer.close() tore down the pair
|
||||
# during teardown — best-effort cleanup, no signal worth surfacing.
|
||||
pass
|
||||
finally:
|
||||
_mcp._CHANNEL_NOTIFICATION_METHOD = old_method
|
||||
os.close(read_fd)
|
||||
except OSError:
|
||||
# read_fd may already be closed if writer.close() tore down the pair
|
||||
# during teardown — best-effort cleanup, no signal worth surfacing.
|
||||
pass
|
||||
|
||||
|
||||
async def test_inbox_bridge_swallows_closed_pipe_drain_error(monkeypatch):
|
||||
@@ -1826,75 +1808,99 @@ def test_inbox_bridge_swallows_closed_loop_runtime_error():
|
||||
|
||||
|
||||
class TestStdioPipeAssertion:
|
||||
"""Pin _warn_if_stdio_not_pipe — the diagnostic warning that replaces
|
||||
the old fatal _assert_stdio_is_pipe_compatible guard.
|
||||
|
||||
The universal stdio transport now works with ANY file descriptor
|
||||
(pipes, regular files, PTYs, sockets), so the old exit-2 behavior
|
||||
is gone. These tests verify the warning is emitted for non-pipe
|
||||
stdio so operators still get diagnostic signal when debugging.
|
||||
"""Pin _assert_stdio_is_pipe_compatible — the friendly fail-fast guard
|
||||
that turns asyncio's `ValueError: Pipe transport is only for pipes,
|
||||
sockets and character devices` into a clear operator message + exit 2.
|
||||
See molecule-ai-workspace-runtime#61.
|
||||
"""
|
||||
|
||||
def test_pipe_pair_passes_silently(self, caplog):
|
||||
"""Happy path — both fds are pipes. No warning emitted."""
|
||||
from a2a_mcp_server import _warn_if_stdio_not_pipe
|
||||
def test_pipe_pair_passes_silently(self):
|
||||
"""Happy path — both fds are pipes (the production launch shape
|
||||
from any MCP client). Should return None without printing or
|
||||
exiting."""
|
||||
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
|
||||
|
||||
r, w = os.pipe()
|
||||
try:
|
||||
with caplog.at_level("WARNING"):
|
||||
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=w)
|
||||
assert "not a pipe" not in caplog.text
|
||||
# No exit, no stderr noise. We don't capture stderr here
|
||||
# because pipe path should produce zero output.
|
||||
_assert_stdio_is_pipe_compatible(stdin_fd=r, stdout_fd=w)
|
||||
finally:
|
||||
os.close(r)
|
||||
os.close(w)
|
||||
|
||||
def test_regular_file_stdout_warns(self, tmp_path, caplog):
|
||||
def test_regular_file_stdout_exits_with_friendly_message(
|
||||
self, tmp_path, capsys
|
||||
):
|
||||
"""Reproducer for runtime#61: stdout redirected to a regular file.
|
||||
Now emits a warning instead of exiting."""
|
||||
from a2a_mcp_server import _warn_if_stdio_not_pipe
|
||||
Pre-fix this would surface upstream as
|
||||
`ValueError: Pipe transport is only for pipes...`. Post-fix we
|
||||
exit with code 2 and a stderr message that names the symptom +
|
||||
fix."""
|
||||
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
|
||||
|
||||
# stdin = pipe (so we isolate the stdout failure path);
|
||||
# stdout = regular file (the bug condition).
|
||||
r, _w = os.pipe()
|
||||
regular = tmp_path / "captured.log"
|
||||
f = open(regular, "wb")
|
||||
try:
|
||||
with caplog.at_level("WARNING"):
|
||||
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=f.fileno())
|
||||
assert "stdout" in caplog.text
|
||||
assert "not a pipe" in caplog.text
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_assert_stdio_is_pipe_compatible(
|
||||
stdin_fd=r, stdout_fd=f.fileno()
|
||||
)
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
# Names the failing stream + the asyncio constraint that
|
||||
# would otherwise crash. Don't pin the exact wording — the
|
||||
# asserts pin the operator-recoverable signal only.
|
||||
assert "stdout" in err
|
||||
assert "regular file" in err
|
||||
assert "pipe" in err
|
||||
finally:
|
||||
f.close()
|
||||
os.close(r)
|
||||
|
||||
def test_regular_file_stdin_warns(self, tmp_path, caplog):
|
||||
"""Symmetric case — stdin redirected from a regular file."""
|
||||
from a2a_mcp_server import _warn_if_stdio_not_pipe
|
||||
def test_regular_file_stdin_exits_with_friendly_message(
|
||||
self, tmp_path, capsys
|
||||
):
|
||||
"""Symmetric case — stdin redirected from a regular file. Same
|
||||
asyncio constraint applies via connect_read_pipe."""
|
||||
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
|
||||
|
||||
regular = tmp_path / "input.json"
|
||||
regular.write_bytes(b'{"jsonrpc":"2.0","id":1,"method":"initialize"}\n')
|
||||
f = open(regular, "rb")
|
||||
_r, w = os.pipe()
|
||||
try:
|
||||
with caplog.at_level("WARNING"):
|
||||
_warn_if_stdio_not_pipe(stdin_fd=f.fileno(), stdout_fd=w)
|
||||
assert "stdin" in caplog.text
|
||||
assert "not a pipe" in caplog.text
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_assert_stdio_is_pipe_compatible(
|
||||
stdin_fd=f.fileno(), stdout_fd=w
|
||||
)
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "stdin" in err
|
||||
assert "regular file" in err
|
||||
finally:
|
||||
f.close()
|
||||
os.close(w)
|
||||
|
||||
def test_closed_fd_warns_about_stat_error(self, caplog):
|
||||
"""If stdio is closed, os.fstat raises OSError. Warning is
|
||||
skipped silently (can't stat the fd)."""
|
||||
from a2a_mcp_server import _warn_if_stdio_not_pipe
|
||||
def test_closed_fd_exits_with_stat_error(self, capsys):
|
||||
"""If stdio is closed (rare but seen in detached daemonized
|
||||
contexts), os.fstat raises OSError. We catch it and exit 2 with
|
||||
a guidance message instead of letting the traceback escape."""
|
||||
from a2a_mcp_server import _assert_stdio_is_pipe_compatible
|
||||
|
||||
r, w = os.pipe()
|
||||
os.close(w) # Now `w` is a stale fd — fstat will fail.
|
||||
try:
|
||||
with caplog.at_level("WARNING"):
|
||||
_warn_if_stdio_not_pipe(stdin_fd=r, stdout_fd=w)
|
||||
# No warning emitted because fstat failed before the check
|
||||
assert "not a pipe" not in caplog.text
|
||||
with pytest.raises(SystemExit) as excinfo:
|
||||
_assert_stdio_is_pipe_compatible(
|
||||
stdin_fd=r, stdout_fd=w
|
||||
)
|
||||
assert excinfo.value.code == 2
|
||||
err = capsys.readouterr().err
|
||||
assert "cannot stat stdout" in err
|
||||
finally:
|
||||
os.close(r)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user