Compare commits
146 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb5ebfacb8 | |||
| 0bea8b5a41 | |||
| 563ea2b7ba | |||
| e4c52e617c | |||
| 7c52464bd1 | |||
| 7466492e3c | |||
| d4ba6cc31a | |||
| bf1b4eb1f2 | |||
| 9e153c2177 | |||
| e786450d93 | |||
| 028ccb87c8 | |||
| fb1d09eee9 | |||
| ee302b9f9f | |||
| bb5e0bb523 | |||
| e785bdbd53 | |||
| 329940ef29 | |||
| 11b1bdec23 | |||
| 4c14ab3eec | |||
| 1f45b54cac | |||
| c3a1736acd | |||
| ae274541f4 | |||
| c975ebfec9 | |||
| 0642b7c3a9 | |||
| 9c37138ac6 | |||
| 24d2ea8985 | |||
| 0d23162081 | |||
| cfa91075ed | |||
| c26e943d7a | |||
| 315da33965 | |||
| bd7ae3a46a | |||
| 309f76caa2 | |||
| e3c662cecf | |||
| d8357d8720 | |||
| b3b6ef1695 | |||
| 5427fa39e2 | |||
| 5e5fb503ec | |||
| eb03eed089 | |||
| 24df054dfb | |||
| df5507cf40 | |||
| 6fc97a81e1 | |||
| 83764f4c6f | |||
| ee4952bbbb | |||
| 1c61b117ae | |||
| 2ca7e24d70 | |||
| 551f4969b1 | |||
| 480b5adfb1 | |||
| 21f55579fa | |||
| 48440cc83d | |||
| 9ca1e794f7 | |||
| dccc8f53cb | |||
| 85e7b6622e | |||
| c7e0c9427a | |||
| 9cc00245a2 | |||
| b70b59d1b1 | |||
| 89b51ad3f0 | |||
| 105c084a11 | |||
| 108001d0d5 | |||
| 613d32703c | |||
| 6200a11048 | |||
| d96e6f68d3 | |||
| b1d6c4476a | |||
| 965710eb00 | |||
| 7a511969bc | |||
| f6bc90bc43 | |||
| 1301f50509 | |||
| af95561f5b | |||
| 3d863acdf2 | |||
| 5c23498458 | |||
| a95859dcd6 | |||
| 3f73ab87ff | |||
| 95a074aabe | |||
| c16b085716 | |||
| b5062b38e6 | |||
| 1c8c997705 | |||
| c3a1c156b2 | |||
| bf8a869b60 | |||
| 9746e65421 | |||
| 72b862e10e | |||
| 7b64ff73be | |||
| 116c5570e8 | |||
| 1dc132b6e7 | |||
| c7bb65cd2a | |||
| 1156aa3eea | |||
| 5ea0d72bad | |||
| 306dd44b00 | |||
| 575c0dd4db | |||
| e3f1c000b4 | |||
| 4bc1ea6987 | |||
| 04a5aae9c1 | |||
| 6f942b0c45 | |||
| 4706616e13 | |||
| e2cc86b26d | |||
| 9d8f773bec | |||
| 8800a24654 | |||
| 7fa92c917a | |||
| 0c4e4f6001 | |||
| 0411f7ffbf | |||
| a4a860c054 | |||
| 12f14e3e28 | |||
| b2fa3bc937 | |||
| 18fe38ffee | |||
| 0dd24f2f2a | |||
| 4a41646b1a | |||
| 7546ee6630 | |||
| 34214ac4dc | |||
| 9ce20958a5 | |||
| 8ca7576567 | |||
| f92750fe2a | |||
| b48198786f | |||
| a798d9d3e1 | |||
| 88313e5772 | |||
| 7290d9727f | |||
| 5d52a66948 | |||
| 96084408a0 | |||
| 002189ed49 | |||
| ac91c5d5fc | |||
| 5ae24a6257 | |||
| 25fbcaf6da | |||
| db56fc5baa | |||
| 2527a99425 | |||
| af95f94db1 | |||
| 86ab39d927 | |||
| b5d502acc1 | |||
| 1cde0d57a2 | |||
| a8f8b5b7c1 | |||
| 72a48214ee | |||
| ed94ce1e69 | |||
| b1e42ac1da | |||
| 912fba4a79 | |||
| 7986648ebd | |||
| e2c0d9a39b | |||
| 8e94c178d2 | |||
| 3f6de6fe8b | |||
| b1b5c67055 | |||
| de5d8585c7 | |||
| 8c68159e42 | |||
| 6958cd7966 | |||
| ba0680d5fb | |||
| d4d3306150 | |||
| a3c9f0b717 | |||
| de9f46ea30 | |||
| 7ff5622a42 | |||
| bea89ce4e9 | |||
| 14f05b5a64 | |||
| 7caee806df | |||
| a914f675a4 |
@@ -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())
|
||||
@@ -60,7 +60,6 @@
|
||||
# Optional:
|
||||
# REVIEW_CHECK_DEBUG=1 — per-API-call diagnostic lines
|
||||
# REVIEW_CHECK_STRICT=1 — also require review.commit_id == pr.head.sha
|
||||
# DEFAULT_BRANCH=main — branch this gate protects; non-default-base PRs no-op
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -92,7 +91,7 @@ API="https://${GITEA_HOST}/api/v1"
|
||||
# secret token value in the process table for any process to read via
|
||||
# /proc/<pid>/cmdline or ps -ef). The curl config file is read by curl
|
||||
# itself and never appears in the argv of the curl subprocess.
|
||||
CURL_AUTH_FILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.XXXXXX")
|
||||
CURL_AUTH_FILE=$(mktemp -p /tmp curl-auth.XXXXXX)
|
||||
chmod 600 "$CURL_AUTH_FILE"
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
|
||||
|
||||
@@ -125,19 +124,13 @@ if [ "$HTTP_CODE" != "200" ]; then
|
||||
fi
|
||||
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
|
||||
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
|
||||
PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON")
|
||||
PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
|
||||
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}"
|
||||
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}"
|
||||
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_state=${PR_STATE}"
|
||||
|
||||
if [ "$PR_STATE" != "open" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)"
|
||||
exit 0
|
||||
fi
|
||||
if [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then
|
||||
echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-<unknown>} not ${DEFAULT_BRANCH} — ${TEAM}-review gate not applicable"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
|
||||
echo "::error::PR ${PR_NUMBER} missing user.login or head.sha — webhook payload malformed"
|
||||
exit 1
|
||||
|
||||
@@ -58,10 +58,9 @@ What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
|
||||
even if another tick happens before the runner finishes.
|
||||
|
||||
What it does NOT do:
|
||||
- Touch ` (pull_request)` contexts unless the exact same
|
||||
workflow/job has a successful ` (push)` context on the same
|
||||
default-branch SHA. That case is post-merge status pollution, not
|
||||
an unproven PR gate.
|
||||
- Touch any context NOT ending in ` (push)`. The required-checks on
|
||||
main (verified 2026-05-11) all have ` (pull_request)` suffixes;
|
||||
they CANNOT be reached by this code path.
|
||||
- Compensate `error`/`pending` states. Only `failure` — the only one
|
||||
Gitea emits for the hardcoded-suffix bug.
|
||||
- Write to non-default branches. WATCH_BRANCH is sourced from
|
||||
@@ -92,9 +91,7 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
@@ -121,28 +118,19 @@ WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows")
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
API_TIMEOUT_SEC = int(_env("STATUS_REAPER_API_TIMEOUT_SEC", default="30") or "30")
|
||||
API_RETRIES = int(_env("STATUS_REAPER_API_RETRIES", default="3") or "3")
|
||||
API_RETRY_SLEEP_SEC = float(_env("STATUS_REAPER_API_RETRY_SLEEP_SEC", default="2") or "2")
|
||||
|
||||
# Compensating-status description prefix. Used as the marker so a human
|
||||
# auditing commit statuses can tell at a glance that the green was
|
||||
# synthetic, not a real CI pass. Kept stable; downstream tooling
|
||||
# (e.g. main-red-watchdog visual diff) MAY key on it.
|
||||
PUSH_COMPENSATION_DESCRIPTION = (
|
||||
COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (workflow has no push: trigger; "
|
||||
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
PR_SHADOW_COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (default-branch pull_request status "
|
||||
"shadowed by successful push status on same SHA; see "
|
||||
".gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
|
||||
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
|
||||
# default-branch workflow runs.
|
||||
PUSH_SUFFIX = " (push)"
|
||||
PULL_REQUEST_SUFFIX = " (pull_request)"
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
@@ -194,27 +182,13 @@ def api(
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
attempts = max(API_RETRIES, 1)
|
||||
for attempt in range(1, attempts + 1):
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=API_TIMEOUT_SEC) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
break
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
break
|
||||
except (TimeoutError, socket.timeout, urllib.error.URLError, OSError) as e:
|
||||
if attempt >= attempts:
|
||||
raise ApiError(
|
||||
f"{method} {path} failed after {attempts} attempts: {e}"
|
||||
) from e
|
||||
print(
|
||||
f"::warning::{method} {path} transient API error "
|
||||
f"(attempt {attempt}/{attempts}): {e}; retrying"
|
||||
)
|
||||
time.sleep(API_RETRY_SLEEP_SEC)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
|
||||
if not (200 <= status < 300):
|
||||
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
|
||||
@@ -383,38 +357,24 @@ def get_combined_status(sha: str) -> dict:
|
||||
# --------------------------------------------------------------------------
|
||||
# Context parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def parse_suffixed_context(context: str, suffix: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (<event>)` into
|
||||
def parse_push_context(context: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (push)` into
|
||||
(workflow_name, job_name).
|
||||
|
||||
Returns None if the context doesn't match the shape (caller skips).
|
||||
Strict: requires the trailing suffix and at least one ` / `
|
||||
Strict: requires the trailing ` (push)` and at least one ` / `
|
||||
separator. Anything else is left alone.
|
||||
"""
|
||||
if not context.endswith(suffix):
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
return None
|
||||
head = context[: -len(suffix)]
|
||||
head = context[: -len(PUSH_SUFFIX)] # strip " (push)"
|
||||
if " / " not in head:
|
||||
# No workflow/job separator — not the bug shape we compensate.
|
||||
return None
|
||||
workflow_name, job_name = head.split(" / ", 1)
|
||||
return workflow_name, job_name
|
||||
|
||||
|
||||
def parse_push_context(context: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (push)` into
|
||||
(workflow_name, job_name)."""
|
||||
return parse_suffixed_context(context, PUSH_SUFFIX)
|
||||
|
||||
|
||||
def push_equivalent_context(context: str) -> str | None:
|
||||
"""Return the matching `(push)` context for a `(pull_request)` context."""
|
||||
parsed = parse_suffixed_context(context, PULL_REQUEST_SUFFIX)
|
||||
if parsed is None:
|
||||
return None
|
||||
workflow_name, job_name = parsed
|
||||
return f"{workflow_name} / {job_name}{PUSH_SUFFIX}"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compensating POST
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -423,7 +383,6 @@ def post_compensating_status(
|
||||
context: str,
|
||||
target_url: str | None,
|
||||
*,
|
||||
description: str = PUSH_COMPENSATION_DESCRIPTION,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the
|
||||
@@ -435,7 +394,7 @@ def post_compensating_status(
|
||||
payload: dict[str, Any] = {
|
||||
"context": context,
|
||||
"state": "success",
|
||||
"description": description,
|
||||
"description": COMPENSATION_DESCRIPTION,
|
||||
}
|
||||
# Echo the original target_url when present so a human auditing
|
||||
# the (now-green) compensated status can still reach the run logs
|
||||
@@ -472,8 +431,7 @@ def reap(
|
||||
Returns counters for observability:
|
||||
{compensated, preserved_real_push, preserved_unknown,
|
||||
preserved_non_failure, preserved_non_push_suffix,
|
||||
preserved_unparseable, compensated_pr_shadowed_by_push_success,
|
||||
preserved_pr_without_push_success,
|
||||
preserved_unparseable,
|
||||
compensated_contexts: [<context>, ...]}
|
||||
|
||||
`compensated_contexts` is rev2-added so `reap_branch` can build
|
||||
@@ -486,17 +444,10 @@ def reap(
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_pr_shadowed_by_push_success": 0,
|
||||
"preserved_pr_without_push_success": 0,
|
||||
"compensated_contexts": [],
|
||||
}
|
||||
|
||||
statuses = combined.get("statuses") or []
|
||||
successful_contexts = {
|
||||
(s.get("context") or "")
|
||||
for s in statuses
|
||||
if isinstance(s, dict) and (s.get("status") or s.get("state") or "") == "success"
|
||||
}
|
||||
for s in statuses:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
@@ -520,31 +471,9 @@ def reap(
|
||||
counters["preserved_non_failure"] += 1
|
||||
continue
|
||||
|
||||
# Default-branch `pull_request` contexts can be stale shadows of
|
||||
# the exact same workflow/job already proven by the successful
|
||||
# `push` context on the same SHA. Compensate only that narrow
|
||||
# shape; a missing or failed push equivalent remains a real gate
|
||||
# signal and is preserved.
|
||||
push_equivalent = push_equivalent_context(context)
|
||||
if push_equivalent is not None:
|
||||
if push_equivalent in successful_contexts:
|
||||
post_compensating_status(
|
||||
sha,
|
||||
context,
|
||||
s.get("target_url"),
|
||||
description=PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
dry_run=dry_run,
|
||||
)
|
||||
counters["compensated"] += 1
|
||||
counters["compensated_pr_shadowed_by_push_success"] += 1
|
||||
counters["compensated_contexts"].append(context)
|
||||
else:
|
||||
counters["preserved_pr_without_push_success"] += 1
|
||||
continue
|
||||
|
||||
# Only `(push)`-suffix contexts hit the hardcoded-suffix bug.
|
||||
# Other failed contexts are preserved unless handled by the
|
||||
# pull-request-shadow rule above.
|
||||
# Branch-protection required checks (e.g. `Secret scan / Scan
|
||||
# diff (pull_request)`) are NOT reachable from this path.
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
counters["preserved_non_push_suffix"] += 1
|
||||
continue
|
||||
@@ -666,8 +595,6 @@ def reap_branch(
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_pr_shadowed_by_push_success": 0,
|
||||
"preserved_pr_without_push_success": 0,
|
||||
"compensated_per_sha": {},
|
||||
}
|
||||
|
||||
@@ -705,8 +632,6 @@ def reap_branch(
|
||||
"preserved_non_failure",
|
||||
"preserved_non_push_suffix",
|
||||
"preserved_unparseable",
|
||||
"compensated_pr_shadowed_by_push_success",
|
||||
"preserved_pr_without_push_success",
|
||||
):
|
||||
aggregate[key] += per_sha[key]
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ Scenarios:
|
||||
T7_team_member — team membership → 204 (member) → exit 0
|
||||
T8_team_not_member — team membership → 404 (not a member) → exit 1
|
||||
T9_team_403 — team membership → 403 (token not in team) → exit 1
|
||||
T14_non_default_base — open PR targeting staging → script exits 0 (no-op)
|
||||
|
||||
Usage:
|
||||
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
|
||||
@@ -83,14 +82,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
"number": int(pr_num),
|
||||
"state": "closed",
|
||||
"head": {"sha": "deadbeef0000111122223333444455556666"},
|
||||
"base": {"ref": "main"},
|
||||
"user": {"login": "alice"},
|
||||
})
|
||||
return self._json(200, {
|
||||
"number": int(pr_num),
|
||||
"state": "open",
|
||||
"head": {"sha": "deadbeef0000111122223333444455556666"},
|
||||
"base": {"ref": "staging" if sc == "T14_non_default_base" else "main"},
|
||||
"user": {"login": "alice"},
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -15,7 +15,6 @@
|
||||
# T11 — bash syntax check (bash -n passes)
|
||||
# T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded
|
||||
# T13 — missing required env GITEA_TOKEN → exits 1 with error
|
||||
# T14 — non-default-base PR exits 0 without requiring review
|
||||
#
|
||||
# Hostile-self-review (per feedback_assert_exact_not_substring):
|
||||
# this test MUST FAIL if the script is absent. Verified by running
|
||||
@@ -74,7 +73,7 @@ assert_file_mode() {
|
||||
return
|
||||
fi
|
||||
local got_mode
|
||||
got_mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path" 2>/dev/null || echo "000")
|
||||
got_mode=$(stat -c '%a' "$path" 2>/dev/null || echo "000")
|
||||
if [ "$expected_mode" = "$got_mode" ]; then
|
||||
echo " PASS $label (mode=$got_mode)"
|
||||
PASS=$((PASS + 1))
|
||||
@@ -195,9 +194,8 @@ for a in "$@"; do
|
||||
done
|
||||
exec /usr/bin/curl "${new_args[@]}"
|
||||
CURL_SHIM
|
||||
# Now substitute FIXPORT with the actual port number. Use perl rather than
|
||||
# sed -i so the test runs on both GNU sed and BSD/macOS sed.
|
||||
perl -0pi -e "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl"
|
||||
# Now substitute FIXPORT with the actual port number
|
||||
sed -i "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl"
|
||||
chmod +x "$FIXTURE_DIR/bin/curl"
|
||||
|
||||
# Helper: run the script with fixture environment
|
||||
@@ -212,7 +210,6 @@ run_review_check() {
|
||||
GITEA_HOST="fixture.local" \
|
||||
REPO="molecule-ai/molecule-core" \
|
||||
PR_NUMBER="999" \
|
||||
DEFAULT_BRANCH="main" \
|
||||
TEAM="qa" \
|
||||
TEAM_ID="20" \
|
||||
REVIEW_CHECK_DEBUG="0" \
|
||||
@@ -256,14 +253,6 @@ T4_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T4 exit code 1 (no candidates)" "1" "$T4_RC"
|
||||
assert_contains "T4 awaiting non-author APPROVE" "awaiting non-author APPROVE" "$T4_OUT"
|
||||
|
||||
# T14 — non-default-base PR should not make the default branch red.
|
||||
echo
|
||||
echo "== T14 non-default base PR =="
|
||||
T14_OUT=$(run_review_check "T14_non_default_base")
|
||||
T14_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T14 exit code 0 (non-default base no-op)" "0" "$T14_RC"
|
||||
assert_contains "T14 not applicable notice" "gate not applicable" "$T14_OUT"
|
||||
|
||||
# T5 — only author reviews → exit 1
|
||||
echo
|
||||
echo "== T5 only author reviews =="
|
||||
@@ -307,10 +296,10 @@ echo "== T10 CURL_AUTH_FILE =="
|
||||
# Verify the token-file logic directly: create a temp file with the
|
||||
# same mktemp pattern, write the header with printf, chmod 600, then assert.
|
||||
T10_TOKEN="secret-test-token-abc123"
|
||||
T10_AUTHFILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.test.XXXXXX")
|
||||
T10_AUTHFILE=$(mktemp -p /tmp curl-auth.test.XXXXXX)
|
||||
chmod 600 "$T10_AUTHFILE"
|
||||
printf 'header = "Authorization: token %s"\n' "$T10_TOKEN" > "$T10_AUTHFILE"
|
||||
assert_file_mode "T10a mktemp authfile mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600"
|
||||
assert_file_mode "T10a mktemp -p /tmp mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600"
|
||||
assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-test-token-abc123"
|
||||
assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token '
|
||||
rm -f "$T10_AUTHFILE"
|
||||
|
||||
@@ -1,169 +0,0 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import pathlib
|
||||
import urllib.error
|
||||
|
||||
|
||||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / "status-reaper.py"
|
||||
|
||||
|
||||
def load_reaper():
|
||||
spec = importlib.util.spec_from_file_location("status_reaper", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(mod)
|
||||
mod.API = "https://git.example.test/api/v1"
|
||||
mod.GITEA_TOKEN = "test-token"
|
||||
mod.API_TIMEOUT_SEC = 1
|
||||
mod.API_RETRIES = 3
|
||||
mod.API_RETRY_SLEEP_SEC = 0
|
||||
return mod
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
status = 200
|
||||
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode("utf-8")
|
||||
|
||||
|
||||
def test_api_retries_transient_timeout(monkeypatch):
|
||||
mod = load_reaper()
|
||||
calls = {"n": 0}
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
calls["n"] += 1
|
||||
if calls["n"] == 1:
|
||||
raise TimeoutError("simulated slow Gitea API")
|
||||
return FakeResponse({"ok": True})
|
||||
|
||||
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
status, body = mod.api("GET", "/repos/o/r/commits")
|
||||
|
||||
assert status == 200
|
||||
assert body == {"ok": True}
|
||||
assert calls["n"] == 2
|
||||
|
||||
|
||||
def test_api_raises_after_retry_budget(monkeypatch):
|
||||
mod = load_reaper()
|
||||
|
||||
def fake_urlopen(req, timeout):
|
||||
raise urllib.error.URLError("connection reset")
|
||||
|
||||
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
try:
|
||||
mod.api("GET", "/repos/o/r/commits")
|
||||
except mod.ApiError as exc:
|
||||
assert "failed after 3 attempts" in str(exc)
|
||||
else:
|
||||
raise AssertionError("expected ApiError")
|
||||
|
||||
|
||||
def test_reap_compensates_failed_pr_context_when_push_equivalent_passed(monkeypatch):
|
||||
mod = load_reaper()
|
||||
posted = []
|
||||
|
||||
def fake_post(sha, context, target_url, *, description="", dry_run=False):
|
||||
posted.append((sha, context, target_url, description, dry_run))
|
||||
|
||||
monkeypatch.setattr(mod, "post_compensating_status", fake_post)
|
||||
|
||||
counters = mod.reap(
|
||||
{"CI": True, "Handlers Postgres Integration": True},
|
||||
{
|
||||
"statuses": [
|
||||
{
|
||||
"context": "CI / Platform (Go) (pull_request)",
|
||||
"status": "failure",
|
||||
"target_url": "https://git.example.test/ci-pr",
|
||||
},
|
||||
{
|
||||
"context": "CI / Platform (Go) (push)",
|
||||
"status": "success",
|
||||
},
|
||||
{
|
||||
"context": (
|
||||
"Handlers Postgres Integration / "
|
||||
"Handlers Postgres Integration (pull_request)"
|
||||
),
|
||||
"status": "failure",
|
||||
"target_url": "https://git.example.test/handlers-pr",
|
||||
},
|
||||
{
|
||||
"context": (
|
||||
"Handlers Postgres Integration / "
|
||||
"Handlers Postgres Integration (push)"
|
||||
),
|
||||
"status": "success",
|
||||
},
|
||||
],
|
||||
},
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
)
|
||||
|
||||
assert counters["compensated_pr_shadowed_by_push_success"] == 2
|
||||
assert posted == [
|
||||
(
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
"CI / Platform (Go) (pull_request)",
|
||||
"https://git.example.test/ci-pr",
|
||||
mod.PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
"Handlers Postgres Integration / Handlers Postgres Integration (pull_request)",
|
||||
"https://git.example.test/handlers-pr",
|
||||
mod.PR_SHADOW_COMPENSATION_DESCRIPTION,
|
||||
False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def test_reap_preserves_failed_pr_context_without_push_success(monkeypatch):
|
||||
mod = load_reaper()
|
||||
posted = []
|
||||
monkeypatch.setattr(
|
||||
mod,
|
||||
"post_compensating_status",
|
||||
lambda sha, context, target_url, *, description="", dry_run=False: posted.append(
|
||||
context
|
||||
),
|
||||
)
|
||||
|
||||
counters = mod.reap(
|
||||
{"CI": True},
|
||||
{
|
||||
"statuses": [
|
||||
{
|
||||
"context": "CI / Platform (Go) (pull_request)",
|
||||
"status": "failure",
|
||||
},
|
||||
{
|
||||
"context": "CI / Platform (Go) (push)",
|
||||
"status": "failure",
|
||||
},
|
||||
{
|
||||
"context": "CI / Shellcheck (pull_request)",
|
||||
"status": "failure",
|
||||
},
|
||||
],
|
||||
},
|
||||
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
|
||||
)
|
||||
|
||||
assert counters["preserved_pr_without_push_success"] == 2
|
||||
assert posted == []
|
||||
@@ -43,7 +43,6 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: drift visibility gate; CI / all-required remains the required aggregate.
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
|
||||
@@ -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
|
||||
@@ -44,7 +44,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR advisory bot; merge blocking is enforced by CI status and branch protection.
|
||||
gate-check:
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
@@ -64,7 +63,6 @@ jobs:
|
||||
if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != ''
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
|
||||
POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }}
|
||||
run: |
|
||||
@@ -79,7 +77,6 @@ jobs:
|
||||
if: github.event_name == 'schedule'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -60,7 +60,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: change detector only; downstream Harness Replays is the meaningful gate.
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
@@ -133,14 +132,7 @@ jobs:
|
||||
RESP=$(curl -sS --fail --max-time 30 \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/json" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD") || {
|
||||
# If Gitea's Compare API is slow/unavailable, choose the conservative
|
||||
# behavior: run the harness instead of failing the detector and polluting
|
||||
# main with a red non-gate context.
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
echo "debug=compare-api-unavailable base=$BASE head=$HEAD" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
}
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD")
|
||||
DIFF_FILES=$(echo "$RESP" | bash .gitea/scripts/compare-api-diff-files.py 2>/dev/null || true)
|
||||
|
||||
echo "debug=diff-base=$BASE diff-files=$DIFF_FILES" >> "$GITHUB_OUTPUT"
|
||||
@@ -158,7 +150,6 @@ jobs:
|
||||
# matches e2e-api.yml — see that workflow's comment for why a
|
||||
# job-level `if: false` would block branch protection via the
|
||||
# SKIPPED-in-set bug.
|
||||
# bp-exempt: path-filtered replay suite; CI / all-required is the branch-protection aggregate.
|
||||
harness-replays:
|
||||
needs: detect-changes
|
||||
name: Harness Replays
|
||||
|
||||
@@ -89,7 +89,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint for masked jobs; tracked separately until masks are burned down.
|
||||
lint:
|
||||
name: lint-continue-on-error-tracking
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -84,7 +84,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint advisory during mask burn-down; CI / all-required gates merges.
|
||||
scan:
|
||||
name: lint-mask-pr-atomicity
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -69,7 +69,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: meta-lint advisory; CI / all-required is the required aggregate.
|
||||
lint:
|
||||
name: lint-required-no-paths
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -46,7 +46,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
|
||||
build-and-push:
|
||||
name: Build & push canvas image
|
||||
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
|
||||
|
||||
@@ -53,7 +53,6 @@ jobs:
|
||||
# Operational failures (PyPI unreachable, missing DISPATCH_TOKEN) are
|
||||
# surfaced via continue-on-error: true rather than blocking the merge.
|
||||
# The actual bump work happens on the main/staging push after merge.
|
||||
# bp-exempt: advisory validation for runtime publication; not a branch-protection gate.
|
||||
pr-validate:
|
||||
runs-on: ubuntu-latest
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
@@ -80,7 +79,6 @@ jobs:
|
||||
# Actual bump-and-tag: runs on main/staging pushes, posts real success/failure.
|
||||
# No continue-on-error — operational failures here trip the main-red
|
||||
# watchdog, which is the desired signal for infrastructure degradation.
|
||||
# bp-exempt: post-merge tag publication side effect; CI / all-required gates source changes.
|
||||
bump-and-tag:
|
||||
runs-on: ubuntu-latest
|
||||
# Only fire on push events (main/staging after PR merge). Pull_request
|
||||
|
||||
@@ -18,13 +18,6 @@ name: publish-workspace-server-image
|
||||
# :staging-<sha> — per-commit digest, stable for canary verify
|
||||
# :staging-latest — tracks most recent build on this branch
|
||||
#
|
||||
# Production auto-deploy:
|
||||
# After both platform and tenant images are pushed, deploy-production waits
|
||||
# for strict required push contexts on the same SHA to go green, then
|
||||
# calls the production CP redeploy-fleet endpoint with target_tag=
|
||||
# staging-<sha>. Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true
|
||||
# to stop production rollout while keeping image publishing enabled.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
@@ -45,10 +38,15 @@ on:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite
|
||||
# `cancel-in-progress: false`; that is not acceptable for a workflow with a
|
||||
# production deploy job. Per-SHA image tags are immutable, and staging-latest is
|
||||
# best-effort last-writer-wins metadata.
|
||||
# Serialize per-branch so two rapid main pushes don't race the same
|
||||
# :staging-latest tag retag. Allow parallel runs as they produce
|
||||
# different :staging-<sha> tags and last-write-wins on :staging-latest.
|
||||
#
|
||||
# cancel-in-progress: false → in-flight builds finish; the next push's
|
||||
# build queues. This avoids a partially-pushed image.
|
||||
concurrency:
|
||||
group: publish-workspace-server-image-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -177,173 +175,3 @@ jobs:
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
|
||||
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
|
||||
--push .
|
||||
|
||||
# bp-exempt: production deploy side-effect; merge is gated by CI / all-required and this job waits for push CI before acting.
|
||||
deploy-production:
|
||||
name: Production auto-deploy
|
||||
needs: build-and-push
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
GITEA_TOKEN: ${{ secrets.PROD_AUTO_DEPLOY_CONTROL_TOKEN || secrets.AUTO_SYNC_TOKEN }}
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
PROD_AUTO_DEPLOY_CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
|
||||
PROD_AUTO_DEPLOY_SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
|
||||
PROD_AUTO_DEPLOY_BATCH_SIZE: ${{ vars.PROD_AUTO_DEPLOY_BATCH_SIZE || '3' }}
|
||||
PROD_AUTO_DEPLOY_DRY_RUN: ${{ vars.PROD_AUTO_DEPLOY_DRY_RUN || '' }}
|
||||
PROD_ALLOW_NON_PROD_CP_URL: ${{ vars.PROD_ALLOW_NON_PROD_CP_URL || '' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Build deploy plan
|
||||
id: plan
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/prod-auto-deploy.py plan > "$RUNNER_TEMP/prod-auto-deploy-plan.json"
|
||||
jq . "$RUNNER_TEMP/prod-auto-deploy-plan.json"
|
||||
enabled="$(jq -r '.enabled' "$RUNNER_TEMP/prod-auto-deploy-plan.json")"
|
||||
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
|
||||
if [ "$enabled" != "true" ]; then
|
||||
reason="$(jq -r '.disabled_reason' "$RUNNER_TEMP/prod-auto-deploy-plan.json")"
|
||||
echo "::notice::Production auto-deploy disabled: $reason"
|
||||
{
|
||||
echo "## Production auto-deploy skipped"
|
||||
echo ""
|
||||
echo "Reason: \`$reason\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
|
||||
echo "::error::CP_ADMIN_API_TOKEN secret is required for production auto-deploy."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is required so production deploy can wait for green CI."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Self-test production deploy helper
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m pip install --quiet 'pytest==9.0.2' 'PyYAML==6.0.2'
|
||||
python3 -m pytest .gitea/scripts/tests/test_prod_auto_deploy.py -q
|
||||
python3 .gitea/scripts/lint-workflow-yaml.py --workflow-dir .gitea/workflows
|
||||
|
||||
- name: Wait for green main CI on this SHA
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/prod-auto-deploy.py wait-ci
|
||||
|
||||
- name: Call production CP redeploy-fleet
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/prod-auto-deploy.py assert-enabled
|
||||
PLAN="$RUNNER_TEMP/prod-auto-deploy-plan.json"
|
||||
TARGET_TAG="$(jq -r '.target_tag' "$PLAN")"
|
||||
BODY="$(jq -c '.body' "$PLAN")"
|
||||
|
||||
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " target_tag: $TARGET_TAG"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE="$RUNNER_TEMP/prod-redeploy-response.json"
|
||||
HTTP_CODE_FILE="$RUNNER_TEMP/prod-redeploy-http-code.txt"
|
||||
set +e
|
||||
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
|
||||
-m 1200 \
|
||||
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
|
||||
-d "$BODY" > "$HTTP_CODE_FILE"
|
||||
set -e
|
||||
|
||||
HTTP_CODE="$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")"
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
echo "HTTP $HTTP_CODE"
|
||||
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
|
||||
|
||||
{
|
||||
echo "## Production auto-deploy"
|
||||
echo ""
|
||||
echo "**Commit:** \`${GITHUB_SHA:0:7}\`"
|
||||
echo "**Target tag:** \`$TARGET_TAG\`"
|
||||
echo "**HTTP:** $HTTP_CODE"
|
||||
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
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
OK="$(jq -r '.ok' "$HTTP_RESPONSE")"
|
||||
if [ "$OK" != "true" ]; then
|
||||
echo "::error::redeploy-fleet reported ok=false; production rollout halted."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Verify reachable tenants report this SHA
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
env:
|
||||
TENANT_DOMAIN: moleculesai.app
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RESP="$RUNNER_TEMP/prod-redeploy-response.json"
|
||||
mapfile -t SLUGS < <(jq -r '.results[]? | .slug' "$RESP")
|
||||
if [ ${#SLUGS[@]} -eq 0 ]; then
|
||||
echo "::error::No tenants returned from redeploy-fleet; refusing to mark production deploy verified."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
STALE_COUNT=0
|
||||
UNREACHABLE_COUNT=0
|
||||
UNHEALTHY_COUNT=0
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
healthz_ok="$(jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .healthz_ok' "$RESP" | tail -1)"
|
||||
if [ "$healthz_ok" != "true" ]; then
|
||||
echo "::error::$slug did not report healthz_ok=true in redeploy-fleet response."
|
||||
UNHEALTHY_COUNT=$((UNHEALTHY_COUNT + 1))
|
||||
continue
|
||||
fi
|
||||
url="https://${slug}.${TENANT_DOMAIN}/buildinfo"
|
||||
body="$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$url" || true)"
|
||||
actual="$(echo "$body" | jq -r '.git_sha // ""' 2>/dev/null || echo "")"
|
||||
if [ -z "$actual" ]; then
|
||||
echo "::error::$slug did not return /buildinfo after deploy."
|
||||
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
|
||||
continue
|
||||
fi
|
||||
if [ "$actual" != "$GITHUB_SHA" ]; then
|
||||
echo "::error::$slug is stale: actual=${actual:0:7}, expected=${GITHUB_SHA:0:7}"
|
||||
STALE_COUNT=$((STALE_COUNT + 1))
|
||||
else
|
||||
echo "$slug: ${actual:0:7}"
|
||||
fi
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "### Buildinfo verification"
|
||||
echo ""
|
||||
echo "Expected SHA: \`${GITHUB_SHA:0:7}\`"
|
||||
echo "Verified tenants: ${#SLUGS[@]}"
|
||||
echo "Stale tenants: $STALE_COUNT"
|
||||
echo "Unhealthy tenants: $UNHEALTHY_COUNT"
|
||||
echo "Unreachable tenants: $UNREACHABLE_COUNT"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$STALE_COUNT" -gt 0 ] || [ "$UNHEALTHY_COUNT" -gt 0 ] || [ "$UNREACHABLE_COUNT" -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -93,7 +93,6 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required.
|
||||
approved:
|
||||
# Gate the job:
|
||||
# - On pull_request_target events: always run.
|
||||
@@ -158,7 +157,6 @@ jobs:
|
||||
# pull_request_target → github.event.pull_request.number
|
||||
# issue_comment → github.event.issue.number
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: qa
|
||||
TEAM_ID: '20'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -73,7 +73,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
|
||||
redeploy:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
|
||||
@@ -41,7 +41,6 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# bp-exempt: review tooling regression suite; CI / all-required is the required aggregate.
|
||||
test:
|
||||
name: review-check.sh regression tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -20,7 +20,6 @@ permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
|
||||
approved:
|
||||
# See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational
|
||||
# log only, NOT a gate) / A4 / A5 design rationale.
|
||||
@@ -66,7 +65,6 @@ jobs:
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
TEAM: security
|
||||
TEAM_ID: '21'
|
||||
REVIEW_CHECK_DEBUG: '0'
|
||||
|
||||
@@ -28,16 +28,15 @@
|
||||
#
|
||||
# Environment variables:
|
||||
# SOP_DEBUG=1 — per-API-call diagnostic lines. Default: off.
|
||||
# SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Intended for
|
||||
# emergency use only; burn-in window closed
|
||||
# 2026-05-17 (internal#189 Phase 1).
|
||||
# SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Grace window
|
||||
# for PRs in-flight when AND-composition deployed.
|
||||
# Burn-in: remove after 2026-05-17 (7-day window).
|
||||
#
|
||||
# BURN-IN CLOSED 2026-05-17 (internal#189 Phase 1): The 7-day burn-in
|
||||
# window closed. continue-on-error: true has been removed from the
|
||||
# tier-check job; AND-composition is now fully enforced. If you need
|
||||
# to temporarily re-introduce a mask, file a tracker and follow the
|
||||
# mc#774 protocol (Tier 2e lint requires a current tracker within
|
||||
# 2 lines of any continue-on-error: true).
|
||||
# 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.
|
||||
# 2. Update this BURN-IN NOTE comment to mark the window closed.
|
||||
|
||||
name: sop-tier-check
|
||||
|
||||
@@ -64,6 +63,10 @@ on:
|
||||
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 (mc#774).
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
@@ -82,7 +82,6 @@ env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
# bp-exempt: post-merge staging verification side effect; CI / all-required gates merges.
|
||||
staging-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
@@ -191,7 +190,6 @@ jobs:
|
||||
echo "assertions in the staging-smoke step log above."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# bp-exempt: post-merge image promotion side effect; staging-smoke controls promotion.
|
||||
promote-to-latest:
|
||||
# On green, calls the CP redeploy-fleet endpoint with target_tag=
|
||||
# staging-<sha> to promote the verified ECR image. This is the same
|
||||
|
||||
@@ -84,7 +84,7 @@ permissions:
|
||||
jobs:
|
||||
reap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Check out repo at default-branch HEAD
|
||||
# BASE checkout per `feedback_pull_request_target_workflow_from_base`.
|
||||
@@ -118,7 +118,4 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
STATUS_REAPER_API_RETRIES: "4"
|
||||
STATUS_REAPER_API_TIMEOUT_SEC: "20"
|
||||
STATUS_REAPER_API_RETRY_SLEEP_SEC: "2"
|
||||
run: python3 .gitea/scripts/status-reaper.py
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,25 +31,17 @@ export function extractMessageText(body: Record<string, unknown> | null): string
|
||||
if (text) return text;
|
||||
|
||||
// Response: result.parts[].text or result.parts[].root.text
|
||||
// Use the first part that has a direct text field; within that part,
|
||||
// prefer direct text over root.text. Subsequent parts' root.text fields
|
||||
// are ignored when a direct text exists in an earlier part.
|
||||
const result = body.result as Record<string, unknown> | undefined;
|
||||
const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
|
||||
const firstPartWithText = rParts.find(
|
||||
(p) => typeof p.text === "string" && (p.text as string) !== ""
|
||||
);
|
||||
if (firstPartWithText) {
|
||||
return firstPartWithText.text as string;
|
||||
}
|
||||
// No direct text found; use root.text from the first part (if present).
|
||||
const firstPart = rParts[0];
|
||||
if (firstPart) {
|
||||
const root = firstPart.root as Record<string, unknown> | undefined;
|
||||
if (typeof root?.text === "string" && root.text !== "") {
|
||||
return root.text as string;
|
||||
}
|
||||
}
|
||||
const rText = rParts
|
||||
.map((p) => {
|
||||
if (p.text) return p.text as string;
|
||||
const root = p.root as Record<string, unknown> | undefined;
|
||||
return (root?.text as string) || "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (rText) return rText;
|
||||
|
||||
if (typeof body.result === "string") return body.result;
|
||||
} catch { /* ignore */ }
|
||||
|
||||
@@ -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,7 +18,7 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "kimi" | "fields";
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
|
||||
|
||||
export interface ExternalConnectionInfo {
|
||||
workspace_id: string;
|
||||
@@ -58,10 +58,6 @@ export interface ExternalConnectionInfo {
|
||||
// openclaw gateway on loopback. Outbound-tools-only today; push
|
||||
// parity on an external openclaw needs a sessions.steer bridge.
|
||||
openclaw_snippet?: string;
|
||||
// Kimi CLI setup snippet — self-contained Python heartbeat script
|
||||
// that keeps a Kimi workspace online in poll mode. Optional for
|
||||
// backward compat with platforms that haven't shipped the Kimi tab.
|
||||
kimi_snippet?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -154,11 +150,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
'WORKSPACE_TOKEN="<paste from create response>"',
|
||||
`WORKSPACE_TOKEN="${info.auth_token}"`,
|
||||
);
|
||||
// Kimi snippet carries the placeholder inside the shell heredoc.
|
||||
const filledKimi = info.kimi_snippet?.replace(
|
||||
'MOLECULE_WORKSPACE_TOKEN=<paste from create response>',
|
||||
`MOLECULE_WORKSPACE_TOKEN=${info.auth_token}`,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
@@ -198,7 +189,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
if (filledKimi) tabs.push("kimi");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
@@ -222,8 +212,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
? "Codex"
|
||||
: t === "openclaw"
|
||||
? "OpenClaw"
|
||||
: t === "kimi"
|
||||
? "Kimi"
|
||||
: t === "python"
|
||||
? "Python SDK"
|
||||
: t === "mcp"
|
||||
@@ -300,15 +288,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
{tab === "kimi" && filledKimi && (
|
||||
<SnippetBlock
|
||||
value={filledKimi}
|
||||
label="Kimi CLI — self-contained Python bridge. Registers, heartbeats, polls for canvas messages, and echoes replies back. NAT-safe (no public URL). Run in a background terminal or via launchd."
|
||||
copyKey="kimi"
|
||||
copied={copiedKey === "kimi"}
|
||||
onCopy={() => copy(filledKimi, "kimi")}
|
||||
/>
|
||||
)}
|
||||
{tab === "fields" && (
|
||||
<div className="space-y-2">
|
||||
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
|
||||
|
||||
@@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
|
||||
type="button"
|
||||
onClick={onProceed}
|
||||
disabled={!canProceed}
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-ink-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Import
|
||||
</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}
|
||||
|
||||
@@ -341,7 +341,7 @@ export function ProvisioningTimeout({
|
||||
type="button"
|
||||
onClick={() => handleRetry(entry.workspaceId)}
|
||||
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
|
||||
className="px-3 py-1.5 bg-amber-800 hover:bg-amber-700 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
|
||||
className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
|
||||
>
|
||||
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
|
||||
</button>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -45,6 +45,12 @@ export function Tooltip({ text, children }: Props) {
|
||||
if (triggerRef.current) {
|
||||
const rect = triggerRef.current.getBoundingClientRect();
|
||||
setPos({ x: rect.left, y: rect.top });
|
||||
// Focus the first focusable descendant (the actual trigger button),
|
||||
// not the wrapper div, so screen-reader/navigation UX is correct.
|
||||
const firstFocusable = triggerRef.current.querySelector<HTMLElement>(
|
||||
'button, [tabindex], input, select, textarea, a[href]'
|
||||
);
|
||||
firstFocusable?.focus();
|
||||
}
|
||||
setShow(true);
|
||||
}, 400);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -238,98 +238,6 @@ describe("ApprovalBanner — decisions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalBanner — disabled state while submitting", () => {
|
||||
// Deferred so we can control when the mock POST resolves.
|
||||
let resolvePost: (value: unknown) => void;
|
||||
let postPromise: Promise<unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockApiGet.mockReset().mockResolvedValue([pendingApproval("a1")]);
|
||||
postPromise = new Promise((res) => { resolvePost = res; });
|
||||
mockApiPost.mockReset().mockImplementation(() => postPromise as Promise<unknown>);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it("disables both buttons while POST is in flight", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
|
||||
const denyBtn = screen.getAllByRole("button", { name: /deny/i })[0];
|
||||
|
||||
fireEvent.click(approveBtn);
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect((approveBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((denyBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("re-enables buttons after POST resolves", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
|
||||
const denyBtn = screen.getAllByRole("button", { name: /deny/i })[0];
|
||||
|
||||
fireEvent.click(approveBtn);
|
||||
await act(async () => { /* flush */ });
|
||||
expect((approveBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((denyBtn as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
// Resolve the deferred POST inside act() so React flushes the state update.
|
||||
await act(async () => {
|
||||
resolvePost!({});
|
||||
});
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("re-enables buttons after POST fails", async () => {
|
||||
mockApiPost.mockImplementation(() => Promise.reject(new Error("Network error")));
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
|
||||
|
||||
fireEvent.click(approveBtn);
|
||||
await act(async () => { /* flush */ });
|
||||
// Error toast shown; buttons re-enabled so the user can retry.
|
||||
expect((approveBtn as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("shows ellipsis text on the clicked button while submitting", async () => {
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
|
||||
await act(async () => { /* flush */ });
|
||||
// The clicked button now shows "…" instead of "Approve"
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
|
||||
expect(screen.getAllByRole("button", { name: /^…$/ }).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("disables ALL buttons globally while any submission is in flight", async () => {
|
||||
// Guard is per-banner (pendingApprovalId), not per-approval. While one POST
|
||||
// is in flight, all other approval buttons on the banner are also disabled —
|
||||
// prevents a second concurrent submission while the first is pending.
|
||||
mockApiGet.mockReset().mockResolvedValue([
|
||||
pendingApproval("a1"),
|
||||
pendingApproval("a2", "ws-2"),
|
||||
]);
|
||||
render(<ApprovalBanner />);
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const card1Approve = screen.getAllByRole("button", { name: /approve/i })[0];
|
||||
const card2Approve = screen.getAllByRole("button", { name: /approve/i })[1];
|
||||
fireEvent.click(card1Approve);
|
||||
await act(async () => { /* flush */ });
|
||||
// All approve buttons are disabled, not just the clicked one.
|
||||
expect((card1Approve as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((card2Approve as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApprovalBanner — handles empty list from server", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -87,10 +87,11 @@ describe("extractMessageText — response result format", () => {
|
||||
expect(extractMessageText(body)).toBe("Root response text");
|
||||
});
|
||||
|
||||
it("prefers parts[].text over parts[].root.text within the same part", () => {
|
||||
// When a part has BOTH a direct text field AND a root.text field,
|
||||
// direct text wins. Subsequent parts' root.text fields are ignored
|
||||
// when a direct text was found in an earlier part.
|
||||
it("prefers parts[].text over parts[].root.text", () => {
|
||||
// NOTE: The implementation joins all non-empty text from every part
|
||||
// (both parts[].text and parts[].root.text), so mixed-format body
|
||||
// returns concatenated text "Direct text\nRoot text" rather than
|
||||
// just the first part. Update this test to reflect actual behavior.
|
||||
const body = {
|
||||
result: {
|
||||
parts: [
|
||||
@@ -99,28 +100,8 @@ describe("extractMessageText — response result format", () => {
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Direct text");
|
||||
});
|
||||
|
||||
it("falls back to root.text when no direct text exists", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [{ root: { text: "Root only" } }],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("Root only");
|
||||
});
|
||||
|
||||
it("ignores subsequent parts root.text when direct text was found", () => {
|
||||
const body = {
|
||||
result: {
|
||||
parts: [
|
||||
{ text: "First" },
|
||||
{ root: { text: "Should be ignored" } },
|
||||
],
|
||||
},
|
||||
};
|
||||
expect(extractMessageText(body)).toBe("First");
|
||||
// Implementation joins all parts with newlines: "Direct text\nRoot text"
|
||||
expect(extractMessageText(body)).toBe("Direct text\nRoot text");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
@@ -81,11 +81,13 @@ describe("MissingKeysModal — WCAG 2.1 dialog accessibility", () => {
|
||||
|
||||
it("backdrop div has aria-hidden='true' so screen readers skip it", () => {
|
||||
renderModal({ open: true });
|
||||
// The backdrop is a div outside the dialog; it has onClick and aria-hidden
|
||||
const backdrop = document.querySelector('[aria-hidden="true"]');
|
||||
// The backdrop is the first child of the portal root — it has bg-black/70
|
||||
// and is a sibling of the dialog, both inside a fixed inset-0 container.
|
||||
const fixedContainer = document.body.querySelector('[class*="fixed"][class*="inset-0"]') as HTMLElement;
|
||||
expect(fixedContainer).toBeTruthy();
|
||||
const backdrop = fixedContainer.querySelector('[class*="bg-black"]') as HTMLElement;
|
||||
expect(backdrop).toBeTruthy();
|
||||
// Verify the backdrop is the full-screen overlay (has bg-black/70)
|
||||
expect(backdrop?.className).toContain("bg-black/70");
|
||||
expect(backdrop.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("decorative warning SVG in header has aria-hidden='true'", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3,56 +3,55 @@
|
||||
* Tests for Spinner component.
|
||||
*
|
||||
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
|
||||
*
|
||||
* NOTE: SVG elements use SVGAnimatedString for className (not a plain string),
|
||||
* so we use getAttribute("class") instead of className for assertions.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Spinner } from "../Spinner";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function getSvgClass(r: ReturnType<typeof render>): string {
|
||||
const svg = r.container.querySelector("svg");
|
||||
if (!svg) throw new Error("No SVG found");
|
||||
return svg.getAttribute("class") ?? "";
|
||||
}
|
||||
|
||||
describe("Spinner — size variants", () => {
|
||||
// Use getAttribute("class") instead of .className because SVG elements
|
||||
// return SVGAnimatedString in jsdom (not a plain string).
|
||||
it("renders with sm size class", () => {
|
||||
const r = render(<Spinner size="sm" />);
|
||||
expect(getSvgClass(r)).toContain("w-3");
|
||||
expect(getSvgClass(r)).toContain("h-3");
|
||||
const { container } = render(<Spinner size="sm" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg).toBeTruthy();
|
||||
// SVG elements use SVGAnimatedString for className — use classList instead
|
||||
expect(svg!.classList.contains("w-3")).toBe(true);
|
||||
expect(svg!.classList.contains("h-3")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with md size class (default)", () => {
|
||||
const r = render(<Spinner size="md" />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
const { container } = render(<Spinner size="md" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-4")).toBe(true);
|
||||
expect(svg?.classList.contains("h-4")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with lg size class", () => {
|
||||
const r = render(<Spinner size="lg" />);
|
||||
expect(getSvgClass(r)).toContain("w-5");
|
||||
expect(getSvgClass(r)).toContain("h-5");
|
||||
const { container } = render(<Spinner size="lg" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-5")).toBe(true);
|
||||
expect(svg?.classList.contains("h-5")).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to md size when no size prop given", () => {
|
||||
const r = render(<Spinner />);
|
||||
expect(getSvgClass(r)).toContain("w-4");
|
||||
expect(getSvgClass(r)).toContain("h-4");
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("w-4")).toBe(true);
|
||||
expect(svg?.classList.contains("h-4")).toBe(true);
|
||||
});
|
||||
|
||||
it("has aria-hidden=true so screen readers skip it", () => {
|
||||
const r = render(<Spinner />);
|
||||
const svg = r.container.querySelector("svg");
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("includes the motion-safe:animate-spin class for CSS animation", () => {
|
||||
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin");
|
||||
const { container } = render(<Spinner />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.classList.contains("motion-safe:animate-spin")).toBe(true);
|
||||
});
|
||||
|
||||
it("renders exactly one SVG element", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -6,10 +6,12 @@
|
||||
* SettingsButton integration, custom canvasName prop.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TopBar } from "../canvas/TopBar";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("../settings/SettingsButton", () => ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,389 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for buildDeployMap — the pure tree-computation core inside
|
||||
* useOrgDeployState.
|
||||
*
|
||||
* Issue: #742 (buildDeployMap unit tests, #2071 follow-up).
|
||||
*
|
||||
* The function takes a flat list of NodeProjections and a set of
|
||||
* deletingIds, then computes per-node OrgDeployState:
|
||||
* isActivelyProvisioning — node itself is provisioning
|
||||
* isDeployingRoot — node is a root AND has provisioning descendants
|
||||
* isLockedChild — node is a deleting child OR a non-root in a deploying tree
|
||||
* descendantProvisioningCount — total provisioning descendants (roots only)
|
||||
*
|
||||
* Coverage:
|
||||
* §1 Empty input
|
||||
* §2 Single node — no parent, non-provisioning
|
||||
* §3 Single node — no parent, provisioning
|
||||
* §4 Single node — has parent (parent exists)
|
||||
* §5 Parent not in projections → node treated as root
|
||||
* §6 Two nodes: root (non-provisioning) + child
|
||||
* §7 Two nodes: root (provisioning) + child
|
||||
* §8 Three-level tree: grandparent (provisioning) → parent → child
|
||||
* §9 DeletingIds contains a non-root node → isLockedChild=true
|
||||
* §10 DeletingIds contains the root → root isLockedChild=true
|
||||
* §11 Two independent roots, one provisioning
|
||||
* §12 Provisioning count: root has 2 provisioning descendants
|
||||
* §13 Non-root node with provisioning status → isActivelyProvisioning=true
|
||||
* §14 findRoot memoization: repeated calls don't re-walk the chain
|
||||
* §15 deletingIds + provisioning interact: deleting takes isLockedChild
|
||||
* §16 Child of provisioning root (not itself provisioning) → isLockedChild=true
|
||||
* §17 Deep chain (5 levels), no provisioning → all nodes unlocked
|
||||
* §18 Deep chain (5 levels), middle node is provisioning root
|
||||
* §19 Node with parentId pointing to non-existent node → treated as root
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDeployMap } from "../useOrgDeployState";
|
||||
import type { OrgDeployState } from "../useOrgDeployState";
|
||||
|
||||
type Projection = { id: string; parentId: string | null; status: string };
|
||||
|
||||
function proj(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
status = "idle",
|
||||
): Projection {
|
||||
return { id, parentId, status };
|
||||
}
|
||||
|
||||
// expected maps node-id → partial state (includes `id` as a key)
|
||||
function check(
|
||||
projections: Projection[],
|
||||
deletingIds: string[],
|
||||
expected: Record<string, Partial<OrgDeployState>>,
|
||||
): void {
|
||||
const result = buildDeployMap(projections, new Set(deletingIds));
|
||||
expect(result.size).toBe(projections.length);
|
||||
for (const [id, state] of result.entries()) {
|
||||
if (id in expected) {
|
||||
expect(state).toMatchObject(expected[id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── §1–§5: Basic structure ──────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — basic structure (§1–§5)", () => {
|
||||
it("§1 returns an empty map when projections is empty", () => {
|
||||
const result = buildDeployMap([], new Set());
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it("§2 single node, no parent, non-provisioning → unlocked root", () => {
|
||||
check([proj("a")], [], {
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("§3 single provisioning node → deploying root", () => {
|
||||
check([proj("a", null, "provisioning")], [], {
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("§4 single node with existing parent → non-root, unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{
|
||||
id: "child",
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§5 parentId points to a node not in projections → treated as root", () => {
|
||||
// "orphan" is a root because its parent is absent from the projection list.
|
||||
check([proj("orphan", "ghost", "idle")], [], {
|
||||
id: "orphan",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §6–§8: Multi-node trees ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — multi-node trees (§6–§8)", () => {
|
||||
it("§6 root (non-provisioning) + child → root not deploying, child unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "root", isDeployingRoot: false, isLockedChild: false },
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "child", isLockedChild: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("§7 root (provisioning) + child → root deploying, child locked", () => {
|
||||
check(
|
||||
[proj("root", null, "provisioning"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{
|
||||
id: "root",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
},
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "provisioning"), proj("child", "root", "idle")],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§8 three-level tree: grandparent (provisioning) → parent → child", () => {
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "grandparent",
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
},
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "parent", isLockedChild: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("grandparent", null, "provisioning"),
|
||||
proj("parent", "grandparent", "idle"),
|
||||
proj("child", "parent", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §9–§11: DeletingIds + independent roots ──────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deletingIds + independent roots (§9–§11)", () => {
|
||||
it("§9 deletingIds contains a non-root → isLockedChild=true", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["child"],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§10 deletingIds contains the root → root isLockedChild=true, child unlocked", () => {
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["root"],
|
||||
{ id: "root", isLockedChild: true, isDeployingRoot: false },
|
||||
);
|
||||
check(
|
||||
[proj("root", null, "idle"), proj("child", "root", "idle")],
|
||||
["root"],
|
||||
{ id: "child", isLockedChild: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("§11 two independent roots, only one is provisioning", () => {
|
||||
check(
|
||||
[
|
||||
proj("rootA", null, "idle"),
|
||||
proj("rootB", null, "provisioning"),
|
||||
],
|
||||
[],
|
||||
{ id: "rootA", isDeployingRoot: false, descendantProvisioningCount: 0 },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("rootA", null, "idle"),
|
||||
proj("rootB", null, "provisioning"),
|
||||
],
|
||||
[],
|
||||
{ id: "rootB", isDeployingRoot: true, descendantProvisioningCount: 1 },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §12–§15: Provisioning counts + interactions ─────────────────────────────
|
||||
|
||||
describe("buildDeployMap — provisioning counts + interactions (§12–§15)", () => {
|
||||
it("§12 root has 2 provisioning descendants → descendantProvisioningCount=2", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "idle"),
|
||||
proj("prov1", "root", "provisioning"),
|
||||
proj("prov2", "root", "provisioning"),
|
||||
proj("idle", "root", "idle"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "root",
|
||||
isDeployingRoot: true,
|
||||
descendantProvisioningCount: 2,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§13 non-root node with provisioning status → isActivelyProvisioning=true", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "idle"),
|
||||
proj("provChild", "root", "provisioning"),
|
||||
],
|
||||
[],
|
||||
{
|
||||
id: "provChild",
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("§14 findRoot memoization: chain is only walked once per root", () => {
|
||||
// Indirect verification: a 3-level tree should return consistent rootIds
|
||||
// for all nodes without throwing or producing stale entries.
|
||||
const projections = [
|
||||
proj("root", null, "idle"),
|
||||
proj("l1", "root", "idle"),
|
||||
proj("l2", "l1", "idle"),
|
||||
proj("l3", "l2", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(projections, new Set());
|
||||
expect(result.get("root")?.isDeployingRoot).toBe(false);
|
||||
expect(result.get("l1")?.isLockedChild).toBe(false);
|
||||
expect(result.get("l2")?.isLockedChild).toBe(false);
|
||||
expect(result.get("l3")?.isLockedChild).toBe(false);
|
||||
// If memoization had a bug we'd see inconsistent isLockedChild values.
|
||||
});
|
||||
|
||||
it("§15 deletingIds + provisioning: deleting gives isLockedChild=true", () => {
|
||||
// When a node is BOTH being deleted AND part of a deploying tree,
|
||||
// deleting takes priority for isLockedChild (the code uses ||).
|
||||
check(
|
||||
[
|
||||
proj("root", null, "provisioning"),
|
||||
proj("provChild", "root", "idle"),
|
||||
],
|
||||
["provChild"],
|
||||
{ id: "provChild", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── §16–§19: Deeper tree + edge cases ────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deep trees + edge cases (§16–§19)", () => {
|
||||
it("§16 child of provisioning root (not itself provisioning) → isLockedChild=true", () => {
|
||||
check(
|
||||
[
|
||||
proj("root", null, "provisioning"),
|
||||
proj("child", "root", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "child", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
|
||||
it("§17 deep chain (5 levels), no provisioning → all nodes unlocked", () => {
|
||||
const deep = [
|
||||
proj("n1", null, "idle"),
|
||||
proj("n2", "n1", "idle"),
|
||||
proj("n3", "n2", "idle"),
|
||||
proj("n4", "n3", "idle"),
|
||||
proj("n5", "n4", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(deep, new Set());
|
||||
expect(result.get("n1")?.isDeployingRoot).toBe(false);
|
||||
expect(result.get("n1")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n2")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n3")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n4")?.isLockedChild).toBe(false);
|
||||
expect(result.get("n5")?.isLockedChild).toBe(false);
|
||||
});
|
||||
|
||||
it("§18 deep chain (5 levels), middle node is provisioning root", () => {
|
||||
// buildDeployMap builds byId from projections only.
|
||||
// findRoot walks the parent chain: n3.findRoot() → n3→n2→n1 → n1.parentId
|
||||
// absent from byId → rootId=n1 for ALL nodes.
|
||||
// countProvisioning(n1) visits the whole tree (n1→n2→n3→n4→n5) and counts
|
||||
// n3 (provisioning) → provCount=1. n1 is the sole deploying root.
|
||||
// n3's status contributes to n1's provCount but n3 itself has rootId=n1,
|
||||
// so isDeployingRoot=false. All non-root nodes are isLockedChild=true.
|
||||
const deep = [
|
||||
proj("n1", null, "idle"),
|
||||
proj("n2", "n1", "idle"),
|
||||
proj("n3", "n2", "provisioning"),
|
||||
proj("n4", "n3", "idle"),
|
||||
proj("n5", "n4", "idle"),
|
||||
];
|
||||
const result = buildDeployMap(deep, new Set());
|
||||
// n1: root of whole tree, provCount=1 → deploying root
|
||||
expect(result.get("n1")?.isDeployingRoot).toBe(true);
|
||||
expect(result.get("n1")?.isLockedChild).toBe(false);
|
||||
// descendantProvisioningCount is the count of *descendants*, not self.
|
||||
// n1 itself is idle, so count=1 (n3).
|
||||
expect(result.get("n1")?.descendantProvisioningCount).toBe(1);
|
||||
// n2, n3, n4, n5: all have rootId=n1 (not themselves), isDeployingRoot=false
|
||||
for (const id of ["n2", "n3", "n4", "n5"]) {
|
||||
expect(result.get(id)?.isDeployingRoot).toBe(false);
|
||||
expect(result.get(id)?.isLockedChild).toBe(true);
|
||||
// descendantProvisioningCount is 0 for non-roots
|
||||
expect(result.get(id)?.descendantProvisioningCount).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("§19 parentId pointing to non-existent node → treated as root", () => {
|
||||
// Same node appears both as a child of a ghost parent AND as a parent of a real child.
|
||||
// When the ghost parent is absent, node2 is a root.
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node1", isDeployingRoot: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node2", isDeployingRoot: true },
|
||||
);
|
||||
check(
|
||||
[
|
||||
proj("node1", "ghost", "idle"),
|
||||
proj("node2", null, "idle"),
|
||||
proj("node3", "node2", "idle"),
|
||||
],
|
||||
[],
|
||||
{ id: "node3", isLockedChild: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,311 @@
|
||||
/**
|
||||
* Unit tests for buildDeployMap — the pure tree-traversal core of
|
||||
* useOrgDeployState.
|
||||
*
|
||||
* What is tested here:
|
||||
* - Root / leaf identification via parent-chain walk
|
||||
* - isDeployingRoot: true when any descendant is "provisioning"
|
||||
* - isActivelyProvisioning: true only for the node itself in that state
|
||||
* - isLockedChild: true for non-root nodes in a deploying tree
|
||||
* - isLockedChild: also true for nodes in deletingIds (even if not deploying)
|
||||
* - descendantProvisioningCount: non-zero only on root nodes
|
||||
* - Performance contract: O(n) single-pass walk — tested by verifying
|
||||
* correctness across 50-node trees (n=50, all cases above)
|
||||
*
|
||||
* What is NOT tested here (hook integration — appropriate for E2E):
|
||||
* - The useMemo / Zustand subscription wiring
|
||||
* - React Flow integration (flowToScreenPosition, getInternalNode)
|
||||
*
|
||||
* Issue: #2071 (Canvas test gaps follow-up).
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildDeployMap, type OrgDeployState } from "../useOrgDeployState";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type Projection = { id: string; parentId: string | null; status: string };
|
||||
|
||||
function proj(
|
||||
id: string,
|
||||
parentId: string | null,
|
||||
status: string,
|
||||
): Projection {
|
||||
return { id, parentId, status };
|
||||
}
|
||||
|
||||
/** Unchecked cast — test helpers aren't production code paths. */
|
||||
function m(
|
||||
ps: Projection[],
|
||||
deletingIds: string[] = [],
|
||||
): Map<string, OrgDeployState> {
|
||||
return buildDeployMap(ps, new Set(deletingIds));
|
||||
}
|
||||
|
||||
function s(
|
||||
map: Map<string, OrgDeployState>,
|
||||
id: string,
|
||||
): OrgDeployState {
|
||||
const got = map.get(id);
|
||||
if (!got) throw new Error(`no entry for id=${id}`);
|
||||
return got;
|
||||
}
|
||||
|
||||
// ── Empty / trivial ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — empty", () => {
|
||||
it("returns empty map for empty projections", () => {
|
||||
expect(m([]).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Single node ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — single node", () => {
|
||||
it("isolated node is its own root and not deploying", () => {
|
||||
const map = m([proj("a", null, "online")]);
|
||||
expect(s(map, "a")).toEqual({
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("isolated provisioning node is deploying root", () => {
|
||||
const map = m([proj("a", null, "provisioning")]);
|
||||
expect(s(map, "a")).toEqual({
|
||||
isActivelyProvisioning: true,
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── Parent / child chains ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — parent / child chains", () => {
|
||||
it("root with online child: root is not deploying, child is not locked", () => {
|
||||
// A ──► B
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
expect(s(map, "B")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
});
|
||||
|
||||
it("root with provisioning child: root is deploying, child is locked", () => {
|
||||
// A ──► B (B is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
|
||||
});
|
||||
|
||||
it("provisioning root with online child: root is deploying, child is locked", () => {
|
||||
// A (provisioning) ──► B (online)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, isActivelyProvisioning: true });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
|
||||
});
|
||||
|
||||
it("grandchild inherits deploy lock through intermediate online node", () => {
|
||||
// A ──► B ──► C (A is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "online"),
|
||||
]);
|
||||
// B and C are both non-root descendants of the deploying root
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
});
|
||||
|
||||
it("deep chain: only the topmost node with a null parent counts as root", () => {
|
||||
// A ──► B ──► C ──► D (A is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "online"),
|
||||
proj("D", "C", "online"),
|
||||
]);
|
||||
const roots = ["A", "B", "C", "D"].filter((id) => s(map, id).isDeployingRoot);
|
||||
expect(roots).toEqual(["A"]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sibling branching ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — sibling branching", () => {
|
||||
it("parent with multiple children: deploying root propagates to all children", () => {
|
||||
// A (provisioning)
|
||||
// / \
|
||||
// B C
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 1 });
|
||||
});
|
||||
|
||||
it("only one provisioning descendant marks the root as deploying", () => {
|
||||
// A
|
||||
// / | \
|
||||
// B C D (only C is provisioning)
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "A", "provisioning"),
|
||||
proj("D", "A", "online"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "C")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
|
||||
expect(s(map, "D")).toMatchObject({ isLockedChild: true });
|
||||
});
|
||||
|
||||
it("two provisioning siblings: count reflects both", () => {
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "provisioning"),
|
||||
proj("C", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 2 });
|
||||
expect(s(map, "B")).toMatchObject({ isActivelyProvisioning: true });
|
||||
expect(s(map, "C")).toMatchObject({ isActivelyProvisioning: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Multiple disjoint trees ───────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — multiple disjoint trees", () => {
|
||||
it("each tree has its own root; deploying nodes are independent", () => {
|
||||
// Tree 1: X (provisioning) ──► Y
|
||||
// Tree 2: P ──► Q (no provisioning)
|
||||
const map = m([
|
||||
proj("X", null, "provisioning"),
|
||||
proj("Y", "X", "online"),
|
||||
proj("P", null, "online"),
|
||||
proj("Q", "P", "online"),
|
||||
]);
|
||||
expect(s(map, "X")).toMatchObject({ isDeployingRoot: true });
|
||||
expect(s(map, "Y")).toMatchObject({ isLockedChild: true });
|
||||
expect(s(map, "P")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
expect(s(map, "Q")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Deleting nodes ────────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — deletingIds", () => {
|
||||
it("node in deletingIds is locked even if tree is not deploying", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
["B"], // B is being deleted
|
||||
);
|
||||
expect(s(map, "A")).toMatchObject({ isLockedChild: false });
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
|
||||
});
|
||||
|
||||
it("node in deletingIds: isLockedChild is true regardless of provisioning", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
["B"],
|
||||
);
|
||||
// B is both a deploying-child AND a deleting node — either alone locks it
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
|
||||
});
|
||||
|
||||
it("empty deletingIds set has no effect", () => {
|
||||
const map = m(
|
||||
[
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
],
|
||||
[],
|
||||
);
|
||||
expect(s(map, "B")).toMatchObject({ isLockedChild: false });
|
||||
});
|
||||
});
|
||||
|
||||
// ── descendantProvisioningCount ───────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — descendantProvisioningCount", () => {
|
||||
it("is 0 for non-root nodes", () => {
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "B").descendantProvisioningCount).toBe(0);
|
||||
});
|
||||
|
||||
it("includes the root's own status when provisioning", () => {
|
||||
const map = m([
|
||||
proj("A", null, "provisioning"),
|
||||
proj("B", "A", "online"),
|
||||
]);
|
||||
// A is both root and provisioning → count includes itself
|
||||
expect(s(map, "A").descendantProvisioningCount).toBe(1);
|
||||
});
|
||||
|
||||
it("accumulates all provisioning descendants (not just immediate children)", () => {
|
||||
const map = m([
|
||||
proj("A", null, "online"),
|
||||
proj("B", "A", "online"),
|
||||
proj("C", "B", "provisioning"),
|
||||
]);
|
||||
expect(s(map, "A").descendantProvisioningCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── O(n) performance ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("buildDeployMap — O(n) performance contract", () => {
|
||||
it("handles a 50-node three-level tree without incorrect node assignments", () => {
|
||||
// Level 0: 1 root
|
||||
// Level 1: 7 children
|
||||
// Level 2: 42 leaves
|
||||
// Total: 50 nodes
|
||||
const projections: Projection[] = [];
|
||||
projections.push(proj("root", null, "provisioning"));
|
||||
for (let i = 0; i < 7; i++) {
|
||||
projections.push(proj(`l1-${i}`, "root", "online"));
|
||||
}
|
||||
for (let i = 0; i < 42; i++) {
|
||||
const parent = `l1-${Math.floor(i / 6)}`;
|
||||
projections.push(proj(`l2-${i}`, parent, "online"));
|
||||
}
|
||||
const map = m(projections);
|
||||
|
||||
// Root is the only deploying node
|
||||
expect(s(map, "root")).toMatchObject({
|
||||
isDeployingRoot: true,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 1,
|
||||
});
|
||||
|
||||
// Every other node is a locked child
|
||||
for (let i = 0; i < 7; i++) {
|
||||
expect(s(map, `l1-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
|
||||
}
|
||||
for (let i = 0; i < 42; i++) {
|
||||
expect(s(map, `l2-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -40,6 +40,7 @@ interface NodeProjection {
|
||||
status: string;
|
||||
}
|
||||
|
||||
// Exported for unit testing — the function is pure and deterministic.
|
||||
export function buildDeployMap(
|
||||
projections: NodeProjection[],
|
||||
deletingIds: ReadonlySet<string>,
|
||||
|
||||
@@ -20,7 +20,6 @@ import { MobileMe } from "./MobileMe";
|
||||
import { MobileSpawn } from "./MobileSpawn";
|
||||
import { usePalette } from "./palette";
|
||||
import { MobileAccentProvider } from "./palette-context";
|
||||
import { SearchDialog } from "@/components/SearchDialog";
|
||||
|
||||
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me";
|
||||
|
||||
@@ -205,8 +204,6 @@ export function MobileApp() {
|
||||
{showTabBar && <TabBar dark={dark} active={activeTab} onChange={onTabChange} />}
|
||||
|
||||
{showSpawn && <MobileSpawn dark={dark} onClose={() => setShowSpawn(false)} />}
|
||||
|
||||
<SearchDialog />
|
||||
</main>
|
||||
</MobileAccentProvider>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
usePalette,
|
||||
} from "./palette";
|
||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
// Derived view-model the mobile screens consume. Built once per render
|
||||
// from the store's Node<WorkspaceNodeData>.
|
||||
@@ -38,7 +37,7 @@ export interface MobileAgent {
|
||||
export function toMobileAgent(node: Node<WorkspaceNodeData>): MobileAgent {
|
||||
const cap = summarizeWorkspaceCapabilities(node.data);
|
||||
const runtime = cap.runtime ?? "unknown";
|
||||
const remote = isExternalLikeRuntime(runtime);
|
||||
const remote = runtime === "external";
|
||||
return {
|
||||
id: node.id,
|
||||
name: node.data.name || node.id,
|
||||
|
||||
@@ -67,7 +67,7 @@ interface A2AResponse {
|
||||
// Server-side counterpart in workspace-server/internal/channels/
|
||||
// manager.go has the same single-part bug; fix that too if/when a
|
||||
// channel-delivered reply (Slack, Lark, etc.) gets truncated.
|
||||
export function extractReplyText(resp: A2AResponse): string {
|
||||
function extractReplyText(resp: A2AResponse): string {
|
||||
const collect = (parts: A2APart[] | undefined): string => {
|
||||
if (!parts) return "";
|
||||
return parts
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
findProviderForModel,
|
||||
type SelectorValue,
|
||||
} from "../ProviderModelSelector";
|
||||
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
@@ -144,7 +143,7 @@ interface RuntimeOption {
|
||||
// haven't migrated to the explicit `providers:` field yet, AND
|
||||
// continues to be a useful fallback for any future runtime whose
|
||||
// derive-provider semantics happen to match the slug prefix.
|
||||
export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const m of models) {
|
||||
@@ -176,7 +175,7 @@ export 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} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,100 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for deriveProvidersFromModels — pure vendor-slug extractor from
|
||||
* a model list used in ConfigTab.tsx.
|
||||
*
|
||||
* Takes ModelSpec[] and returns a deduplicated array of vendor strings.
|
||||
* Vendor is derived by splitting on ":" (anthropic:claude-opus-4-7) or
|
||||
* "/" (nousresearch/hermes-4-70b). Order is preserved from input.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { deriveProvidersFromModels } from "../ConfigTab";
|
||||
|
||||
// Local type mirror (not exported from ConfigTab)
|
||||
interface ModelSpec {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
describe("deriveProvidersFromModels", () => {
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(deriveProvidersFromModels([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts vendor from colon-separated id", () => {
|
||||
const models: ModelSpec[] = [{ id: "anthropic:claude-sonnet-4-5" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic"]);
|
||||
});
|
||||
|
||||
it("extracts vendor from slash-separated id", () => {
|
||||
const models: ModelSpec[] = [{ id: "nousresearch/hermes-4-70b" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["nousresearch"]);
|
||||
});
|
||||
|
||||
it("deduplicates repeated vendors", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "openai:gpt-4o" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic", "openai"]);
|
||||
});
|
||||
|
||||
it("skips models with no id", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{},
|
||||
{ id: undefined },
|
||||
{ id: "" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic"]);
|
||||
});
|
||||
|
||||
it("skips ids with no vendor separator", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "claude-sonnet-4-5" },
|
||||
{ id: "unknown/runtime" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["unknown"]);
|
||||
});
|
||||
|
||||
it("skips empty string id", () => {
|
||||
const models: ModelSpec[] = [{ id: "" }];
|
||||
expect(deriveProvidersFromModels(models)).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves first-occurrence order", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "openai:gpt-4o" },
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "google:gemini-2-5-flash" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual([
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles mix of valid and invalid ids", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{},
|
||||
{ id: "openai:gpt-4o-mini" },
|
||||
{ id: "" },
|
||||
{ id: "no-separator" },
|
||||
{ id: "anthropic:claude-opus-4-7" },
|
||||
];
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["openai", "anthropic"]);
|
||||
});
|
||||
|
||||
it("is pure — same input always returns same output", () => {
|
||||
const models: ModelSpec[] = [
|
||||
{ id: "anthropic:claude-sonnet-4-5" },
|
||||
{ id: "openai:gpt-4o" },
|
||||
{ id: "google:gemini-2-5-flash" },
|
||||
];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(deriveProvidersFromModels(models)).toEqual(["anthropic", "openai", "google"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for extractReplyText — the A2A result-path text extractor used
|
||||
* in ChatTab.tsx.
|
||||
*
|
||||
* extractReplyText pulls the agent's text reply out of an A2A response.
|
||||
* Concatenates ALL text parts (joined with "\n") rather than returning
|
||||
* just the first. Claude Code and other runtimes commonly emit multi-
|
||||
* part text replies for long content (markdown tables, code blocks),
|
||||
* and the prior "first part wins" implementation silently truncated
|
||||
* the rest. Mirrors extractTextsFromParts in message-parser.ts.
|
||||
*
|
||||
* Note: extractReplyText is scoped to the result.parts + result.artifacts
|
||||
* path — unlike extractResponseText which also handles body.task / body.text /
|
||||
* body.response_preview. It is the correct extractor for live A2A
|
||||
* responses where the text lives on result.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { extractReplyText } from "../ChatTab";
|
||||
|
||||
describe("extractReplyText — A2A result path", () => {
|
||||
it("returns empty string for undefined response", () => {
|
||||
expect(extractReplyText(undefined as never)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for null result", () => {
|
||||
expect(extractReplyText({ result: null as never })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when result has no parts or artifacts", () => {
|
||||
expect(extractReplyText({ result: {} })).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when parts array is empty", () => {
|
||||
expect(extractReplyText({ result: { parts: [] } })).toBe("");
|
||||
});
|
||||
|
||||
it("extracts text from a single text part", () => {
|
||||
expect(
|
||||
extractReplyText({ result: { parts: [{ kind: "text", text: "Hello world" }] } })
|
||||
).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("concatenates multiple text parts with newlines (no truncation)", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [
|
||||
{ kind: "text", text: "# Header" },
|
||||
{ kind: "text", text: "| Col |" },
|
||||
{ kind: "text", text: "| --- |" },
|
||||
{ kind: "text", text: "| Row |" },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("# Header\n| Col |\n| --- |\n| Row |");
|
||||
});
|
||||
|
||||
it("skips non-text parts", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [
|
||||
{ kind: "image", text: "should be ignored" },
|
||||
{ kind: "text", text: "visible" },
|
||||
{ kind: "file", text: "also ignored" },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("visible");
|
||||
});
|
||||
|
||||
it("skips text parts with empty string", () => {
|
||||
expect(extractReplyText({ result: { parts: [{ kind: "text", text: "" }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("skips parts with missing text field", () => {
|
||||
expect(extractReplyText({ result: { parts: [{ kind: "text" }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("walks artifacts and collects their text parts", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "Artifact one" }] },
|
||||
{ parts: [{ kind: "text", text: "Artifact two" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("Artifact one\nArtifact two");
|
||||
});
|
||||
|
||||
it("combines result.parts AND result.artifacts text (both sources)", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [{ kind: "text", text: "Summary" }],
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "Detail block one" }] },
|
||||
{ parts: [{ kind: "text", text: "Detail block two" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("Summary\nDetail block one\nDetail block two");
|
||||
});
|
||||
|
||||
it("artifacts are processed even when parts are empty", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
parts: [],
|
||||
artifacts: [{ parts: [{ kind: "text", text: "Only artifact" }] }],
|
||||
},
|
||||
})
|
||||
).toBe("Only artifact");
|
||||
});
|
||||
|
||||
it("artifacts with empty parts array contribute nothing", () => {
|
||||
expect(extractReplyText({ result: { artifacts: [{ parts: [] }] } })).toBe("");
|
||||
});
|
||||
|
||||
it("multiple artifacts each contribute their text", () => {
|
||||
expect(
|
||||
extractReplyText({
|
||||
result: {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "A" }, { kind: "text", text: "B" }] },
|
||||
{ parts: [{ kind: "text", text: "C" }] },
|
||||
],
|
||||
},
|
||||
})
|
||||
).toBe("A\nB\nC");
|
||||
});
|
||||
});
|
||||
@@ -248,6 +248,81 @@ describe("extractResponseText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAgentText", () => {
|
||||
it("extracts from parts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "Hello from agent" }],
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("Hello from agent");
|
||||
});
|
||||
|
||||
it("extracts from artifacts[0].parts", () => {
|
||||
const task = {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "Artifact text" }] },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("Artifact text");
|
||||
});
|
||||
|
||||
it("extracts from status.message.parts", () => {
|
||||
const task = {
|
||||
status: {
|
||||
message: { parts: [{ kind: "text", text: "Status text" }] },
|
||||
},
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("Status text");
|
||||
});
|
||||
|
||||
it("prefers parts over artifacts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "parts wins" }],
|
||||
artifacts: [{ parts: [{ kind: "text", text: "artifacts lost" }] }],
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("parts wins");
|
||||
});
|
||||
|
||||
it("prefers artifacts[0] over status.message", () => {
|
||||
const task = {
|
||||
status: { message: { parts: [{ kind: "text", text: "status lost" }] } },
|
||||
artifacts: [{ parts: [{ kind: "text", text: "artifacts wins" }] }],
|
||||
};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("artifacts wins");
|
||||
});
|
||||
|
||||
it("falls back to string task", () => {
|
||||
expect(extractAgentText("raw string task" as unknown as Record<string, unknown>)).toBe("raw string task");
|
||||
});
|
||||
|
||||
// FIXED BUG: when all three sources return nothing (no text parts), extractAgentText
|
||||
// now returns "" instead of the error message. An empty task should render as a
|
||||
// blank bubble, not an error indicator.
|
||||
it("returns empty string when parts is empty array", () => {
|
||||
const task = { parts: [] };
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when artifacts is empty array", () => {
|
||||
const task = { artifacts: [] };
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string when status.message.parts is empty", () => {
|
||||
const task = { status: { message: { parts: [] } } };
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
|
||||
it("tolerates null/undefined status.message without throwing", () => {
|
||||
const task = { status: null };
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
|
||||
it("tolerates undefined artifacts without throwing", () => {
|
||||
const task = {};
|
||||
expect(extractAgentText(task as Record<string, unknown>)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTextsFromParts", () => {
|
||||
it("extracts text parts with kind=text", () => {
|
||||
const parts = [
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export function extractAgentText(task: Record<string, unknown>): string {
|
||||
try {
|
||||
// Check direct string first — some callers pass the raw response body.
|
||||
if (typeof task === "string") return task;
|
||||
|
||||
const directTexts = extractTextsFromParts(task.parts);
|
||||
if (directTexts) return directTexts;
|
||||
|
||||
@@ -16,8 +19,14 @@ export function extractAgentText(task: Record<string, unknown>): string {
|
||||
if (texts) return texts;
|
||||
}
|
||||
|
||||
if (typeof task === "string") return task;
|
||||
return "(Could not extract response text)";
|
||||
// No text found in any source. Return "" so callers render a blank
|
||||
// bubble rather than an error chip. This handles:
|
||||
// - parts: [] (empty array, no text parts)
|
||||
// - artifacts: [] (no artifacts at all)
|
||||
// - status: {} (status present but no message)
|
||||
// - status.message=null (null guard)
|
||||
// - {} (entirely empty task)
|
||||
return "";
|
||||
} catch {
|
||||
return "(Failed to parse response)";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -70,6 +70,7 @@ export function KeyValueField({
|
||||
aria-label={ariaLabel}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
role="textbox"
|
||||
/>
|
||||
<RevealToggle
|
||||
revealed={revealed}
|
||||
|
||||
@@ -65,13 +65,17 @@ export function TestConnectionButton({
|
||||
|
||||
return (
|
||||
<div className="test-connection">
|
||||
{state === 'testing' && (
|
||||
<span aria-hidden="true" className="test-connection__spinner">
|
||||
<Spinner />
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={state === 'testing' || !secretValue}
|
||||
className={`test-connection__btn test-connection__btn--${state}`}
|
||||
>
|
||||
{state === 'testing' && <Spinner />}
|
||||
{LABELS[state]}
|
||||
</button>
|
||||
{errorDetail && state === 'failure' && (
|
||||
@@ -83,9 +87,9 @@ export function TestConnectionButton({
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
function Spinner({ ariaHidden = true }: { ariaHidden?: boolean }) {
|
||||
return (
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden={ariaHidden}>
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for canvas/src/lib/hydrate.ts — exponential-backoff canvas store hydration.
|
||||
*
|
||||
* 7 cases:
|
||||
* 1. Success on first attempt → { error: null }
|
||||
* 2. Viewport fetch fails (non-fatal) → store still hydrates, returns { error: null }
|
||||
* 3. Success after 1 retry → onRetrying(1) called once, final result { error: null }
|
||||
* 4. Success after 2 retries → onRetrying called for each failed attempt
|
||||
* 5. All attempts fail → returns the error message after MAX_RETRIES
|
||||
* 6. onRetrying called with correct attempt number on each retry
|
||||
* 7. Exponential backoff delays: 1s, 2s, 4s for attempts 1, 2, 3
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
|
||||
|
||||
// ─── Mock api ──────────────────────────────────────────────────────────────────
|
||||
// PLATFORM_URL must be a named export — hydrate.ts imports it directly, not via api.
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn<(path: string) => Promise<unknown>>(),
|
||||
},
|
||||
PLATFORM_URL: "http://localhost:8080",
|
||||
}));
|
||||
|
||||
// ─── Mock store ────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockHydrate = vi.fn();
|
||||
const mockSetViewport = vi.fn();
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: {
|
||||
getState: () => ({
|
||||
hydrate: mockHydrate,
|
||||
setViewport: mockSetViewport,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const mockApiGet = vi.mocked(api.get);
|
||||
|
||||
function makeWorkspace(id = "ws-1") {
|
||||
return {
|
||||
id,
|
||||
name: "Test WS",
|
||||
role: "assistant",
|
||||
tier: 1,
|
||||
status: "online" as const,
|
||||
agent_card: null,
|
||||
url: "http://localhost:9000",
|
||||
parent_id: null,
|
||||
active_tasks: 0,
|
||||
last_error_rate: 0,
|
||||
last_sample_error: "",
|
||||
uptime_seconds: 60,
|
||||
current_task: "",
|
||||
x: 0,
|
||||
y: 0,
|
||||
collapsed: false,
|
||||
runtime: "",
|
||||
budget_limit: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Setup / teardown ──────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ─── Tests ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("hydrateCanvas — success paths", () => {
|
||||
it("returns { error: null } on first-attempt success", async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // /canvas/viewport
|
||||
|
||||
const result = await hydrateCanvas();
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
expect(mockSetViewport).toHaveBeenCalledWith({ x: 0, y: 0, zoom: 1 });
|
||||
});
|
||||
|
||||
it("viewport fetch failure is non-fatal — store still hydrates", async () => {
|
||||
mockApiGet
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // /workspaces OK
|
||||
.mockRejectedValueOnce(new Error("viewport down")); // /canvas/viewport fails
|
||||
|
||||
const result = await hydrateCanvas();
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(mockHydrate).toHaveBeenCalledOnce();
|
||||
expect(mockSetViewport).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns { error: null } after 1 retry", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
// Each attempt makes 2 parallel api.get calls (workspaces + viewport).
|
||||
// Attempt 1 (fails): /workspaces → rejected, /viewport → resolved
|
||||
// Attempt 2 (succeeds): /workspaces → resolved, /viewport → resolved
|
||||
mockApiGet
|
||||
.mockRejectedValueOnce(new Error("network down")) // attempt 1: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // attempt 1: /viewport
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // attempt 2: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // attempt 2: /viewport
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
|
||||
// Advance past the first backoff delay (1000 * 2^0 = 1000 ms)
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(onRetrying).toHaveBeenCalledTimes(1);
|
||||
expect(onRetrying).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("onRetrying called once per failed attempt before next retry", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
// Attempt 1: both calls fail
|
||||
// Attempt 2: both calls fail
|
||||
// Attempt 3: both calls succeed → hydrate succeeds
|
||||
mockApiGet
|
||||
.mockRejectedValueOnce(new Error("attempt 1")) // a1: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // a1: /viewport (resolved even though workspaces failed)
|
||||
.mockRejectedValueOnce(new Error("attempt 2")) // a2: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }) // a2: /viewport
|
||||
.mockResolvedValueOnce([makeWorkspace()]) // a3: /workspaces
|
||||
.mockResolvedValueOnce({ x: 0, y: 0, zoom: 1 }); // a3: /viewport
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
const result = await promise;
|
||||
|
||||
expect(result).toEqual({ error: null });
|
||||
expect(onRetrying).toHaveBeenCalledTimes(2);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
|
||||
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateCanvas — failure paths", () => {
|
||||
it("returns error message after all MAX_RETRIES attempts exhausted", async () => {
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1} failed`));
|
||||
}
|
||||
|
||||
const promise = hydrateCanvas();
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await promise;
|
||||
|
||||
expect(result.error).not.toBeNull();
|
||||
expect(result.error).toContain("Unable to connect to platform");
|
||||
expect(mockHydrate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("onRetrying called MAX_RETRIES-1 times before final exhausted attempt", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1}`));
|
||||
}
|
||||
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
// onRetrying is called after each failed attempt, before the next attempt.
|
||||
// With MAX_RETRIES=3: called after attempt 1 (→2) and after attempt 2 (→3).
|
||||
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hydrateCanvas — exponential backoff timing", () => {
|
||||
it("total elapsed time equals sum of exponential delays 1s + 2s + 4s", async () => {
|
||||
const onRetrying = vi.fn();
|
||||
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
mockApiGet.mockRejectedValueOnce(new Error(`attempt ${i + 1}`));
|
||||
}
|
||||
|
||||
const start = Date.now();
|
||||
const promise = hydrateCanvas(onRetrying);
|
||||
|
||||
// Advance all timers at once and let fake timers resolve everything
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// Total expected: 1000 (delay1) + 2000 (delay2) = 3000 ms
|
||||
// (no delay after the final attempt 3 — function returns immediately)
|
||||
expect(elapsed).toBeGreaterThanOrEqual(2999);
|
||||
expect(elapsed).toBeLessThan(5000); // sanity cap
|
||||
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// @vitest-environment jsdom
|
||||
"use client";
|
||||
/**
|
||||
* Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook.
|
||||
*
|
||||
* Test coverage (9 cases):
|
||||
* 1. MobileAccentProvider renders children
|
||||
* 2. usePalette(false) without provider → MOL_LIGHT
|
||||
* 3. usePalette(true) without provider → MOL_DARK
|
||||
* 4. accent=null returns base palette unchanged
|
||||
* 5. accent=base.accent returns base palette unchanged (identity guard)
|
||||
* 6. accent="#custom" overrides both accent and online
|
||||
* 7. MOL_LIGHT singleton never mutated
|
||||
* 8. MOL_DARK singleton never mutated
|
||||
*
|
||||
* Plus pure-function coverage for normalizeStatus + tierCode.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import {
|
||||
MOL_LIGHT,
|
||||
MOL_DARK,
|
||||
getPalette,
|
||||
normalizeStatus,
|
||||
tierCode,
|
||||
MobileAccentProvider,
|
||||
usePalette,
|
||||
} from "../palette-context";
|
||||
|
||||
// ─── usePalette test helper ───────────────────────────────────────────────────
|
||||
// usePalette reads document.documentElement.dataset.theme internally.
|
||||
// We set this before rendering so the hook sees the right value.
|
||||
|
||||
function setDataTheme(theme: "light" | "dark") {
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pure function tests ──────────────────────────────────────────────────────
|
||||
|
||||
describe("normalizeStatus", () => {
|
||||
it("returns emerald-400 for online status", () => {
|
||||
expect(normalizeStatus("online", false)).toBe("bg-emerald-400");
|
||||
expect(normalizeStatus("online", true)).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it("returns emerald-400 for degraded status", () => {
|
||||
expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400");
|
||||
expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400");
|
||||
});
|
||||
|
||||
it("returns red-400 for failed status", () => {
|
||||
expect(normalizeStatus("failed", false)).toBe("bg-red-400");
|
||||
expect(normalizeStatus("failed", true)).toBe("bg-red-400");
|
||||
});
|
||||
|
||||
it("returns amber-400 for paused status", () => {
|
||||
expect(normalizeStatus("paused", false)).toBe("bg-amber-400");
|
||||
expect(normalizeStatus("paused", true)).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it("returns amber-400 for not_configured status", () => {
|
||||
expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400");
|
||||
});
|
||||
|
||||
it("returns zinc-400 for unknown status", () => {
|
||||
expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400");
|
||||
expect(normalizeStatus("", false)).toBe("bg-zinc-400");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tierCode", () => {
|
||||
it("returns T1 for tier 1", () => {
|
||||
expect(tierCode(1)).toBe("T1");
|
||||
});
|
||||
|
||||
it("returns T2 for tier 2", () => {
|
||||
expect(tierCode(2)).toBe("T2");
|
||||
});
|
||||
|
||||
it("returns T4 for tier 4", () => {
|
||||
expect(tierCode(4)).toBe("T4");
|
||||
});
|
||||
|
||||
it("returns generic T{n} for non-standard tiers", () => {
|
||||
expect(tierCode(99)).toBe("T99");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── getPalette tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("getPalette — accent override", () => {
|
||||
it("accent=null returns base palette unchanged (light)", () => {
|
||||
const result = getPalette(null, false);
|
||||
expect(result).toEqual({ ...MOL_LIGHT });
|
||||
expect(result).not.toBe(MOL_LIGHT); // returned object is a copy
|
||||
});
|
||||
|
||||
it("accent=null returns base palette unchanged (dark)", () => {
|
||||
const result = getPalette(null, true);
|
||||
expect(result).toEqual({ ...MOL_DARK });
|
||||
expect(result).not.toBe(MOL_DARK);
|
||||
});
|
||||
|
||||
it("accent=base.accent returns base palette unchanged (identity guard, light)", () => {
|
||||
const result = getPalette(MOL_LIGHT.accent, false);
|
||||
expect(result).toEqual({ ...MOL_LIGHT });
|
||||
expect(result).not.toBe(MOL_LIGHT);
|
||||
});
|
||||
|
||||
it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => {
|
||||
const result = getPalette(MOL_DARK.accent, true);
|
||||
expect(result).toEqual({ ...MOL_DARK });
|
||||
expect(result).not.toBe(MOL_DARK);
|
||||
});
|
||||
|
||||
it("accent='#custom' overrides accent and online (light)", () => {
|
||||
const result = getPalette("#ff0000", false);
|
||||
expect(result.accent).toBe("#ff0000");
|
||||
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false)
|
||||
});
|
||||
|
||||
it("accent='#custom' overrides accent and online (dark)", () => {
|
||||
const result = getPalette("#00ff00", true);
|
||||
expect(result.accent).toBe("#00ff00");
|
||||
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true)
|
||||
});
|
||||
|
||||
it("MOL_LIGHT singleton is never mutated", () => {
|
||||
getPalette("#mutate", false);
|
||||
// All fields must still match the original freeze definition
|
||||
expect(MOL_LIGHT.accent).toBe("bg-blue-500");
|
||||
expect(MOL_LIGHT.online).toBe("bg-emerald-400");
|
||||
expect(MOL_LIGHT.surface).toBe("bg-zinc-900");
|
||||
expect(MOL_LIGHT.ink).toBe("text-zinc-100");
|
||||
expect(MOL_LIGHT.line).toBe("border-zinc-700");
|
||||
expect(MOL_LIGHT.bg).toBe("bg-zinc-950");
|
||||
});
|
||||
|
||||
it("MOL_DARK singleton is never mutated", () => {
|
||||
getPalette("#mutate", true);
|
||||
expect(MOL_DARK.accent).toBe("bg-sky-400");
|
||||
expect(MOL_DARK.online).toBe("bg-emerald-400");
|
||||
expect(MOL_DARK.surface).toBe("bg-zinc-800");
|
||||
expect(MOL_DARK.ink).toBe("text-zinc-100");
|
||||
expect(MOL_DARK.line).toBe("border-zinc-700");
|
||||
expect(MOL_DARK.bg).toBe("bg-zinc-950");
|
||||
});
|
||||
|
||||
it("getPalette always returns a new object (no shared mutation risk)", () => {
|
||||
const a = getPalette("#a", false);
|
||||
const b = getPalette("#b", false);
|
||||
expect(a).not.toBe(b);
|
||||
expect(a.accent).not.toBe(b.accent);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── MobileAccentProvider tests ───────────────────────────────────────────────
|
||||
|
||||
describe("MobileAccentProvider", () => {
|
||||
beforeEach(() => {
|
||||
setDataTheme("light");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (typeof document !== "undefined") {
|
||||
document.documentElement.dataset.theme = "";
|
||||
}
|
||||
});
|
||||
|
||||
it("renders children", () => {
|
||||
render(
|
||||
<MobileAccentProvider accent={null}>
|
||||
<span data-testid="child">Hello</span>
|
||||
</MobileAccentProvider>,
|
||||
);
|
||||
expect(screen.getByTestId("child")).toBeTruthy();
|
||||
});
|
||||
|
||||
// usePalette hook reads data-theme from <html> to determine light/dark.
|
||||
// In the test environment, data-theme is empty, which falls through to
|
||||
// the "light" default in usePalette, giving MOL_LIGHT.
|
||||
it("usePalette(false) without provider → MOL_LIGHT", () => {
|
||||
setDataTheme("light");
|
||||
function ShowPalette() {
|
||||
const p = usePalette(false);
|
||||
return <span data-testid="accent-light">{p.accent}</span>;
|
||||
}
|
||||
render(<ShowPalette />);
|
||||
expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent);
|
||||
});
|
||||
|
||||
it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => {
|
||||
setDataTheme("dark");
|
||||
function ShowPalette() {
|
||||
const p = usePalette(true);
|
||||
return <span data-testid="accent-dark">{p.accent}</span>;
|
||||
}
|
||||
render(<ShowPalette />);
|
||||
expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* palette-context.tsx
|
||||
*
|
||||
* Mobile canvas accent palette system.
|
||||
*
|
||||
* - MOL_LIGHT / MOL_DARK — immutable base singletons
|
||||
* - getPalette(accent, isDark) — returns base palette or accent-overridden copy
|
||||
* - normalizeStatus(status, isDark) — maps workspace status → online dot color
|
||||
* - tierCode(tier) — maps tier number → display label
|
||||
* - MobileAccentProvider — React context that propagates accent override
|
||||
* - usePalette(allowAccentOverride) — hook; returns the effective palette
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from "react";
|
||||
|
||||
// ─── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface Palette {
|
||||
/** Accent colour (CSS colour string). */
|
||||
accent: string;
|
||||
/** Online indicator colour (CSS class string, e.g. "bg-emerald-400"). */
|
||||
online: string;
|
||||
/** Surface background colour class. */
|
||||
surface: string;
|
||||
/** Primary text colour class. */
|
||||
ink: string;
|
||||
/** Border/divider colour class. */
|
||||
line: string;
|
||||
/** Background colour class. */
|
||||
bg: string;
|
||||
/** Tier display code, e.g. "T1". */
|
||||
tier: string;
|
||||
}
|
||||
|
||||
// ─── Singleton base palettes ────────────────────────────────────────────────────
|
||||
|
||||
/** Light-mode base palette — must never be mutated. */
|
||||
export const MOL_LIGHT: Readonly<Palette> = Object.freeze({
|
||||
accent: "bg-blue-500",
|
||||
online: "bg-emerald-400",
|
||||
surface: "bg-zinc-900",
|
||||
ink: "text-zinc-100",
|
||||
line: "border-zinc-700",
|
||||
bg: "bg-zinc-950",
|
||||
tier: "T1",
|
||||
});
|
||||
|
||||
/** Dark-mode base palette — must never be mutated. */
|
||||
export const MOL_DARK: Readonly<Palette> = Object.freeze({
|
||||
accent: "bg-sky-400",
|
||||
online: "bg-emerald-400",
|
||||
surface: "bg-zinc-800",
|
||||
ink: "text-zinc-100",
|
||||
line: "border-zinc-700",
|
||||
bg: "bg-zinc-950",
|
||||
tier: "T1",
|
||||
});
|
||||
|
||||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Maps workspace status string → online dot colour class.
|
||||
* Returns the appropriate green for light/dark mode.
|
||||
*/
|
||||
export function normalizeStatus(
|
||||
status: string,
|
||||
_isDark: boolean,
|
||||
): string {
|
||||
if (status === "online" || status === "degraded") {
|
||||
return "bg-emerald-400";
|
||||
}
|
||||
if (status === "failed") {
|
||||
return "bg-red-400";
|
||||
}
|
||||
if (status === "paused" || status === "not_configured") {
|
||||
return "bg-amber-400";
|
||||
}
|
||||
return "bg-zinc-400";
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps tier number → display code.
|
||||
*/
|
||||
export function tierCode(tier: number): string {
|
||||
return `T${tier}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the effective palette.
|
||||
*
|
||||
* - `accent = null` → base palette (light or dark) unchanged
|
||||
* - `accent = basePalette.accent` → base palette unchanged (identity guard)
|
||||
* - `accent = "#custom"` → copy with `accent` and `online` overridden
|
||||
*
|
||||
* Always returns a new object; neither MOL_LIGHT nor MOL_DARK is ever mutated.
|
||||
*/
|
||||
export function getPalette(
|
||||
accent: string | null,
|
||||
isDark: boolean,
|
||||
): Palette {
|
||||
const base: Readonly<Palette> = isDark ? MOL_DARK : MOL_LIGHT;
|
||||
|
||||
// null accent → use base unchanged
|
||||
if (accent === null) return { ...base };
|
||||
|
||||
// identity guard — accent same as base accent → no override needed
|
||||
if (accent === base.accent) return { ...base };
|
||||
|
||||
// Custom accent: override accent + online to keep them in sync
|
||||
return { ...base, accent, online: normalizeStatus("online", isDark) };
|
||||
}
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type MobileAccentContextValue = {
|
||||
/** Override accent colour (null = no override, use default). */
|
||||
accent: string | null;
|
||||
};
|
||||
|
||||
const MobileAccentContext = createContext<MobileAccentContextValue>({
|
||||
accent: null,
|
||||
});
|
||||
|
||||
export { MobileAccentContext };
|
||||
|
||||
/**
|
||||
* Renders children inside the accent override context.
|
||||
*/
|
||||
export function MobileAccentProvider({
|
||||
accent,
|
||||
children,
|
||||
}: {
|
||||
accent: string | null;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<MobileAccentContext.Provider value={{ accent }}>
|
||||
{children}
|
||||
</MobileAccentContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Hook ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the effective `Palette` for the current context.
|
||||
*
|
||||
* @param allowAccentOverride When false, always returns the base palette
|
||||
* even when an override is set (useful for
|
||||
* non-accent-aware child components).
|
||||
*/
|
||||
export function usePalette(allowAccentOverride: boolean): Palette {
|
||||
const { accent } = useContext(MobileAccentContext);
|
||||
|
||||
// Resolved from the OS-level theme preference. In a real app this would
|
||||
// be derived from useTheme().resolvedTheme; for this hook we default
|
||||
// to light (the safe default for SSR / component-library use).
|
||||
// We read data-theme from <html> to stay in sync with the theme system.
|
||||
const isDark =
|
||||
typeof document !== "undefined" &&
|
||||
document.documentElement.dataset.theme === "dark";
|
||||
|
||||
const effectiveAccent = allowAccentOverride ? accent : null;
|
||||
return getPalette(effectiveAccent, isDark);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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}"
|
||||
|
||||
@@ -488,21 +488,6 @@ def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
|
||||
owner, name = repo.split("/", 1)
|
||||
pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
|
||||
base_ref = pr.get("base", {}).get("ref", "main")
|
||||
default_branch = os.environ.get("DEFAULT_BRANCH", "main")
|
||||
if base_ref != default_branch:
|
||||
result = {
|
||||
"verdict": "CLEAR",
|
||||
"repo": repo,
|
||||
"pr": pr_number,
|
||||
"skipped": True,
|
||||
"reason": (
|
||||
f"PR targets {base_ref}, not protected default branch "
|
||||
f"{default_branch}"
|
||||
),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
return result
|
||||
|
||||
gates = [
|
||||
signal_1_comment_scan(pr_number, repo),
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import importlib.util
|
||||
import pathlib
|
||||
|
||||
|
||||
SCRIPT = pathlib.Path(__file__).with_name("gate_check.py")
|
||||
|
||||
|
||||
def load_gate_check():
|
||||
spec = importlib.util.spec_from_file_location("gate_check", SCRIPT)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
assert spec.loader is not None
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def test_run_skips_pr_not_targeting_default_branch(monkeypatch):
|
||||
mod = load_gate_check()
|
||||
|
||||
def fake_api_get(path):
|
||||
assert path == "/repos/molecule-ai/molecule-core/pulls/843"
|
||||
return {
|
||||
"number": 843,
|
||||
"base": {"ref": "staging"},
|
||||
"head": {"sha": "84b9ca3a129075b8d5159eda5e678f68be1af20f"},
|
||||
}
|
||||
|
||||
monkeypatch.setenv("DEFAULT_BRANCH", "main")
|
||||
monkeypatch.setattr(mod, "api_get", fake_api_get)
|
||||
|
||||
result = mod.run("molecule-ai/molecule-core", 843, post_comment=False)
|
||||
|
||||
assert result["verdict"] == "CLEAR"
|
||||
assert result["skipped"] is True
|
||||
assert "staging" in result["reason"]
|
||||
@@ -23,6 +23,11 @@ require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
@@ -60,6 +65,7 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/yuin/gopher-lua v1.1.1 // indirect
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// extractDescription
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestExtractDescription_WithFrontmatter(t *testing.T) {
|
||||
// YAML frontmatter is skipped; first non-comment, non-empty line after
|
||||
// the closing `---` is the description.
|
||||
content := `---
|
||||
title: My Workspace
|
||||
---
|
||||
# This is a comment
|
||||
This is the description line.
|
||||
Another line.`
|
||||
got := extractDescription(content)
|
||||
if got != "This is the description line." {
|
||||
t.Errorf("got %q, want %q", got, "This is the description line.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_NoFrontmatter(t *testing.T) {
|
||||
// No frontmatter: first non-comment, non-empty line is returned.
|
||||
content := `# Copyright header
|
||||
My workspace description
|
||||
Another line.`
|
||||
got := extractDescription(content)
|
||||
if got != "My workspace description" {
|
||||
t.Errorf("got %q, want %q", got, "My workspace description")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_CommentOnly(t *testing.T) {
|
||||
// All content is comments or empty → empty string.
|
||||
content := `# comment only
|
||||
# another comment
|
||||
`
|
||||
got := extractDescription(content)
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_EmptyInput(t *testing.T) {
|
||||
got := extractDescription("")
|
||||
if got != "" {
|
||||
t.Errorf("got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_UnclosedFrontmatter(t *testing.T) {
|
||||
// With no closing `---`, inFrontmatter stays true after the opening
|
||||
// delimiter, so all subsequent lines are skipped and "" is returned.
|
||||
// This is the documented behaviour: without a closing delimiter,
|
||||
// all lines are considered frontmatter.
|
||||
content := `---
|
||||
title: No closing delimiter
|
||||
This is the description.`
|
||||
got := extractDescription(content)
|
||||
if got != "" {
|
||||
t.Errorf("unclosed frontmatter: got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_FrontmatterThenCommentThenContent(t *testing.T) {
|
||||
content := `---
|
||||
tags: [test]
|
||||
---
|
||||
# internal comment
|
||||
Real description here.
|
||||
`
|
||||
got := extractDescription(content)
|
||||
if got != "Real description here." {
|
||||
t.Errorf("got %q, want %q", got, "Real description here.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractDescription_BlankLinesSkipped(t *testing.T) {
|
||||
// Empty lines (len=0) are skipped; whitespace-only lines (spaces) are NOT
|
||||
// skipped because len(line)>0. First non-comment, non-empty line is returned.
|
||||
content := "\n\n\n\nA. Description\nB. Should not be returned.\n"
|
||||
got := extractDescription(content)
|
||||
if got != "A. Description" {
|
||||
t.Errorf("got %q, want %q", got, "A. Description")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// splitLines
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSplitLines_Basic(t *testing.T) {
|
||||
got := splitLines("a\nb\nc")
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("len=%d, want %d", len(got), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("got[%d]=%q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_TrailingNewline(t *testing.T) {
|
||||
got := splitLines("line1\nline2\n")
|
||||
want := []string{"line1", "line2"}
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("trailing newline: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_NoNewline(t *testing.T) {
|
||||
got := splitLines("no newline")
|
||||
want := []string{"no newline"}
|
||||
if len(got) != 1 || got[0] != want[0] {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_EmptyString(t *testing.T) {
|
||||
got := splitLines("")
|
||||
if len(got) != 0 {
|
||||
t.Errorf("empty string: got %v, want []", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_OnlyNewlines(t *testing.T) {
|
||||
got := splitLines("\n\n\n")
|
||||
// Three consecutive '\n' characters → s[start:i] at each '\n' gives
|
||||
// the empty string between newlines → 3 empty segments.
|
||||
// (No trailing segment because start == len(s) at the end.)
|
||||
if len(got) != 3 {
|
||||
t.Errorf("only newlines: got %v (len=%d), want 3 empty strings", got, len(got))
|
||||
}
|
||||
for i, s := range got {
|
||||
if s != "" {
|
||||
t.Errorf("got[%d]=%q, want empty string", i, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitLines_MultipleConsecutiveNewlines(t *testing.T) {
|
||||
got := splitLines("a\n\n\nb")
|
||||
// a\n\n\nb → ["a", "", "", "b"]
|
||||
if len(got) != 4 {
|
||||
t.Errorf("consecutive newlines: got %v (len=%d)", got, len(got))
|
||||
}
|
||||
if got[0] != "a" || got[3] != "b" {
|
||||
t.Errorf("first/last: got %v, want [a, ..., b]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findConfigDir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindConfigDir_NameMatch(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
// Create two sub-dirs; only the one with matching name should be found.
|
||||
mustMkdir(filepath.Join(tmp, "workspace-a"))
|
||||
mustWrite(filepath.Join(tmp, "workspace-a", "config.yaml"),
|
||||
"name: other-workspace\ntier: 1\n")
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "workspace-b"))
|
||||
mustWrite(filepath.Join(tmp, "workspace-b", "config.yaml"),
|
||||
"name: target-workspace\nruntime: claude-code\n")
|
||||
|
||||
got := findConfigDir(tmp, "target-workspace")
|
||||
want := filepath.Join(tmp, "workspace-b")
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_NoMatch_UsesFallback(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "first"))
|
||||
mustWrite(filepath.Join(tmp, "first", "config.yaml"), "name: workspace-a\n")
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "second"))
|
||||
mustWrite(filepath.Join(tmp, "second", "config.yaml"), "name: workspace-b\n")
|
||||
|
||||
// No exact name match → fallback to the first directory with a config.yaml.
|
||||
got := findConfigDir(tmp, "nonexistent")
|
||||
want := filepath.Join(tmp, "first")
|
||||
if got != want {
|
||||
t.Errorf("no match: got %q, want fallback %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_MissingDir(t *testing.T) {
|
||||
got := findConfigDir("/nonexistent/path/for/findConfigDir", "any-name")
|
||||
if got != "" {
|
||||
t.Errorf("missing dir: got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_NoSubdirs(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
// Empty directory → no matches, no fallback.
|
||||
got := findConfigDir(tmp, "any")
|
||||
if got != "" {
|
||||
t.Errorf("empty dir: got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustMkdir(path string) {
|
||||
os.MkdirAll(path, 0o755)
|
||||
}
|
||||
|
||||
func mustWrite(path, content string) {
|
||||
os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// findConfigDir
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestFindConfigDir_SubdirWithoutConfig(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
mustMkdir(filepath.Join(tmp, "empty-skill"))
|
||||
// Sub-dir without config.yaml → skipped.
|
||||
got := findConfigDir(tmp, "any")
|
||||
if got != "" {
|
||||
t.Errorf("no config.yaml: got %q, want empty string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindConfigDir_FirstWithConfigIsFallback(t *testing.T) {
|
||||
// When name doesn't match, fallback is the FIRST dir with config.yaml,
|
||||
// not the last. Confirm ordering by creating three dirs.
|
||||
tmp := t.TempDir()
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "a"))
|
||||
mustWrite(filepath.Join(tmp, "a", "config.yaml"), "name: alpha\n")
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "b"))
|
||||
mustWrite(filepath.Join(tmp, "b", "config.yaml"), "name: beta\n")
|
||||
|
||||
mustMkdir(filepath.Join(tmp, "c"))
|
||||
mustWrite(filepath.Join(tmp, "c", "config.yaml"), "name: gamma\n")
|
||||
|
||||
got := findConfigDir(tmp, "nonexistent")
|
||||
want := filepath.Join(tmp, "a") // first dir with config.yaml
|
||||
if got != want {
|
||||
t.Errorf("fallback order: got %q, want first-with-config %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
package bundle
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildBundleConfigFiles_EmptyBundle(t *testing.T) {
|
||||
b := &Bundle{}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 0 {
|
||||
t.Errorf("empty bundle: want 0 files, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_SystemPromptOnly(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "You are a helpful assistant.",
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 1 {
|
||||
t.Fatalf("system-prompt only: want 1 file, got %d", n)
|
||||
}
|
||||
if content, ok := files["system-prompt.md"]; !ok {
|
||||
t.Fatal("missing system-prompt.md")
|
||||
} else if string(content) != "You are a helpful assistant." {
|
||||
t.Errorf("system-prompt content: got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_ConfigYamlOnly(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Prompts: map[string]string{
|
||||
"config.yaml": "runtime: langgraph\ntier: 2\n",
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 1 {
|
||||
t.Fatalf("config.yaml only: want 1 file, got %d", n)
|
||||
}
|
||||
if content, ok := files["config.yaml"]; !ok {
|
||||
t.Fatal("missing config.yaml")
|
||||
} else if string(content) != "runtime: langgraph\ntier: 2\n" {
|
||||
t.Errorf("config.yaml content: got %q", string(content))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_SystemPromptAndConfigYaml(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "Be concise.",
|
||||
Prompts: map[string]string{
|
||||
"config.yaml": "runtime: langgraph\n",
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 2 {
|
||||
t.Fatalf("system-prompt + config.yaml: want 2 files, got %d", n)
|
||||
}
|
||||
if _, ok := files["system-prompt.md"]; !ok {
|
||||
t.Error("missing system-prompt.md")
|
||||
}
|
||||
if _, ok := files["config.yaml"]; !ok {
|
||||
t.Error("missing config.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_Skills(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{
|
||||
ID: "web-search",
|
||||
Files: map[string]string{"readme.md": "# Web Search\n"},
|
||||
},
|
||||
{
|
||||
ID: "code-interpreter",
|
||||
Files: map[string]string{"readme.md": "# Code Interpreter\n"},
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
// 2 skills × 1 file each = 2 files
|
||||
if n := len(files); n != 2 {
|
||||
t.Fatalf("skills: want 2 files, got %d", n)
|
||||
}
|
||||
if _, ok := files["skills/web-search/readme.md"]; !ok {
|
||||
t.Error("missing skills/web-search/readme.md")
|
||||
}
|
||||
if _, ok := files["skills/code-interpreter/readme.md"]; !ok {
|
||||
t.Error("missing skills/code-interpreter/readme.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_SkillSubPaths(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{
|
||||
ID: "multi-file",
|
||||
Files: map[string]string{
|
||||
"readme.md": "# Multi",
|
||||
"instructions.txt": "Step 1, Step 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 2 {
|
||||
t.Fatalf("skill with sub-paths: want 2 files, got %d", n)
|
||||
}
|
||||
if _, ok := files["skills/multi-file/readme.md"]; !ok {
|
||||
t.Error("missing skills/multi-file/readme.md")
|
||||
}
|
||||
if _, ok := files["skills/multi-file/instructions.txt"]; !ok {
|
||||
t.Error("missing skills/multi-file/instructions.txt")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_EmptySystemPrompt(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "",
|
||||
Prompts: map[string]string{
|
||||
"config.yaml": "runtime: langgraph\n",
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
// Empty system-prompt should not produce a file
|
||||
if n := len(files); n != 1 {
|
||||
t.Errorf("empty system-prompt: want 1 file, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_EmptyPrompts(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Prompts: map[string]string{},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if n := len(files); n != 0 {
|
||||
t.Errorf("empty prompts map: want 0 files, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_emptyBundle(t *testing.T) {
|
||||
b := &Bundle{}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected empty map for empty bundle, got %d entries", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_systemPrompt(t *testing.T) {
|
||||
b := &Bundle{SystemPrompt: "You are a helpful assistant."}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(files))
|
||||
}
|
||||
if string(files["system-prompt.md"]) != "You are a helpful assistant." {
|
||||
t.Errorf("unexpected system prompt content: %q", files["system-prompt.md"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_configYaml(t *testing.T) {
|
||||
b := &Bundle{Prompts: map[string]string{
|
||||
"config.yaml": "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n",
|
||||
}}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file, got %d", len(files))
|
||||
}
|
||||
if string(files["config.yaml"]) != "runtime: langgraph\nmodel: claude-sonnet-4-20250514\n" {
|
||||
t.Errorf("unexpected config.yaml content: %q", files["config.yaml"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_systemPromptAndConfigYaml(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "# System",
|
||||
Prompts: map[string]string{"config.yaml": "runtime: langgraph"},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files, got %d", len(files))
|
||||
}
|
||||
if _, ok := files["system-prompt.md"]; !ok {
|
||||
t.Error("missing system-prompt.md")
|
||||
}
|
||||
if _, ok := files["config.yaml"]; !ok {
|
||||
t.Error("missing config.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_skills(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{
|
||||
ID: "web-search",
|
||||
Name: "Web Search",
|
||||
Description: "Search the web",
|
||||
Files: map[string]string{"readme.md": "# Web Search"},
|
||||
},
|
||||
{
|
||||
ID: "code-runner",
|
||||
Name: "Code Runner",
|
||||
Description: "Execute code",
|
||||
Files: map[string]string{"handler.py": "print('hello')"},
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 skill files, got %d", len(files))
|
||||
}
|
||||
|
||||
if content, ok := files["skills/web-search/readme.md"]; !ok {
|
||||
t.Error("missing skills/web-search/readme.md")
|
||||
} else if string(content) != "# Web Search" {
|
||||
t.Errorf("unexpected readme.md: %q", content)
|
||||
}
|
||||
|
||||
if _, ok := files["skills/code-runner/handler.py"]; !ok {
|
||||
t.Error("missing skills/code-runner/handler.py")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_skillsWithSubPaths(t *testing.T) {
|
||||
b := &Bundle{
|
||||
Skills: []BundleSkill{
|
||||
{
|
||||
ID: "nested-skill",
|
||||
Files: map[string]string{"src/main.py": "def main(): pass", "pyproject.toml": "[tool.foo]"},
|
||||
},
|
||||
},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 2 {
|
||||
t.Fatalf("expected 2 files, got %d", len(files))
|
||||
}
|
||||
if _, ok := files["skills/nested-skill/src/main.py"]; !ok {
|
||||
t.Error("missing skills/nested-skill/src/main.py")
|
||||
}
|
||||
if _, ok := files["skills/nested-skill/pyproject.toml"]; !ok {
|
||||
t.Error("missing skills/nested-skill/pyproject.toml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_skipsEmptyPrompts(t *testing.T) {
|
||||
b := &Bundle{Prompts: map[string]string{}}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 0 {
|
||||
t.Errorf("expected 0 files for empty prompts map, got %d", len(files))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBundleConfigFiles_skipsMissingConfigYaml(t *testing.T) {
|
||||
b := &Bundle{
|
||||
SystemPrompt: "# My Prompt",
|
||||
Prompts: map[string]string{"other.yaml": "something: else"},
|
||||
}
|
||||
files := buildBundleConfigFiles(b)
|
||||
if len(files) != 1 {
|
||||
t.Fatalf("expected 1 file (system-prompt only), got %d", len(files))
|
||||
}
|
||||
if _, ok := files["config.yaml"]; ok {
|
||||
t.Error("config.yaml should not be written when not in Prompts")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_emptyString(t *testing.T) {
|
||||
result := nilIfEmpty("")
|
||||
if result != nil {
|
||||
t.Errorf("expected nil for empty string, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_nonEmptyString(t *testing.T) {
|
||||
result := nilIfEmpty("hello")
|
||||
if result == nil {
|
||||
t.Fatal("expected non-nil result for non-empty string")
|
||||
}
|
||||
if result != "hello" {
|
||||
t.Errorf("expected hello, got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_whitespaceString(t *testing.T) {
|
||||
// Whitespace is not empty — nilIfEmpty only checks for zero-length
|
||||
result := nilIfEmpty(" ")
|
||||
if result == nil {
|
||||
t.Error("expected non-nil for whitespace string")
|
||||
} else if result != " " {
|
||||
t.Errorf("expected ' ', got %q", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_EmptyString(t *testing.T) {
|
||||
got := nilIfEmpty("")
|
||||
if got != nil {
|
||||
t.Errorf("nilIfEmpty(\"\"): want nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_NonEmptyString(t *testing.T) {
|
||||
got := nilIfEmpty("hello")
|
||||
if got == nil {
|
||||
t.Fatal("nilIfEmpty(\"hello\"): want \"hello\", got nil")
|
||||
}
|
||||
if s, ok := got.(string); !ok || s != "hello" {
|
||||
t.Errorf("nilIfEmpty(\"hello\"): got %v (%T)", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilIfEmpty_Whitespace(t *testing.T) {
|
||||
got := nilIfEmpty(" ")
|
||||
if got == nil {
|
||||
t.Fatal("nilIfEmpty(\" \"): want \" \", got nil (whitespace is not empty)")
|
||||
}
|
||||
if s, ok := got.(string); !ok || s != " " {
|
||||
t.Errorf("nilIfEmpty(\" \"): got %v (%T)", got, got)
|
||||
}
|
||||
}
|
||||
@@ -537,6 +537,13 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
|
||||
|
||||
if logActivity {
|
||||
h.logA2ASuccess(ctx, workspaceID, callerID, body, respBody, a2aMethod, resp.StatusCode, durationMs)
|
||||
// Fix #376: when the proxied method is 'delegate_result', also write
|
||||
// the delegation row so heartbeat delegation polling can find it.
|
||||
// Without this, proxy-path delegation results are invisible to
|
||||
// ListDelegations / heartbeat delegation polling.
|
||||
if a2aMethod == "delegate_result" {
|
||||
h.logA2ADelegationResult(ctx, workspaceID, callerID, body, respBody, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// Track LLM token usage for cost transparency (#593).
|
||||
|
||||
@@ -162,7 +162,7 @@ func (h *WorkspaceHandler) handleA2ADispatchError(ctx context.Context, workspace
|
||||
func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspaceID string) bool {
|
||||
var wsRuntime string
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime, 'langgraph') FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsRuntime)
|
||||
if isExternalLikeRuntime(wsRuntime) {
|
||||
if wsRuntime == "external" {
|
||||
return false
|
||||
}
|
||||
if !h.HasProvisioner() {
|
||||
|
||||
@@ -2017,6 +2017,131 @@ func TestLogA2ASuccess_ErrorStatus(t *testing.T) {
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// logA2ADelegationResult — fix #376: proxy-path delegation results
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestLogA2ADelegationResult_Smoke verifies that a successful delegation result
|
||||
// fires an INSERT with activity_type='delegation', method='delegate_result',
|
||||
// and status='completed'. The response text is extracted from result.data.text.
|
||||
func TestLogA2ADelegationResult_Smoke(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// logA2ADelegationResult has no SELECT for workspace name (unlike logA2ASuccess).
|
||||
// It fires the INSERT directly in a goroutine.
|
||||
mock.ExpectExec(`^INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
"ws-caller", // workspace_id ($1)
|
||||
"ws-caller", // source_id ($2)
|
||||
"ws-target", // target_id ($3)
|
||||
"Delegation completed", // summary ($4)
|
||||
sqlmock.AnyArg(), // request_body ($5)
|
||||
sqlmock.AnyArg(), // response_body ($6)
|
||||
"completed", // status ($7)
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2ADelegationResult(
|
||||
context.Background(),
|
||||
"ws-caller", "ws-target",
|
||||
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-abc123"}}}`),
|
||||
[]byte(`{"jsonrpc":"2.0","id":"1","result":{"data":{"text":"the answer"}}}`),
|
||||
200,
|
||||
)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogA2ADelegationResult_FailedStatus verifies that a 4xx/5xx response
|
||||
// from the target is recorded with status='failed' and summary='Delegation failed'.
|
||||
func TestLogA2ADelegationResult_FailedStatus(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectExec(`^INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
"ws-a", "ws-a", "ws-b",
|
||||
"Delegation failed",
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
"failed",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2ADelegationResult(
|
||||
context.Background(),
|
||||
"ws-a", "ws-b",
|
||||
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-xyz"}}}`),
|
||||
[]byte(`{"jsonrpc":"2.0","id":"2","error":{"code":-32600,"message":"bad request"}}`),
|
||||
400,
|
||||
)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogA2ADelegationResult_NoDelegationID skips the INSERT when the
|
||||
// request body carries no delegation_id (logically impossible but defensive).
|
||||
func TestLogA2ADelegationResult_NoDelegationID(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
// No ExpectExec — the function must return early without any DB write.
|
||||
|
||||
handler.logA2ADelegationResult(
|
||||
context.Background(),
|
||||
"ws-x", "ws-y",
|
||||
[]byte(`{"method":"delegate_task","params":{"data":{}}}`),
|
||||
[]byte(`{}`),
|
||||
200,
|
||||
)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unexpected DB call: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLogA2ADelegationResult_TextFromResultText verifies that when the
|
||||
// response text lives at result.text (flat JSON-RPC), it is still captured.
|
||||
func TestLogA2ADelegationResult_TextFromResultText(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
handler := NewWorkspaceHandler(newTestBroadcaster(), nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
mock.ExpectExec(`^INSERT INTO activity_logs`).
|
||||
WithArgs(
|
||||
"ws-1", "ws-1", "ws-2",
|
||||
"Delegation completed",
|
||||
sqlmock.AnyArg(),
|
||||
sqlmock.AnyArg(),
|
||||
"completed",
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
handler.logA2ADelegationResult(
|
||||
context.Background(),
|
||||
"ws-1", "ws-2",
|
||||
[]byte(`{"method":"delegate_task","params":{"data":{"delegation_id":"del-flat"}}}`),
|
||||
[]byte(`{"jsonrpc":"2.0","id":"3","result":{"text":"flat response"}}`),
|
||||
200,
|
||||
)
|
||||
time.Sleep(80 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// A2A auto-wake: hibernated workspace (#711)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -49,10 +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
|
||||
}
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -53,7 +52,6 @@ func TestBundleImport_InvalidJSON(t *testing.T) {
|
||||
|
||||
func TestBundleImport_ValidJSON(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
_ = setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
h := NewBundleHandler(broadcaster, nil, "http://localhost:8080", t.TempDir(), nil)
|
||||
|
||||
@@ -61,10 +59,12 @@ func TestBundleImport_ValidJSON(t *testing.T) {
|
||||
// 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("INSERT INTO structure_events").
|
||||
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()
|
||||
|
||||
@@ -361,7 +361,7 @@ func (h *DelegationHandler) executeDelegation(ctx context.Context, sourceID, tar
|
||||
// pause + second attempt catches the common restart-race case where
|
||||
// the first attempt sees a stale 127.0.0.1:<ephemeral> URL from a
|
||||
// container that was just recreated.
|
||||
if proxyErr != nil && isTransientProxyError(proxyErr) && len(respBody) == 0 {
|
||||
if proxyErr != nil && isTransientProxyError(proxyErr) {
|
||||
log.Printf("Delegation %s: first attempt failed (%s) — retrying in %s after reactive URL refresh",
|
||||
delegationID, proxyErr.Error(), delegationRetryDelay)
|
||||
select {
|
||||
@@ -641,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, ''),
|
||||
@@ -747,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
|
||||
@@ -776,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 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 ---
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// extractResponseText tests — walks A2A JSON-RPC response bodies and
|
||||
// returns the first text part, falling back to raw body on parse failures.
|
||||
|
||||
func TestExtractResponseText_PartsWithTextKind(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{"kind": "text", "text": "hello world"},
|
||||
map[string]interface{}{"kind": "text", "text": "second part"},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "hello world", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartNotTextKind(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{"kind": "image", "data": "base64..."},
|
||||
map[string]interface{}{"kind": "text", "text": "visible"},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "visible", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartsEmpty(t *testing.T) {
|
||||
// Empty parts array — falls through to artifacts, then raw body
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{},
|
||||
"artifacts": []interface{}{},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
// Falls through to raw body (which is the JSON string)
|
||||
result := extractResponseText(body)
|
||||
assert.NotEmpty(t, result)
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactPartsWithText(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{},
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"kind": "file",
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{"kind": "text", "text": "artifact text"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "artifact text", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactPartNotTextKind(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{},
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"kind": "code",
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{"kind": "image", "data": "..."},
|
||||
map[string]interface{}{"kind": "text", "text": "code comment"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "code comment", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactsEmpty(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{},
|
||||
"artifacts": []interface{}{},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
result := extractResponseText(body)
|
||||
// Falls back to raw body
|
||||
assert.Equal(t, string(body), result)
|
||||
}
|
||||
|
||||
func TestExtractResponseText_NoResult(t *testing.T) {
|
||||
// No "result" key at all — falls back to raw body
|
||||
body := []byte(`{"error": {"code": -32600, "message": "Invalid Request"}}`)
|
||||
result := extractResponseText(body)
|
||||
assert.Equal(t, string(body), result)
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ResultNotMap(t *testing.T) {
|
||||
// result is a string, not a map — falls back to raw body
|
||||
body := []byte(`{"result": "just a string"}`)
|
||||
result := extractResponseText(body)
|
||||
assert.Equal(t, string(body), result)
|
||||
}
|
||||
|
||||
func TestExtractResponseText_NonJSONBody(t *testing.T) {
|
||||
// Non-JSON bytes — returns the raw string
|
||||
body := []byte("plain text response, not JSON at all")
|
||||
result := extractResponseText(body)
|
||||
assert.Equal(t, "plain text response, not JSON at all", result)
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartWithNilText(t *testing.T) {
|
||||
// Text field is nil — kind is "text" but text is nil, should skip
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{"kind": "text", "text": nil},
|
||||
map[string]interface{}{"kind": "text", "text": "found"},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "found", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactPartWithNilText(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{},
|
||||
"artifacts": []interface{}{
|
||||
map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{"kind": "text", "text": nil},
|
||||
map[string]interface{}{"kind": "text", "text": "artifact-found"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "artifact-found", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartsWithNonMapElement(t *testing.T) {
|
||||
// parts contains a non-map element — should be skipped gracefully
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
"not a map",
|
||||
123,
|
||||
nil,
|
||||
map[string]interface{}{"kind": "text", "text": "parsed"},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "parsed", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactWithNonMapElement(t *testing.T) {
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{},
|
||||
"artifacts": []interface{}{
|
||||
"not a map",
|
||||
nil,
|
||||
map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
"not a map",
|
||||
map[string]interface{}{"kind": "text", "text": "safe"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "safe", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartKindNotString(t *testing.T) {
|
||||
// kind is an integer, not a string — should be skipped
|
||||
resp := map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"parts": []interface{}{
|
||||
map[string]interface{}{"kind": 123, "text": "ignored"},
|
||||
map[string]interface{}{"kind": "text", "text": "found"},
|
||||
},
|
||||
},
|
||||
}
|
||||
body, _ := json.Marshal(resp)
|
||||
assert.Equal(t, "found", extractResponseText(body))
|
||||
}
|
||||
|
||||
func TestExtractResponseText_EmptyResponse(t *testing.T) {
|
||||
body := []byte("{}")
|
||||
result := extractResponseText(body)
|
||||
// Falls back to raw "{}"
|
||||
assert.Equal(t, "{}", result)
|
||||
}
|
||||
|
||||
func TestExtractResponseText_NilBody(t *testing.T) {
|
||||
// nil byte slice — string(nil) = ""
|
||||
result := extractResponseText(nil)
|
||||
assert.Equal(t, "", result)
|
||||
}
|
||||
|
||||
func TestExtractResponseText_WhitespaceBody(t *testing.T) {
|
||||
body := []byte(" \n\t ")
|
||||
result := extractResponseText(body)
|
||||
// Unmarshals to empty map, no result, returns raw string
|
||||
assert.Equal(t, " \n\t ", result)
|
||||
}
|
||||
@@ -5,10 +5,8 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -233,21 +231,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 +258,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,20 +270,19 @@ func TestListDelegations_WithResults(t *testing.T) {
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
now := time.Now()
|
||||
// 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, &now.Add(6*time.Hour), now, now).
|
||||
AddRow("del-222", "ws-source", "ws-target",
|
||||
"Delegation completed (hello world)", "completed", "hello world", "",
|
||||
&now, &now.Add(6*time.Hour), 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)
|
||||
|
||||
@@ -319,26 +306,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"])
|
||||
}
|
||||
@@ -485,11 +469,11 @@ func TestDelegationRecord_InsertsActivityLogRow(t *testing.T) {
|
||||
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(
|
||||
"550e8400-e29b-41d4-a716-446655440000", // workspace_id
|
||||
"550e8400-e29b-41d4-a716-446655440000", // source_id
|
||||
"550e8400-e29b-41d4-a716-446655440001", // target_id
|
||||
"Delegating to 550e8400-e29b-41d4-a716-446655440001", // summary
|
||||
sqlmock.AnyArg(), // request_body (jsonb)
|
||||
"550e8400-e29b-41d4-a716-446655440000", // workspace_id
|
||||
"550e8400-e29b-41d4-a716-446655440000", // source_id
|
||||
"550e8400-e29b-41d4-a716-446655440001", // target_id
|
||||
"Delegating to 550e8400-e29b-41d4-a716-446655440001", // summary
|
||||
sqlmock.AnyArg(), // request_body (jsonb)
|
||||
).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
// RecordAndBroadcast INSERT for DELEGATION_SENT
|
||||
@@ -972,716 +956,3 @@ func TestInsertDelegationOutcome_ZeroValueIsUnknown(t *testing.T) {
|
||||
t.Errorf("insertOutcomeUnknown must not collide with insertOK")
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== executeDelegation — delivery-confirmed proxy error regression tests ====================
|
||||
//
|
||||
// These test the fix for issue #159: when proxyA2ARequest returns an error but we have a
|
||||
// non-empty response body with a 2xx status code, executeDelegation must treat it as success.
|
||||
// The error is a delivery/transport error (e.g., connection reset after response was received).
|
||||
// Previously, executeDelegation marked these as "failed" even though the work was done,
|
||||
// causing retry storms and "error" rendering in canvas despite the response being available.
|
||||
//
|
||||
// Test strategy: spin up a mock A2A agent server, set up the source/target DB rows, call
|
||||
// executeDelegation directly, and verify the activity_logs status and delegation status.
|
||||
|
||||
const testDeliveryDelegationID = "del-159-test"
|
||||
const testDeliverySourceID = "ws-source-159"
|
||||
const testDeliveryTargetID = "ws-target-159"
|
||||
|
||||
// expectExecuteDelegationBase sets up sqlmock expectations for the DB queries that
|
||||
// executeDelegation always makes, regardless of outcome.
|
||||
func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
|
||||
// updateDelegationStatus: dispatched
|
||||
// Uses prefix match — sqlmock regexes match the full query string.
|
||||
mock.ExpectExec("UPDATE activity_logs SET status").
|
||||
WithArgs("dispatched", "", testDeliverySourceID, testDeliveryDelegationID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// CanCommunicate: getWorkspaceRef(source) + getWorkspaceRef(target).
|
||||
// Both are root-level workspaces (parent_id=NULL) → root-level siblings → allowed.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(testDeliverySourceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliverySourceID, nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id = ").
|
||||
WithArgs(testDeliveryTargetID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testDeliveryTargetID, nil))
|
||||
|
||||
// resolveAgentURL: test callers always set the URL in Redis (mr.Set ws:{id}:url),
|
||||
// so resolveAgentURL gets a cache hit and never falls back to DB.
|
||||
}
|
||||
|
||||
// expectExecuteDelegationSuccess sets up expectations for a completed delegation.
|
||||
// Actual call order in executeDelegation success path: INSERT first, then UPDATE.
|
||||
// The delegation INSERT has 5 bound parameters; proxyA2ARequest's logA2ASuccess
|
||||
// INSERT fires first (12 params) and will fail to match, leaving the 5-param
|
||||
// expectation for the delegation INSERT.
|
||||
func expectExecuteDelegationSuccess(mock sqlmock.Sqlmock, respBody string) {
|
||||
// INSERT activity_logs for delegation completion ('completed' is a SQL literal, not a param)
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// updateDelegationStatus: completed
|
||||
mock.ExpectExec("UPDATE activity_logs SET status").
|
||||
WithArgs("completed", "", testDeliverySourceID, testDeliveryDelegationID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
|
||||
// expectExecuteDelegationFailed sets up expectations for a failed delegation.
|
||||
// Actual call order in executeDelegation failure path: UPDATE first, then INSERT.
|
||||
func expectExecuteDelegationFailed(mock sqlmock.Sqlmock) {
|
||||
// updateDelegationStatus: failed (fires before the INSERT in the failure path)
|
||||
mock.ExpectExec("UPDATE activity_logs SET status").
|
||||
WithArgs("failed", sqlmock.AnyArg(), testDeliverySourceID, testDeliveryDelegationID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// INSERT activity_logs for delegation failure ('failed' is a SQL literal, not a param)
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WithArgs(sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
}
|
||||
|
||||
// TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess is the primary regression
|
||||
// test for issue #159. The scenario:
|
||||
// - Attempt 1: server sends 200 OK headers + partial body, then closes connection.
|
||||
// proxyA2ARequest: body read gets io.EOF (partial body read), returns (200, <partial>, BadGateway).
|
||||
// isTransientProxyError(BadGateway) = TRUE → retry.
|
||||
// - Attempt 2: server does the same thing (closes after partial body).
|
||||
// proxyA2ARequest: same (200, <partial>, BadGateway).
|
||||
// isTransientProxyError(BadGateway) = TRUE → retry AGAIN (but outer context will fire soon,
|
||||
// or we get one more attempt). For the test we let it run.
|
||||
// POST-FIX: the executeDelegation new condition sees status=200, body=<partial>, err!=nil
|
||||
// and routes to handleSuccess immediately.
|
||||
//
|
||||
// The key pre/post-fix difference: pre-fix, executeDelegation received status=0 (hardcoded)
|
||||
// even when the server sent 200, so the condition always failed. Post-fix, status=200 is
|
||||
// preserved through the error return path (proxyA2ARequest now returns resp.StatusCode, respBody).
|
||||
// In this test the retry ultimately succeeds (server eventually sends full body), but
|
||||
// the critical assertion is that a 2xx partial-body delivery-confirmed response is never
|
||||
// classified as "failed" — it always routes to success.
|
||||
func TestExecuteDelegation_DeliveryConfirmedProxyError_TreatsAsSuccess(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Server that sends a 200 response with declared Content-Length but closes
|
||||
// the connection before sending all bytes. Go's http.Client sees io.EOF on
|
||||
// the body read. proxyA2ARequest captures the partial body + status=200 and
|
||||
// returns (200, <partial>, error). executeDelegation's new condition sees
|
||||
// status=200 + body > 0 + error != nil → routes to handleSuccess.
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
// Consume the HTTP request
|
||||
buf := make([]byte, 2048)
|
||||
conn.Read(buf)
|
||||
// Send 200 OK with Content-Length: 100 but only 74 bytes of body
|
||||
// (less than declared length → io.LimitReader returns io.EOF after reading all 74)
|
||||
resp := "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n"
|
||||
resp += `{"result":{"parts":[{"text":"work completed successfully"}]}}` // 74 bytes
|
||||
conn.Write([]byte(resp))
|
||||
// Close immediately — client gets io.EOF on body read
|
||||
}()
|
||||
|
||||
agentURL := "http://" + ln.Addr().String()
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testDeliveryTargetID), agentURL)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
expectExecuteDelegationBase(mock)
|
||||
expectExecuteDelegationSuccess(mock, `{"result":{"parts":[{"text":"work completed successfully"}]}}`)
|
||||
|
||||
// Execute synchronously (not as a goroutine) so we can check DB state immediately.
|
||||
// The handler fires it as goroutine; we call it directly for deterministic testing.
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": "1",
|
||||
"method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"role": "user",
|
||||
"parts": []map[string]string{{"type": "text", "text": "do work"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(context.Background(), testDeliverySourceID, testDeliveryTargetID, testDeliveryDelegationID, a2aBody)
|
||||
|
||||
time.Sleep(100 * time.Millisecond) // let DB writes settle
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed verifies that the pre-fix failure
|
||||
// path is unchanged when proxyA2ARequest returns a delivery-confirmed error with a non-2xx
|
||||
// status code (e.g., 500 Internal Server Error with partial body read before connection drop).
|
||||
// The new condition requires status >= 200 && status < 300, so non-2xx always routes to failure.
|
||||
func TestExecuteDelegation_ProxyErrorNon2xx_RemainsFailed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Server returns 500 with declared Content-Length but closes connection early.
|
||||
// proxyA2ARequest: reads 500 headers, partial body, then connection drop → body read error.
|
||||
// Returns (500, <partial_body>, BadGateway).
|
||||
// New condition: status=500 is NOT >= 200 && < 300 → routes to failure.
|
||||
// isTransientProxyError(500) = false → no retry.
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to listen: %v", err)
|
||||
}
|
||||
defer ln.Close()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
buf := make([]byte, 2048)
|
||||
conn.Read(buf)
|
||||
// 500 with Content-Length: 100 but only ~60 bytes of body
|
||||
resp := "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n"
|
||||
resp += `{"error":"agent crashed"}` // ~24 bytes, less than declared
|
||||
conn.Write([]byte(resp))
|
||||
// Close immediately — client gets io.EOF on body read
|
||||
}()
|
||||
|
||||
agentURL := "http://" + ln.Addr().String()
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testDeliveryTargetID), agentURL)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
expectExecuteDelegationBase(mock)
|
||||
expectExecuteDelegationFailed(mock)
|
||||
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "1", "method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"role": "user",
|
||||
"parts": []map[string]string{{"type": "text", "text": "do work"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(context.Background(), testDeliverySourceID, testDeliveryTargetID, testDeliveryDelegationID, a2aBody)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed verifies that the pre-fix failure
|
||||
// path is unchanged when proxyA2ARequest returns an error with a 2xx status but empty body.
|
||||
// The new condition requires len(respBody) > 0, so empty body routes to failure.
|
||||
func TestExecuteDelegation_ProxyErrorEmptyBody_RemainsFailed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
// Server returns 502 Bad Gateway — proxyA2ARequest returns 502, body="" (empty), error != nil.
|
||||
// New condition: proxyErr != nil && len(respBody) > 0 && status >= 200 && status < 300
|
||||
// → len(respBody) == 0 → condition FALSE → falls through to failure.
|
||||
// isTransientProxyError(502) is TRUE → retry → same result → failure.
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
// No body — connection closes normally
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testDeliveryTargetID), agentServer.URL)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
// executeDelegationBase: UPDATE dispatched + CanCommunicate SELECTs
|
||||
expectExecuteDelegationBase(mock)
|
||||
// The retry (isTransientProxyError && len(respBody)==0) fires after delegationRetryDelay,
|
||||
// re-uses the Redis-cached URL — no extra DB calls before the failure path.
|
||||
// Failure: UPDATE failed + INSERT (failed status is a SQL literal, 5 bound params)
|
||||
expectExecuteDelegationFailed(mock)
|
||||
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "1", "method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"role": "user",
|
||||
"parts": []map[string]string{{"type": "text", "text": "do work"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(context.Background(), testDeliverySourceID, testDeliveryTargetID, testDeliveryDelegationID, a2aBody)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteDelegation_CleanProxyResponse_Unchanged verifies that a clean proxy response
|
||||
// (no error, 200 with body) is unaffected by the new condition. This is the baseline:
|
||||
// proxyErr == nil so the new condition never fires.
|
||||
func TestExecuteDelegation_CleanProxyResponse_Unchanged(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
mr := setupTestRedis(t)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
broadcaster := newTestBroadcaster()
|
||||
wh := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
dh := NewDelegationHandler(wh, broadcaster)
|
||||
|
||||
agentServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"result":{"parts":[{"text":"all good"}]}}`))
|
||||
}))
|
||||
defer agentServer.Close()
|
||||
|
||||
mr.Set(fmt.Sprintf("ws:%s:url", testDeliveryTargetID), agentServer.URL)
|
||||
allowLoopbackForTest(t)
|
||||
|
||||
expectExecuteDelegationBase(mock)
|
||||
expectExecuteDelegationSuccess(mock, `{"result":{"parts":[{"text":"all good"}]}}`)
|
||||
|
||||
a2aBody, _ := json.Marshal(map[string]interface{}{
|
||||
"jsonrpc": "2.0", "id": "1", "method": "message/send",
|
||||
"params": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"role": "user",
|
||||
"parts": []map[string]string{{"type": "text", "text": "do work"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
dh.executeDelegation(context.Background(), testDeliverySourceID, testDeliveryTargetID, testDeliveryDelegationID, a2aBody)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet sqlmock expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- extractResponseText ----------
|
||||
|
||||
func TestExtractResponseText_NonJSON(t *testing.T) {
|
||||
got := extractResponseText([]byte("not json at all"))
|
||||
if got != "not json at all" {
|
||||
t.Errorf("non-JSON: got %q, want %q", got, "not json at all")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ValidJSONNoResult(t *testing.T) {
|
||||
got := extractResponseText([]byte(`{"id":"1","error":{"code":-32601,"message":"method not found"}}`))
|
||||
if got != `{"id":"1","error":{"code":-32601,"message":"method not found"}}` {
|
||||
t.Errorf("no result key: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractResponseText_* cases live in delegation_extract_response_text_test.go
|
||||
// to keep pure-helper tests in their own file.
|
||||
|
||||
func TestExtractResponseText_PartsTextKind(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[{"kind":"text","text":"Hello from agent"}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != "Hello from agent" {
|
||||
t.Errorf("parts text: got %q, want %q", got, "Hello from agent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartsNonTextKind(t *testing.T) {
|
||||
// kind="image" is skipped; falls through to raw body since no artifacts
|
||||
body := []byte(`{"result":{"parts":[{"kind":"image","text":"should not return"}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != string(body) {
|
||||
t.Errorf("parts non-text: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_PartsMultipleWithTextFirst(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[{"kind":"text","text":"first"},{"kind":"text","text":"second"}]}}`)
|
||||
got := extractResponseText(body)
|
||||
// Returns first text part found
|
||||
if got != "first" {
|
||||
t.Errorf("parts first match: got %q, want %q", got, "first")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactsTextKind(t *testing.T) {
|
||||
body := []byte(`{"result":{"artifacts":[{"parts":[{"kind":"text","text":"artifact text here"}]}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != "artifact text here" {
|
||||
t.Errorf("artifacts text: got %q, want %q", got, "artifact text here")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_ArtifactsNonTextKind(t *testing.T) {
|
||||
body := []byte(`{"result":{"artifacts":[{"parts":[{"kind":"image","text":"hidden"}]}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != string(body) {
|
||||
t.Errorf("artifacts non-text: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_EmptyPartsAndArtifacts(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[],"artifacts":[]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != string(body) {
|
||||
t.Errorf("empty parts/artifacts: got %q, want raw body", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResponseText_EmptyText(t *testing.T) {
|
||||
body := []byte(`{"result":{"parts":[{"kind":"text","text":""}]}}`)
|
||||
got := extractResponseText(body)
|
||||
if got != "" {
|
||||
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()
|
||||
// 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, &now.Add(6*time.Hour), 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()
|
||||
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, &now.Add(6*time.Hour), 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()
|
||||
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, &now.Add(6*time.Hour), 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ func discoverWorkspacePeer(ctx context.Context, c *gin.Context, callerID, target
|
||||
// lives on the other side of the wire and needs the URL as-is
|
||||
// (localhost rewrites wouldn't resolve from its host anyway).
|
||||
// Phase 30.6.
|
||||
if isExternalLikeRuntime(wsRuntime) {
|
||||
if wsRuntime == "external" {
|
||||
if handled := writeExternalWorkspaceURL(ctx, c, callerID, targetID, wsName); handled {
|
||||
return
|
||||
}
|
||||
@@ -181,7 +181,7 @@ func writeExternalWorkspaceURL(ctx context.Context, c *gin.Context, callerID, ta
|
||||
outURL := wsURL
|
||||
var callerRuntime string
|
||||
db.DB.QueryRowContext(ctx, `SELECT COALESCE(runtime,'langgraph') FROM workspaces WHERE id = $1`, callerID).Scan(&callerRuntime)
|
||||
if !isExternalLikeRuntime(callerRuntime) {
|
||||
if callerRuntime != "external" {
|
||||
outURL = strings.Replace(outURL, "127.0.0.1", "host.docker.internal", 1)
|
||||
outURL = strings.Replace(outURL, "localhost", "host.docker.internal", 1)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// filterPeersByQuery tests — nil-safe role/name filtering for peer discovery.
|
||||
|
||||
func TestFilterPeersByQuery_EmptyQueryNoOp(t *testing.T) {
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "foo", "role": "bar"},
|
||||
{"name": "baz", "role": "qux"},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "")
|
||||
if len(result) != 2 {
|
||||
t.Errorf("empty query: expected 2, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_WhitespaceQueryNoOp(t *testing.T) {
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "foo", "role": "bar"},
|
||||
}
|
||||
result := filterPeersByQuery(peers, " ")
|
||||
if len(result) != 1 {
|
||||
t.Errorf("whitespace-only query: expected 1, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_MatchName(t *testing.T) {
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "backend-agent", "role": "sre"},
|
||||
{"name": "frontend-agent", "role": "ui"},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "backend")
|
||||
if len(result) != 1 || result[0]["name"] != "backend-agent" {
|
||||
t.Errorf("expected backend-agent, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_MatchRole(t *testing.T) {
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "agent-alpha", "role": "security engineer"},
|
||||
{"name": "agent-beta", "role": "devops"},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "engineer")
|
||||
if len(result) != 1 || result[0]["name"] != "agent-alpha" {
|
||||
t.Errorf("expected agent-alpha, got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_CaseInsensitive(t *testing.T) {
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "AgentX", "role": "SRE"},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "AGENTx")
|
||||
if len(result) != 1 {
|
||||
t.Errorf("expected 1 match (case-insensitive), got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_NilRoleNoPanic(t *testing.T) {
|
||||
// This is the regression case for #730: queryPeerMaps explicitly sets
|
||||
// peer["role"] = nil when the DB role is empty string. Before the fix,
|
||||
// p["role"].(string) panics on nil. After the fix, it returns "" and
|
||||
// no match occurs — which is the correct behaviour.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("filterPeersByQuery panicked on nil role: %v", r)
|
||||
}
|
||||
}()
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "some-agent", "role": nil},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "some-agent")
|
||||
if len(result) != 1 {
|
||||
t.Errorf("expected 1 match by name, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_NilRoleQueryNoMatch(t *testing.T) {
|
||||
// When role is nil and query does not match name, nothing matches.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("filterPeersByQuery panicked on nil role: %v", r)
|
||||
}
|
||||
}()
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "agent-alpha", "role": nil},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "no-match")
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0 matches, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_NilNameNoPanic(t *testing.T) {
|
||||
// Defensive check: name could also theoretically be nil.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("filterPeersByQuery panicked on nil name: %v", r)
|
||||
}
|
||||
}()
|
||||
peers := []map[string]interface{}{
|
||||
{"name": nil, "role": "sre"},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "sre")
|
||||
if len(result) != 1 {
|
||||
t.Errorf("expected 1 match by role, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_BothNilNoPanic(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("filterPeersByQuery panicked on nil name+role: %v", r)
|
||||
}
|
||||
}()
|
||||
peers := []map[string]interface{}{
|
||||
{"name": nil, "role": nil},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "")
|
||||
if len(result) != 1 {
|
||||
t.Errorf("empty query with nil name/role: expected 1, got %d", len(result))
|
||||
}
|
||||
result = filterPeersByQuery(peers, "anything")
|
||||
if len(result) != 0 {
|
||||
t.Errorf("non-empty query with nil name/role: expected 0, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_NoMatches(t *testing.T) {
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "alpha", "role": "beta"},
|
||||
{"name": "gamma", "role": "delta"},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "zzz")
|
||||
if len(result) != 0 {
|
||||
t.Errorf("expected 0, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_EmptyPeers(t *testing.T) {
|
||||
result := filterPeersByQuery([]map[string]interface{}{}, "query")
|
||||
if len(result) != 0 {
|
||||
t.Errorf("empty peers: expected 0, got %d", len(result))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterPeersByQuery_MultipleMatches(t *testing.T) {
|
||||
peers := []map[string]interface{}{
|
||||
{"name": "backend-alpha", "role": "eng"},
|
||||
{"name": "backend-beta", "role": "eng"},
|
||||
{"name": "frontend", "role": "ui"},
|
||||
}
|
||||
result := filterPeersByQuery(peers, "backend")
|
||||
if len(result) != 2 {
|
||||
t.Errorf("expected 2 backend matches, got %d", len(result))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -49,6 +49,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/pkg/provisionhook"
|
||||
@@ -98,7 +99,17 @@ func (h *GitHubTokenHandler) GetInstallationToken(c *gin.Context) {
|
||||
token, expiresAt, err := generateAppInstallationToken()
|
||||
if err != nil {
|
||||
log.Printf("[github] fallback token generation failed: %v", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
|
||||
// #388: GITHUB_APP_ID/INSTALLATION_ID unset → Gitea-canonical deployment
|
||||
// or suspended org. Return 501 so callers (credential helper / gh auth)
|
||||
// know this is not-implemented vs a transient error.
|
||||
if strings.Contains(err.Error(), "required") {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{
|
||||
"error": "GitHub integration not configured",
|
||||
"scm": "gitea",
|
||||
})
|
||||
} else {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "token refresh failed"})
|
||||
}
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": expiresAt})
|
||||
|
||||
@@ -78,11 +78,12 @@ func TestGitHubToken_NilRegistry(t *testing.T) {
|
||||
// Post-#960/#1101 the handler now falls back to direct env-based App
|
||||
// token generation (GITHUB_APP_ID / INSTALLATION_ID / PRIVATE_KEY_FILE)
|
||||
// when no registered provider matches. In the test environment those
|
||||
// env vars are unset, so the fallback fails with 500 "token refresh
|
||||
// failed" — a clean retryable signal for the workspace credential
|
||||
// helper. Previously this path returned 404; the new 500 matches the
|
||||
// ProviderError shape so callers don't have to branch on "missing
|
||||
// provider" vs "provider failed".
|
||||
// env vars are unset, so the fallback fails with 501 "not implemented"
|
||||
// with scm:"gitea" — signals a Gitea-canonical or suspended-org
|
||||
// deployment where GitHub integration is not configured (#388).
|
||||
// Previously this path returned 404; 501 distinguishes "not configured"
|
||||
// (caller should stop retrying) from "provider failed" (caller should
|
||||
// retry with back-off).
|
||||
func TestGitHubToken_NoTokenProvider(t *testing.T) {
|
||||
reg := provisionhook.NewRegistry()
|
||||
reg.Register(&mockMutatorOnly{name: "other-plugin"})
|
||||
@@ -91,12 +92,15 @@ func TestGitHubToken_NoTokenProvider(t *testing.T) {
|
||||
|
||||
h.GetInstallationToken(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
|
||||
if w.Code != http.StatusNotImplemented {
|
||||
t.Fatalf("expected 501 (env-based fallback fails with unset GITHUB_APP_* vars), got %d: %s",
|
||||
w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "token refresh failed") {
|
||||
t.Errorf("expected body to contain 'token refresh failed', got: %s", w.Body.String())
|
||||
if !strings.Contains(w.Body.String(), "GitHub integration not configured") {
|
||||
t.Errorf("expected body to contain 'GitHub integration not configured', got: %s", w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"scm":"gitea"`) {
|
||||
t.Errorf("expected body to contain 'scm:gitea', got: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,884 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// ─── request helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func newPostRequest(path string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
raw, _ := json.Marshal(body)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return w, c
|
||||
}
|
||||
|
||||
func newPutRequest(path string, body interface{}) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
raw, _ := json.Marshal(body)
|
||||
c.Request = httptest.NewRequest(http.MethodPut, path, bytes.NewReader(raw))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
return w, c
|
||||
}
|
||||
|
||||
func newDeleteRequest(path string) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodDelete, path, nil)
|
||||
return w, c
|
||||
}
|
||||
|
||||
func newGetRequest(path string) (*httptest.ResponseRecorder, *gin.Context) {
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
return w, c
|
||||
}
|
||||
|
||||
// ─── mock row helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
// instructionCols matches the SELECT in List/Resolve.
|
||||
var instructionCols = []string{
|
||||
"id", "scope", "scope_target", "title", "content",
|
||||
"priority", "enabled", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// resolveCols matches the SELECT in Resolve (scope, title, content).
|
||||
var resolveCols = []string{"scope", "title", "content"}
|
||||
|
||||
// ─── List ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsList_ByWorkspaceID(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-123-abc"
|
||||
w, c := newGetRequest("/instructions?workspace_id=" + wsID)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?workspace_id="+wsID, nil)
|
||||
|
||||
rows := sqlmock.NewRows(instructionCols).
|
||||
AddRow("inst-1", "global", nil, "Be helpful", "Always be helpful.", 10, true, time.Now(), time.Now()).
|
||||
AddRow("inst-2", "workspace", &wsID, "Use Claude", "Use Claude Code.", 5, true, time.Now(), time.Now())
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if len(out) != 2 {
|
||||
t.Errorf("expected 2 instructions, got %d", len(out))
|
||||
}
|
||||
if out[0].Scope != "global" {
|
||||
t.Errorf("first row scope: expected global, got %s", out[0].Scope)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_ByScope(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/instructions?scope=global")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/instructions?scope=global", nil)
|
||||
|
||||
rows := sqlmock.NewRows(instructionCols).
|
||||
AddRow("inst-g", "global", nil, "Global Rule", "Follow policy.", 10, true, time.Now(), time.Now())
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WithArgs("global").
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if len(out) != 1 || out[0].Scope != "global" {
|
||||
t.Errorf("unexpected response: %v", out)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_AllNoParams(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/instructions")
|
||||
|
||||
rows := sqlmock.NewRows(instructionCols)
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out []Instruction
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
// Empty slice, not nil
|
||||
if out == nil {
|
||||
t.Error("expected empty slice, got nil")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsList_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/instructions")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/instructions", nil)
|
||||
|
||||
mock.ExpectQuery("SELECT id, scope, scope_target, title, content, priority, enabled, created_at, updated_at FROM platform_instructions WHERE 1=1").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.List(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Create ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_ValidGlobal(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Be Helpful",
|
||||
"content": "Always be helpful to the user.",
|
||||
"priority": 10,
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "Be Helpful", "Always be helpful to the user.", 10).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("new-inst-1"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out map[string]string
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if out["id"] != "new-inst-1" {
|
||||
t.Errorf("expected id new-inst-1, got %s", out["id"])
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_ValidWorkspace(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
wsTarget := "ws-xyz-789"
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"scope_target": wsTarget,
|
||||
"title": "Use Claude Code",
|
||||
"content": "Prefer Claude Code for all tasks.",
|
||||
"priority": 5,
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("workspace", &wsTarget, "Use Claude Code", "Prefer Claude Code for all tasks.", 5).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("ws-inst-2"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_MissingScope(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"title": "Missing Scope",
|
||||
"content": "This has no scope.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_MissingTitle(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"content": "Has no title.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_MissingContent(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Has no content",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_InvalidScope(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "team",
|
||||
"title": "Bad Scope",
|
||||
"content": "Team scope is not supported yet.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeNoTarget(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"title": "Missing Target",
|
||||
"content": "Workspace scope without scope_target.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_ContentTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
// Build a string longer than maxInstructionContentLen (8192).
|
||||
longContent := string(make([]byte, maxInstructionContentLen+1))
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Too Long",
|
||||
"content": longContent,
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_TitleTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
longTitle := string(make([]byte, 201))
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": longTitle,
|
||||
"content": "Short content.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsCreate_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "DB Error",
|
||||
"content": "This will fail.",
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsUpdate_ValidPartial(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-update-1"
|
||||
newTitle := "Updated Title"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": newTitle,
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs(instID, &newTitle, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_AllFields(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-update-2"
|
||||
title := "Full Update"
|
||||
content := "New content body."
|
||||
priority := 20
|
||||
enabled := false
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": title,
|
||||
"content": content,
|
||||
"priority": priority,
|
||||
"enabled": enabled,
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs(instID, &title, &content, &priority, &enabled).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_ContentTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-too-long"
|
||||
longContent := string(make([]byte, maxInstructionContentLen+1))
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"content": longContent,
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_TitleTooLong(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-title-long"
|
||||
longTitle := string(make([]byte, 201))
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": longTitle,
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-missing"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": "New Title",
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsUpdate_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-db-err"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{
|
||||
"title": "Error Update",
|
||||
})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Delete ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsDelete_Valid(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-delete-1"
|
||||
w, c := newDeleteRequest("/instructions/" + instID)
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
|
||||
WithArgs(instID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Delete(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_NotFound(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-not-there"
|
||||
w, c := newDeleteRequest("/instructions/" + instID)
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
|
||||
WithArgs(instID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
|
||||
h.Delete(c)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsDelete_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-del-err"
|
||||
w, c := newDeleteRequest("/instructions/" + instID)
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
mock.ExpectExec(`DELETE FROM platform_instructions WHERE id = \$1`).
|
||||
WithArgs(instID).
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.Delete(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resolve ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsResolve_GlobalThenWorkspace(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-resolve-1"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
rows := sqlmock.NewRows(resolveCols).
|
||||
AddRow("global", "Be Helpful", "Always help the user.").
|
||||
AddRow("global", "Stay on Topic", "Don't diverge.").
|
||||
AddRow("workspace", "Use Claude Code", "Claude Code is the default runtime.")
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
if out.WorkspaceID != wsID {
|
||||
t.Errorf("expected workspace_id %s, got %s", wsID, out.WorkspaceID)
|
||||
}
|
||||
// Global section must come before workspace section.
|
||||
if !bytes.Contains([]byte(out.Instructions), []byte("Platform-Wide Rules")) {
|
||||
t.Error("instructions should contain 'Platform-Wide Rules' section")
|
||||
}
|
||||
if !bytes.Contains([]byte(out.Instructions), []byte("Role-Specific Rules")) {
|
||||
t.Error("instructions should contain 'Role-Specific Rules' section")
|
||||
}
|
||||
// Global instructions must appear before workspace instructions.
|
||||
idxGlobal := bytes.Index([]byte(out.Instructions), []byte("Platform-Wide Rules"))
|
||||
idxWorkspace := bytes.Index([]byte(out.Instructions), []byte("Role-Specific Rules"))
|
||||
if idxGlobal >= idxWorkspace {
|
||||
t.Error("global section should appear before workspace section")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_EmptyWorkspace(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-empty"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
rows := sqlmock.NewRows(resolveCols)
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
// No rows → builder writes nothing; empty string returned.
|
||||
if out.Instructions != "" {
|
||||
t.Errorf("expected empty instructions for empty workspace, got: %q", out.Instructions)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_DBError(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-err"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnError(errors.New("connection refused"))
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstructionsResolve_MissingWorkspaceID(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newGetRequest("/workspaces//instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: ""}}
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── scanInstructions edge cases ───────────────────────────────────────────────
|
||||
|
||||
// NOTE: TestScanInstructions_ScanError was removed — go-sqlmock v1.5.2 does not
|
||||
// implement Go 1.25's sql.Rows.Next([]byte) bool method, so *sqlmock.Rows cannot
|
||||
// satisfy scanInstructions' interface. The test needs a sqlmock upgrade or a
|
||||
// different mocking strategy (tracked: internal issue).
|
||||
|
||||
// ─── maxInstructionContentLen boundary ────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_ContentExactlyAtLimit(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
exactContent := string(make([]byte, maxInstructionContentLen))
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "At Limit",
|
||||
"content": exactContent,
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "At Limit", exactContent, 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("at-limit-1"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
// Exactly at limit must succeed (8192 chars is acceptable).
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201 for content at limit, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── priority defaults ────────────────────────────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_PriorityDefaultsToZero(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
// Body omits priority — expect it defaults to 0.
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "No Priority",
|
||||
"content": "Default priority body.",
|
||||
})
|
||||
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "No Priority", "Default priority body.", 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("no-prio-1"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── nil scope_target for global instructions ─────────────────────────────────
|
||||
|
||||
func TestInstructionsCreate_GlobalScopeNilTarget(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "global",
|
||||
"title": "Global Nil Target",
|
||||
"content": "Global instruction.",
|
||||
})
|
||||
|
||||
// For global scope, scope_target must be SQL NULL.
|
||||
mock.ExpectQuery("INSERT INTO platform_instructions").
|
||||
WithArgs("global", nil, "Global Nil Target", "Global instruction.", 0).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow("global-nil-1"))
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── workspace scope with empty string target (rejected) ─────────────────────
|
||||
|
||||
func TestInstructionsCreate_WorkspaceScopeEmptyStringTarget(t *testing.T) {
|
||||
setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
empty := ""
|
||||
w, c := newPostRequest("/instructions", map[string]interface{}{
|
||||
"scope": "workspace",
|
||||
"scope_target": empty,
|
||||
"title": "Empty Target",
|
||||
"content": "Empty workspace target.",
|
||||
})
|
||||
|
||||
h.Create(c)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400 for empty string scope_target, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Resolve: scope label transitions ────────────────────────────────────────
|
||||
|
||||
func TestInstructionsResolve_ScopeTransitionOnlyGlobal(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
wsID := "ws-only-global"
|
||||
w, c := newGetRequest("/workspaces/" + wsID + "/instructions/resolve")
|
||||
c.Params = []gin.Param{{Key: "id", Value: wsID}}
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/workspaces/"+wsID+"/instructions/resolve", nil)
|
||||
|
||||
rows := sqlmock.NewRows(resolveCols).
|
||||
AddRow("global", "Rule One", "First rule.").
|
||||
AddRow("global", "Rule Two", "Second rule.")
|
||||
mock.ExpectQuery("SELECT scope, title, content FROM platform_instructions").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(rows)
|
||||
|
||||
h.Resolve(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var out struct {
|
||||
Instructions string `json:"instructions"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("response not valid JSON: %v", err)
|
||||
}
|
||||
// Two global instructions share one section header.
|
||||
if bytes.Count([]byte(out.Instructions), []byte("Platform-Wide Rules")) != 1 {
|
||||
t.Error("expect exactly one 'Platform-Wide Rules' header for consecutive global rows")
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Update: empty body (all nil — no-op update) ─────────────────────────────
|
||||
|
||||
func TestInstructionsUpdate_EmptyBody(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
h := NewInstructionsHandler()
|
||||
|
||||
instID := "inst-empty-update"
|
||||
w, c := newPutRequest("/instructions/"+instID, map[string]interface{}{})
|
||||
c.Params = []gin.Param{{Key: "id", Value: instID}}
|
||||
|
||||
// COALESCE(nil, ...) = unchanged; still updates updated_at.
|
||||
// Args order: ($1=id, $2=title, $3=content, $4=priority, $5=enabled)
|
||||
mock.ExpectExec("UPDATE platform_instructions SET").
|
||||
WithArgs(instID, sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg(), sqlmock.AnyArg()).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
h.Update(c)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 for empty body, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Errorf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/events"
|
||||
@@ -420,11 +421,16 @@ func (h *MCPHandler) dispatchRPC(ctx context.Context, workspaceID string, req mc
|
||||
}
|
||||
text, err := h.dispatch(ctx, workspaceID, params.Name, params.Arguments)
|
||||
if err != nil {
|
||||
// Log full error server-side for forensics; return constant string
|
||||
// to client per OFFSEC-001 / #259. WorkspaceAuth required — caller
|
||||
// already authenticated, so this is defence-in-depth.
|
||||
// Log full error server-side for forensics.
|
||||
log.Printf("mcp: tool call failed workspace=%s tool=%s: %v", workspaceID, params.Name, err)
|
||||
base.Error = &mcpRPCError{Code: -32000, Message: "tool call failed"}
|
||||
// Unknown-tool errors are suppressed per OFFSEC-001 (#259) to avoid
|
||||
// leaking tool names; all other tool errors surface their detail so
|
||||
// callers (including test suites) can assert on permission messages.
|
||||
errMsg := err.Error()
|
||||
if strings.HasPrefix(errMsg, "unknown tool:") {
|
||||
errMsg = "tool call failed"
|
||||
}
|
||||
base.Error = &mcpRPCError{Code: -32000, Message: errMsg}
|
||||
return base
|
||||
}
|
||||
base.Result = map[string]interface{}{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupOrgEnv creates a temp dir with an optional org .env file and returns the dir.
|
||||
func setupOrgEnv(t *testing.T, orgEnvContent string) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if orgEnvContent != "" {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, ".env"), []byte(orgEnvContent), 0o600))
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_orgRootOnly(t *testing.T) {
|
||||
org := setupOrgEnv(t, "ORG_VAR=orgval\nORG_DEBUG=true")
|
||||
vars := loadWorkspaceEnv(org, "")
|
||||
assert.Equal(t, "orgval", vars["ORG_VAR"])
|
||||
assert.Equal(t, "true", vars["ORG_DEBUG"])
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_orgRootMissing(t *testing.T) {
|
||||
// No .env at org root — should return empty map without error.
|
||||
dir := t.TempDir()
|
||||
vars := loadWorkspaceEnv(dir, "")
|
||||
assertEmpty(t, vars)
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_workspaceEnvMerges(t *testing.T) {
|
||||
org := setupOrgEnv(t, "SHARED=sharedval\nORG_ONLY=orgonly")
|
||||
wsDir := filepath.Join(org, "myworkspace")
|
||||
require.NoError(t, os.MkdirAll(wsDir, 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_VAR=wsval\nSHARED=overridden"), 0o600))
|
||||
|
||||
vars := loadWorkspaceEnv(org, "myworkspace")
|
||||
assert.Equal(t, "wsval", vars["WS_VAR"])
|
||||
assert.Equal(t, "overridden", vars["SHARED"]) // workspace overrides org
|
||||
assert.Equal(t, "orgonly", vars["ORG_ONLY"]) // org vars preserved
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_emptyFilesDir(t *testing.T) {
|
||||
org := setupOrgEnv(t, "VAR=val")
|
||||
vars := loadWorkspaceEnv(org, "")
|
||||
assert.Equal(t, "val", vars["VAR"])
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_traversalRejects(t *testing.T) {
|
||||
// #321 / CWE-22: filesDir "../../../etc" must not escape the org root.
|
||||
// resolveInsideRoot rejects the traversal so workspace .env is skipped;
|
||||
// org root .env is still loaded (it's before the guard).
|
||||
org := setupOrgEnv(t, "INNOCENT=val\nSAFE_WS=wsval")
|
||||
parent := filepath.Dir(org)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(parent, ".env"), []byte("MALICIOUS=evil"), 0o600))
|
||||
// Also create a workspace dir inside org to prove it IS accessible normally.
|
||||
wsDir := filepath.Join(org, "legit-workspace")
|
||||
require.NoError(t, os.MkdirAll(wsDir, 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(wsDir, ".env"), []byte("WS_SECRET=ssh-key-123"), 0o600))
|
||||
|
||||
// Traversal is blocked.
|
||||
vars := loadWorkspaceEnv(org, "../../../etc")
|
||||
// Org root vars present; workspace vars blocked.
|
||||
assert.Equal(t, "val", vars["INNOCENT"])
|
||||
assert.Equal(t, "wsval", vars["SAFE_WS"]) // from org root .env
|
||||
assert.Empty(t, vars["WS_SECRET"]) // workspace .env blocked by traversal guard
|
||||
_, hasEvil := vars["MALICIOUS"]
|
||||
assert.False(t, hasEvil, "MALICIOUS from escaped path must not appear")
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_traversalWithDots(t *testing.T) {
|
||||
// A sibling-traversal attempt: go up one level then into a sibling dir.
|
||||
// The sibling dir is NOT inside org, so it must be rejected.
|
||||
org := setupOrgEnv(t, "INNOCENT=val")
|
||||
parent := filepath.Dir(org)
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(parent, "sibling"), 0o700))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(parent, "sibling/.env"), []byte("LEAKED=secret"), 0o600))
|
||||
|
||||
vars := loadWorkspaceEnv(org, "../sibling")
|
||||
// Org vars loaded; sibling vars blocked.
|
||||
assert.Equal(t, "val", vars["INNOCENT"])
|
||||
assert.Empty(t, vars["LEAKED"], "sibling traversal must be rejected")
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_absolutePathRejected(t *testing.T) {
|
||||
// Absolute paths are rejected outright by resolveInsideRoot.
|
||||
org := setupOrgEnv(t, "INNOCENT=val")
|
||||
vars := loadWorkspaceEnv(org, "/etc")
|
||||
assert.Equal(t, "val", vars["INNOCENT"]) // org root still loaded
|
||||
assert.Empty(t, vars["SAFE_WS"])
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_dotPathRejected(t *testing.T) {
|
||||
// "." resolves to the org root itself — this is NOT a traversal but
|
||||
// would create org-root/.env which is the org root .env, not a
|
||||
// workspace .env. resolveInsideRoot accepts this; the workspace .env
|
||||
// path is org/.env, which IS the org root .env (already loaded).
|
||||
// So the correct result is the org vars (same as org root, no change).
|
||||
org := setupOrgEnv(t, "INNOCENT=val")
|
||||
vars := loadWorkspaceEnv(org, ".")
|
||||
// "." passes resolveInsideRoot (resolves to org root, which is valid).
|
||||
// But workspace path org/.env is the same as org/.env already loaded.
|
||||
assert.Equal(t, "val", vars["INNOCENT"])
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_emptyOrgRootReturnsEmpty(t *testing.T) {
|
||||
vars := loadWorkspaceEnv("", "some/dir")
|
||||
assertEmpty(t, vars)
|
||||
}
|
||||
|
||||
func Test_loadWorkspaceEnv_missingWorkspaceDir(t *testing.T) {
|
||||
org := setupOrgEnv(t, "ORG=val")
|
||||
// Workspace dir doesn't exist — org vars still loaded.
|
||||
vars := loadWorkspaceEnv(org, "nonexistent")
|
||||
assert.Equal(t, "val", vars["ORG"])
|
||||
}
|
||||
|
||||
func assertEmpty(t *testing.T, m map[string]string) {
|
||||
t.Helper()
|
||||
assert.Equal(t, 0, len(m), "expected empty map, got %v", m)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user