Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5cc9493bb | |||
| 71ad3ffe1d | |||
| a3fc350c6e | |||
| 57364c1bed | |||
| acc149e18e | |||
| 83ad7e252b | |||
| d27df740f5 | |||
| 4bf87d122d | |||
| aabf933a5c | |||
| 11cd1b4c40 | |||
| 4d6be109c7 | |||
| 2de81cdd85 | |||
| 84cba60ec2 | |||
| 44affbde24 | |||
| 81825575f9 | |||
| 22d2f8a6fc | |||
| a053ca6f72 | |||
| dfc9d91ccd | |||
| 9fb7060e9c | |||
| 567937e2bc | |||
| 94eff31c20 | |||
| 80a5f51c27 | |||
| 95c84021c2 | |||
| aff482a43c | |||
| cde433f2df | |||
| 90e115ba55 | |||
| f233f71f5a | |||
| 82a6cf42cd | |||
| 1b0e947bdd | |||
| ebf88a469f | |||
| bcc66ecdcf | |||
| 06b0ec8f12 | |||
| bb35c771f9 | |||
| 9a3db439ec | |||
| 533502da35 | |||
| c2110c799d | |||
| 679d86a9be | |||
| 03337955ca | |||
| e27f0747f2 | |||
| 73a09443a0 | |||
| 9dbdaf3f4e | |||
| a1c09f6a76 | |||
| 7c0836ea69 | |||
| 470bf7b50a | |||
| 458bceddd2 | |||
| 48f960db38 | |||
| e68746b521 | |||
| 780d86eddc | |||
| 9c3fcafa1a | |||
| d1a2a88f74 | |||
| 4978bd7e72 | |||
| 6c03c51a99 | |||
| cfcb7bf840 | |||
| d9d93bb728 | |||
| eff258e46e | |||
| 19ec517a1d | |||
| 545d7c4fb2 | |||
| 2ecd0de127 | |||
| 783f293c91 | |||
| b446c080aa | |||
| fa8462883e | |||
| c9146884c5 | |||
| eba3a48342 | |||
| 7f178778d5 | |||
| d1664b3144 | |||
| 41c2258043 | |||
| 254362b3bc | |||
| bc1e848977 | |||
| 0d8cf76326 | |||
| f09a6e582d | |||
| 165c7c5906 | |||
| 527b6ca36b | |||
| 7cff067b6e | |||
| 684d9b699c | |||
| b49d5bbe6c | |||
| b27826d148 | |||
| b4427ac8a6 | |||
| 5324e69049 | |||
| 500789da69 | |||
| a8e9b6177f | |||
| 8eafee5b74 | |||
| 1586d47d75 | |||
| 1439a46437 | |||
| 48f9386c19 | |||
| 878c8493a0 | |||
| 85bd51ab2f | |||
| 3371b46b9f | |||
| 2fe3229e0e | |||
| 09fa65a094 |
@@ -100,11 +100,12 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
|
||||
# (bash trap 'function' EXIT expands variables at trap-fire time, not def time).
|
||||
PR_JSON=$(mktemp)
|
||||
REVIEWS_JSON=$(mktemp)
|
||||
COMMENTS_JSON=$(mktemp)
|
||||
TEAM_PROBE_TMP=$(mktemp)
|
||||
NA_STATUSES_TMP="" # declared here so cleanup() always has the var
|
||||
|
||||
cleanup() {
|
||||
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
|
||||
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$COMMENTS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -206,7 +207,81 @@ CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILT
|
||||
debug "candidate non-author approvers: $(echo "$CANDIDATES" | tr '\n' ' ')"
|
||||
|
||||
if [ -z "$CANDIDATES" ]; then
|
||||
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates yet)"
|
||||
# --- Guardrail (internal#503): explain the most common false
|
||||
# "no candidates" red. Gitea's review event enum is EXACTLY
|
||||
# APPROVED/REQUEST_CHANGES/COMMENT/PENDING. A wrong value ("APPROVE",
|
||||
# lowercase, ...) is silently accepted (HTTP 200) and stored as
|
||||
# state=PENDING. A correctly-started draft review has an EMPTY body;
|
||||
# a NON-empty body + state==PENDING by a non-author == an intended
|
||||
# verdict mis-filed by a wrong event string. Surface it actionably.
|
||||
# This does NOT change the gate result (still fail-closed below) — it
|
||||
# only converts a mystery red into a named, self-fixing error.
|
||||
MISFILED_FILTER='.[]
|
||||
| select(.state == "PENDING")
|
||||
| select(.dismissed != true)
|
||||
| select(.user.login != $author)
|
||||
| select(((.body // "") | gsub("^\\s+|\\s+$";"") | length) > 0)
|
||||
| "\(.id)\t\(.user.login)"'
|
||||
MISFILED=$(jq -r --arg author "$PR_AUTHOR" "$MISFILED_FILTER" "$REVIEWS_JSON" 2>/dev/null || true)
|
||||
if [ -n "$MISFILED" ]; then
|
||||
echo "::error::${TEAM}-review: non-author review(s) were SUBMITTED but stored as PENDING — almost certainly the wrong Gitea review event string (internal#503)."
|
||||
echo "::error::Gitea accepts ONLY the exact enum APPROVED / REQUEST_CHANGES / COMMENT. 'APPROVE' or lowercase is silently (HTTP 200) filed as PENDING and is invisible to this gate."
|
||||
printf '%s\n' "$MISFILED" | while IFS="$(printf '\t')" read -r _rid _rl; do
|
||||
[ -n "${_rid:-}" ] && echo "::error:: review id=${_rid} by '${_rl}': RE-SUBMIT via POST ${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews with {\"event\":\"APPROVED\"} (correct enum) — do NOT edit the DB."
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Fallback (internal#348): check issue comments for agent-approval ---
|
||||
# core-qa-agent and core-security-agent approve via issue comments, NOT
|
||||
# the reviews API. The reviews API returns zero entries for comment-only
|
||||
# approvals. This fallback reads PR issue comments and extracts logins that:
|
||||
# 1. Posted a comment matching the agent-prefix pattern for this gate:
|
||||
# qa → "[core-qa-agent] APPROVED"
|
||||
# security → "[core-security-agent] APPROVED"
|
||||
# OR posted a generic approval keyword (word-anchored, case-insensitive):
|
||||
# APPROVED / LGTM / ACCEPTED
|
||||
# 2. Are not the PR author
|
||||
# 3. The team-membership probe below is the authoritative filter.
|
||||
AGENT_PATTERN=""
|
||||
case "$TEAM" in
|
||||
qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;;
|
||||
security) AGENT_PATTERN="\\[core-security-agent\\]" ;;
|
||||
esac
|
||||
HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \
|
||||
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments")
|
||||
debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}"
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
# JQ expression: select non-author comments that match either the
|
||||
# agent-prefix pattern (case-insensitive) OR a generic approval keyword.
|
||||
JQ_APPROVALS='
|
||||
.[] |
|
||||
select(.user.login != $author) |
|
||||
. as $cmt |
|
||||
if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then
|
||||
$cmt.user.login
|
||||
elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then
|
||||
$cmt.user.login
|
||||
else
|
||||
empty
|
||||
end
|
||||
'
|
||||
CANDIDATES=$(jq -r \
|
||||
--arg author "$PR_AUTHOR" \
|
||||
--arg agent_pattern "$AGENT_PATTERN" \
|
||||
"$JQ_APPROVALS" \
|
||||
"$COMMENTS_JSON" 2>/dev/null | sort -u)
|
||||
debug "comment-based approval candidates: $(echo "$CANDIDATES" | tr '\n' ' ')"
|
||||
|
||||
if [ -n "$CANDIDATES" ]; then
|
||||
echo "::notice::${TEAM}-review: reviews API found no APPROVED reviews; found $(echo "$CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..."
|
||||
fi
|
||||
else
|
||||
debug "could not fetch issue comments (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${CANDIDATES:-}" ]; then
|
||||
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates from reviews API or issue comments)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -268,6 +268,7 @@ def compute_ack_state(
|
||||
items_by_slug: dict[str, dict[str, Any]],
|
||||
numeric_aliases: dict[int, str],
|
||||
team_membership_probe: "callable[[str, list[str]], list[str]]",
|
||||
high_risk: bool = False,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Compute per-item ack state.
|
||||
|
||||
@@ -330,11 +331,16 @@ def compute_ack_state(
|
||||
for slug, candidates in pending_team_check.items():
|
||||
if not candidates:
|
||||
continue
|
||||
required = items_by_slug[slug]["required_teams"]
|
||||
# Risk-class-aware required-teams resolution (RFC#450 Option C):
|
||||
# high-risk PRs use `required_teams_high_risk` (when set on the
|
||||
# item); default class uses `required_teams`. The probe closure
|
||||
# is built with the same high_risk flag so the two reads are
|
||||
# always consistent (both sites share `resolve_required_teams`).
|
||||
required = resolve_required_teams(items_by_slug[slug], high_risk)
|
||||
approved = team_membership_probe(slug, candidates) # returns subset
|
||||
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
|
||||
ackers_per_slug[slug] = approved
|
||||
# Stash required teams for description rendering.
|
||||
# Stash resolved teams for description rendering.
|
||||
items_by_slug[slug]["_required_resolved"] = required
|
||||
|
||||
return {
|
||||
@@ -765,6 +771,42 @@ def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
|
||||
return default_mode
|
||||
|
||||
|
||||
def is_high_risk(pr: dict[str, Any], cfg: dict[str, Any]) -> bool:
|
||||
"""Return True when the PR is high-risk per RFC#450 Option C.
|
||||
|
||||
A PR is high-risk when ANY of:
|
||||
- it carries the `tier:high` label (mechanically strictest tier), or
|
||||
- it carries any label listed in cfg.high_risk_labels.
|
||||
|
||||
High-risk PRs use `required_teams_high_risk` (when set on an item)
|
||||
instead of the default `required_teams`. Items without
|
||||
`required_teams_high_risk` are unaffected (the default applies).
|
||||
|
||||
Governance fix for internal#442 — closes the inconsistency between
|
||||
sop-tier-check (tier-aware) and sop-checklist (was tier-blind).
|
||||
"""
|
||||
label_set = {(l.get("name") or "") for l in (pr.get("labels") or [])}
|
||||
if "tier:high" in label_set:
|
||||
return True
|
||||
high_risk_labels = set(cfg.get("high_risk_labels") or [])
|
||||
return bool(label_set & high_risk_labels)
|
||||
|
||||
|
||||
def resolve_required_teams(item: dict[str, Any], high_risk: bool) -> list[str]:
|
||||
"""Pick the active required_teams list for an item.
|
||||
|
||||
When high_risk is True AND the item declares a non-empty
|
||||
`required_teams_high_risk`, return that. Else fall back to
|
||||
`required_teams`. Keeping this in one helper means the gate's
|
||||
decision shape stays single-sited even as items grow.
|
||||
"""
|
||||
if high_risk:
|
||||
elevated = item.get("required_teams_high_risk") or []
|
||||
if elevated:
|
||||
return list(elevated)
|
||||
return list(item.get("required_teams") or [])
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--owner", required=True)
|
||||
@@ -825,6 +867,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
|
||||
|
||||
# High-risk classification (RFC#450 Option C, governance fix for
|
||||
# internal#442). Computed ONCE per PR — used by both the probe
|
||||
# closure and compute_ack_state so the elevation decision is
|
||||
# single-sited.
|
||||
high_risk = is_high_risk(pr, cfg)
|
||||
|
||||
# Build team-membership probe closure that caches results per
|
||||
# (user, team-id) so a user acking multiple items only triggers
|
||||
# one membership lookup per team.
|
||||
@@ -832,7 +880,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
def probe(slug: str, users: list[str]) -> list[str]:
|
||||
item = items_by_slug[slug]
|
||||
team_names: list[str] = item["required_teams"]
|
||||
team_names: list[str] = resolve_required_teams(item, high_risk)
|
||||
# Resolve names → ids. NOTE: orgs/{org}/teams/search may not be
|
||||
# available — fall back to the list endpoint.
|
||||
team_ids: list[int] = []
|
||||
@@ -877,7 +925,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
# may still find membership in another team.
|
||||
return approved
|
||||
|
||||
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
|
||||
ack_state = compute_ack_state(
|
||||
comments, author, items_by_slug, numeric_aliases, probe, high_risk=high_risk
|
||||
)
|
||||
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
|
||||
|
||||
state, description = render_status(items, ack_state, body_state)
|
||||
@@ -890,7 +940,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||
description = f"[info tier:low] {description}"
|
||||
|
||||
# Diagnostics to job log.
|
||||
print(f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} mode={mode}")
|
||||
print(
|
||||
f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} "
|
||||
f"mode={mode} risk_class={'high' if high_risk else 'default'}"
|
||||
)
|
||||
for it in items:
|
||||
slug = it["slug"]
|
||||
ackers = ack_state[slug]["ackers"]
|
||||
|
||||
@@ -17,6 +17,9 @@ Scenarios:
|
||||
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)
|
||||
T15_comments_agent_approval — reviews empty; comments have "[core-qa-agent] APPROVED" → exit 0
|
||||
T16_comments_generic_approval — reviews empty; comments have "APPROVED" by team member → exit 0
|
||||
T17_comments_no_approval — reviews empty; comments have no approval keywords → exit 1
|
||||
|
||||
Usage:
|
||||
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
|
||||
@@ -97,7 +100,9 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# GET /repos/{owner}/{name}/pulls/{pr_number}/reviews
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)/reviews$", path)
|
||||
if m:
|
||||
if sc in ("T4_reviews_empty", "T5_reviews_only_author"):
|
||||
if sc in ("T4_reviews_empty", "T5_reviews_only_author",
|
||||
"T15_comments_agent_approval", "T16_comments_generic_approval",
|
||||
"T17_comments_no_approval"):
|
||||
return self._json(200, [])
|
||||
if sc == "T6_reviews_dismissed":
|
||||
return self._json(200, [{
|
||||
@@ -116,6 +121,28 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
|
||||
])
|
||||
|
||||
# GET /repos/{owner}/{name}/issues/{pr_number}/comments
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)/comments$", path)
|
||||
if m:
|
||||
if sc == "T15_comments_agent_approval":
|
||||
return self._json(200, [
|
||||
{"user": {"login": "core-qa-agent"}, "body": "[core-qa-agent] APPROVED this PR. Good changes.", "id": 1},
|
||||
{"user": {"login": "alice"}, "body": "I authored this PR", "id": 2},
|
||||
{"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 3},
|
||||
])
|
||||
if sc == "T16_comments_generic_approval":
|
||||
return self._json(200, [
|
||||
{"user": {"login": "core-qa-agent"}, "body": "APPROVED — all acceptance criteria met", "id": 1},
|
||||
{"user": {"login": "alice"}, "body": "-authored", "id": 2},
|
||||
])
|
||||
if sc == "T17_comments_no_approval":
|
||||
return self._json(200, [
|
||||
{"user": {"login": "alice"}, "body": "I authored this PR", "id": 1},
|
||||
{"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 2},
|
||||
])
|
||||
# Default scenarios (T1–T9, T14): no comments
|
||||
return self._json(200, [])
|
||||
|
||||
# GET /teams/{team_id}/members/{username}
|
||||
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
|
||||
if m:
|
||||
@@ -127,6 +154,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# T7_team_member: member
|
||||
return self._empty(204)
|
||||
|
||||
# GET /repos/{owner}/{name}/statuses/{sha} — for N/A declaration check
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/statuses/([a-f0-9]+)$", path)
|
||||
if m:
|
||||
# All comment-based scenarios have no N/A declarations
|
||||
return self._json(200, [])
|
||||
|
||||
return self._json(404, {"path": path, "msg": "fixture: no route"})
|
||||
|
||||
def do_POST(self):
|
||||
|
||||
@@ -334,6 +334,31 @@ assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-
|
||||
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
|
||||
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
|
||||
|
||||
# T15 — comment-based approval via agent prefix pattern → exit 0
|
||||
echo
|
||||
echo "== T15 comment agent-prefix approval =="
|
||||
T15_OUT=$(run_review_check "T15_comments_agent_approval")
|
||||
T15_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T15 exit code 0 (agent-comment approval + team member)" "0" "$T15_RC"
|
||||
assert_contains "T15 comment fallback notice" "comment-based approval" "$T15_OUT"
|
||||
assert_contains "T15 core-qa-agent APPROVED" "APPROVED by core-qa-agent" "$T15_OUT"
|
||||
|
||||
# T16 — comment-based approval via generic APPROVED keyword → exit 0
|
||||
echo
|
||||
echo "== T16 comment generic keyword approval =="
|
||||
T16_OUT=$(run_review_check "T16_comments_generic_approval")
|
||||
T16_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T16 exit code 0 (generic-approval comment + team member)" "0" "$T16_RC"
|
||||
assert_contains "T16 comment fallback notice" "comment-based approval" "$T16_OUT"
|
||||
|
||||
# T17 — no approval keywords in comments → exit 1
|
||||
echo
|
||||
echo "== T17 comments with no approval keywords =="
|
||||
T17_OUT=$(run_review_check "T17_comments_no_approval")
|
||||
T17_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T17 exit code 1 (no candidates from comments)" "1" "$T17_RC"
|
||||
assert_contains "T17 no candidates error" "no candidates from reviews API or issue comments" "$T17_OUT"
|
||||
|
||||
echo
|
||||
echo "------"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
|
||||
@@ -602,4 +602,216 @@ class TestComputeNaState(unittest.TestCase):
|
||||
self.assertEqual(len(na_directives), 1)
|
||||
self.assertEqual(na_directives[0][0], "sop-n/a")
|
||||
self.assertEqual(na_directives[0][1], "qa-review")
|
||||
self.assertIn("no surface", na_directives[0][2])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RFC#450 Option C — risk-classed two-eyes (governance fix for internal#442)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsHighRisk(unittest.TestCase):
|
||||
"""The high-risk predicate decides which required_teams list applies.
|
||||
|
||||
Predicate: tier:high label OR any label in cfg.high_risk_labels.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.cfg = sop.load_config(CONFIG_PATH)
|
||||
|
||||
def test_no_labels_is_default_class(self):
|
||||
pr = {"labels": []}
|
||||
self.assertFalse(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_tier_high_is_high_risk(self):
|
||||
pr = {"labels": [{"name": "tier:high"}]}
|
||||
self.assertTrue(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_tier_low_is_default_class(self):
|
||||
pr = {"labels": [{"name": "tier:low"}]}
|
||||
self.assertFalse(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_tier_medium_is_default_class(self):
|
||||
# tier:medium alone is NOT high-risk (Option C — medium routes
|
||||
# to the wider engineers OR-set).
|
||||
pr = {"labels": [{"name": "tier:medium"}]}
|
||||
self.assertFalse(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_area_security_label_is_high_risk(self):
|
||||
pr = {"labels": [{"name": "tier:medium"}, {"name": "area:security"}]}
|
||||
self.assertTrue(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_area_schema_label_is_high_risk(self):
|
||||
pr = {"labels": [{"name": "area:schema"}]}
|
||||
self.assertTrue(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_area_identity_label_is_high_risk(self):
|
||||
pr = {"labels": [{"name": "area:identity"}]}
|
||||
self.assertTrue(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_area_fleet_image_label_is_high_risk(self):
|
||||
pr = {"labels": [{"name": "area:fleet-image"}]}
|
||||
self.assertTrue(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_area_gate_meta_label_is_high_risk(self):
|
||||
# Gate-meta = changes to sop-checklist/sop-tier-check itself.
|
||||
pr = {"labels": [{"name": "area:gate-meta"}]}
|
||||
self.assertTrue(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
def test_unknown_area_label_is_default_class(self):
|
||||
pr = {"labels": [{"name": "area:docs"}]}
|
||||
self.assertFalse(sop.is_high_risk(pr, self.cfg))
|
||||
|
||||
|
||||
class TestResolveRequiredTeams(unittest.TestCase):
|
||||
"""The team resolver picks the elevated list only for high-risk PRs
|
||||
AND only when the item declares one — items without an elevated
|
||||
list always use the default required_teams."""
|
||||
|
||||
def test_default_class_uses_default_teams(self):
|
||||
item = {"required_teams": ["engineers", "managers", "ceo"], "required_teams_high_risk": ["ceo"]}
|
||||
self.assertEqual(
|
||||
sop.resolve_required_teams(item, high_risk=False),
|
||||
["engineers", "managers", "ceo"],
|
||||
)
|
||||
|
||||
def test_high_risk_uses_elevated_teams(self):
|
||||
item = {"required_teams": ["engineers", "managers", "ceo"], "required_teams_high_risk": ["ceo"]}
|
||||
self.assertEqual(
|
||||
sop.resolve_required_teams(item, high_risk=True),
|
||||
["ceo"],
|
||||
)
|
||||
|
||||
def test_high_risk_without_elevated_falls_back_to_default(self):
|
||||
# Items that don't declare required_teams_high_risk (e.g.
|
||||
# comprehensive-testing, staging-smoke) are unaffected by risk-class.
|
||||
item = {"required_teams": ["engineers"]}
|
||||
self.assertEqual(
|
||||
sop.resolve_required_teams(item, high_risk=True),
|
||||
["engineers"],
|
||||
)
|
||||
|
||||
def test_empty_elevated_list_falls_back_to_default(self):
|
||||
# A defensive case: required_teams_high_risk: [] should not
|
||||
# silently lock out all approvers — fall back to the default
|
||||
# so the gate stays satisfiable. (Tightening should remove the
|
||||
# key, not set it to empty.)
|
||||
item = {"required_teams": ["engineers"], "required_teams_high_risk": []}
|
||||
self.assertEqual(
|
||||
sop.resolve_required_teams(item, high_risk=True),
|
||||
["engineers"],
|
||||
)
|
||||
|
||||
|
||||
class TestRootCauseAckEligibilityWidened(unittest.TestCase):
|
||||
"""Closes internal#442: a non-author engineers-team ack now satisfies
|
||||
root-cause / no-backwards-compat for the default class.
|
||||
|
||||
The dead-managers/ceo-persona-token gridlock is the symptom; the
|
||||
root cause is that sop-checklist ignored tier-class. These tests
|
||||
pin the new wider-default behavior so it can't regress silently.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
self.items = _items_by_slug()
|
||||
self.aliases = _numeric_aliases()
|
||||
|
||||
@staticmethod
|
||||
def _approve_only(allowed):
|
||||
return lambda slug, users: [u for u in users if u in allowed]
|
||||
|
||||
def test_engineers_ack_satisfies_root_cause_default_class(self):
|
||||
# Bob is in engineers only (not managers, not ceo). Default class.
|
||||
comments = [_comment("bob", "/sop-ack root-cause")]
|
||||
# Probe: bob is approved because root-cause now lists engineers.
|
||||
probe = self._approve_only({"bob"})
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, probe, high_risk=False
|
||||
)
|
||||
self.assertEqual(state["root-cause"]["ackers"], ["bob"])
|
||||
|
||||
def test_engineers_ack_satisfies_no_backwards_compat_default_class(self):
|
||||
comments = [_comment("bob", "/sop-ack no-backwards-compat")]
|
||||
probe = self._approve_only({"bob"})
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, probe, high_risk=False
|
||||
)
|
||||
self.assertEqual(state["no-backwards-compat"]["ackers"], ["bob"])
|
||||
|
||||
def test_engineers_ack_alone_fails_root_cause_when_high_risk(self):
|
||||
# High-risk PR: only ceo can ack. Engineers-only ack must fail.
|
||||
comments = [_comment("bob", "/sop-ack root-cause")]
|
||||
# Probe: bob is in engineers, not ceo. Under high_risk,
|
||||
# required_teams_high_risk=[ceo] → bob is NOT approved.
|
||||
# Probe receives the items + flag indirectly via main(); for
|
||||
# the unit-test path we inject a probe that rejects bob.
|
||||
probe = self._approve_only(set()) # nobody is in ceo
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, probe, high_risk=True
|
||||
)
|
||||
self.assertEqual(state["root-cause"]["ackers"], [])
|
||||
self.assertIn("bob", state["root-cause"]["rejected"]["not_in_team"])
|
||||
|
||||
def test_ceo_ack_satisfies_root_cause_when_high_risk(self):
|
||||
# High-risk PR + ceo-team approver → passes (the senior path).
|
||||
comments = [_comment("hongming", "/sop-ack root-cause")]
|
||||
probe = self._approve_only({"hongming"})
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, probe, high_risk=True
|
||||
)
|
||||
self.assertEqual(state["root-cause"]["ackers"], ["hongming"])
|
||||
|
||||
def test_self_ack_still_forbidden_even_with_widened_eligibility(self):
|
||||
# Author cannot self-ack — widening teams must NOT weaken
|
||||
# the non-author rule.
|
||||
comments = [_comment("alice", "/sop-ack root-cause")]
|
||||
probe = self._approve_only({"alice"})
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, probe, high_risk=False
|
||||
)
|
||||
self.assertEqual(state["root-cause"]["ackers"], [])
|
||||
self.assertIn("alice", state["root-cause"]["rejected"]["self_ack"])
|
||||
|
||||
|
||||
class TestHighRiskClassUsesElevatedListInConfig(unittest.TestCase):
|
||||
"""End-to-end: the shipped config + RFC#450 predicate must keep
|
||||
root-cause / no-backwards-compat gated on ceo for high-risk PRs."""
|
||||
|
||||
def test_root_cause_high_risk_elevated_to_ceo_only(self):
|
||||
items = _items_by_slug()
|
||||
# tier:high alone makes the PR high-risk → root-cause needs ceo.
|
||||
self.assertEqual(
|
||||
sop.resolve_required_teams(items["root-cause"], high_risk=True),
|
||||
["ceo"],
|
||||
)
|
||||
# Default class accepts engineers/managers/ceo.
|
||||
self.assertEqual(
|
||||
sorted(sop.resolve_required_teams(items["root-cause"], high_risk=False)),
|
||||
sorted(["engineers", "managers", "ceo"]),
|
||||
)
|
||||
|
||||
def test_no_backwards_compat_high_risk_elevated_to_ceo_only(self):
|
||||
items = _items_by_slug()
|
||||
self.assertEqual(
|
||||
sop.resolve_required_teams(items["no-backwards-compat"], high_risk=True),
|
||||
["ceo"],
|
||||
)
|
||||
self.assertEqual(
|
||||
sorted(sop.resolve_required_teams(items["no-backwards-compat"], high_risk=False)),
|
||||
sorted(["engineers", "managers", "ceo"]),
|
||||
)
|
||||
|
||||
def test_other_items_unchanged_by_risk_class(self):
|
||||
# Items without required_teams_high_risk are unaffected.
|
||||
items = _items_by_slug()
|
||||
for slug in (
|
||||
"comprehensive-testing",
|
||||
"local-postgres-e2e",
|
||||
"staging-smoke",
|
||||
"five-axis-review",
|
||||
"memory-consulted",
|
||||
):
|
||||
self.assertEqual(
|
||||
sop.resolve_required_teams(items[slug], high_risk=False),
|
||||
sop.resolve_required_teams(items[slug], high_risk=True),
|
||||
f"item {slug} should not be affected by risk-class",
|
||||
)
|
||||
|
||||
@@ -50,6 +50,34 @@ tier_failure_mode:
|
||||
"tier:low": soft
|
||||
default_mode: hard # used when no tier:* label is present
|
||||
|
||||
# High-risk class (RFC#450 Option C, governance-fix for internal#442).
|
||||
#
|
||||
# A PR is "high-risk" when ANY of the listed labels are applied OR when
|
||||
# the PR has `tier:high` (mechanically the strictest existing tier).
|
||||
# High-risk items use `required_teams_high_risk` (when present on the
|
||||
# item); non-high-risk items use the default `required_teams`.
|
||||
#
|
||||
# This closes the inconsistency that the SOP charter already mandates
|
||||
# `tier:high → ceo only` for the sibling `sop-tier-check` gate; the
|
||||
# sop-checklist's `root-cause` and `no-backwards-compat` items now
|
||||
# follow the same risk-classed two-eyes shape:
|
||||
# - Default class (tier:low/medium, not high-risk): a non-author
|
||||
# engineers/managers/ceo ack satisfies the item — 25+ live
|
||||
# identities, no dependency on a dead/inactive senior persona
|
||||
# token.
|
||||
# - High-risk class (tier:high OR any high_risk_label): still
|
||||
# requires a non-author ceo ack (durable human team).
|
||||
#
|
||||
# Tightening: add labels to high_risk_labels.
|
||||
# Loosening: remove labels.
|
||||
high_risk_labels:
|
||||
- "risk:high"
|
||||
- "area:security"
|
||||
- "area:schema"
|
||||
- "area:fleet-image"
|
||||
- "area:identity"
|
||||
- "area:gate-meta"
|
||||
|
||||
items:
|
||||
- slug: comprehensive-testing
|
||||
numeric_alias: 1
|
||||
@@ -78,11 +106,15 @@ items:
|
||||
- slug: root-cause
|
||||
numeric_alias: 4
|
||||
pr_section_marker: "Root-cause not symptom"
|
||||
required_teams: [managers, ceo]
|
||||
required_teams: [engineers, managers, ceo]
|
||||
required_teams_high_risk: [ceo]
|
||||
description: >-
|
||||
One-sentence root-cause statement. Ack from managers tier
|
||||
(team-leads) or ceo. Senior judgment required to attest
|
||||
root-cause-versus-symptom.
|
||||
One-sentence root-cause statement. Default class: non-author
|
||||
engineers/managers/ceo ack suffices (engineers can attest
|
||||
root-cause-vs-symptom for routine fixes). High-risk class
|
||||
(see `high_risk_labels`): non-author ceo ack required —
|
||||
senior judgment for irreversible/security/identity/gate
|
||||
changes. Closes internal#442 + tracks RFC#450.
|
||||
|
||||
- slug: five-axis-review
|
||||
numeric_alias: 5
|
||||
@@ -95,10 +127,14 @@ items:
|
||||
- slug: no-backwards-compat
|
||||
numeric_alias: 6
|
||||
pr_section_marker: "No backwards-compat shim / dead code added"
|
||||
required_teams: [managers, ceo]
|
||||
required_teams: [engineers, managers, ceo]
|
||||
required_teams_high_risk: [ceo]
|
||||
description: >-
|
||||
Yes/no + justification if no. Senior ack required because
|
||||
backward-compat shims are how dead-code accretes.
|
||||
Yes/no + justification if no. Default class: non-author
|
||||
engineers/managers/ceo ack suffices. High-risk class
|
||||
(see `high_risk_labels`): non-author ceo ack required —
|
||||
senior judgment for shim-versus-real-fix on irreversible
|
||||
surfaces. Closes internal#442 + tracks RFC#450.
|
||||
|
||||
- slug: memory-consulted
|
||||
numeric_alias: 7
|
||||
|
||||
@@ -158,8 +158,68 @@ jobs:
|
||||
echo "NOTE: No warning in output (may be suppressed by log level)"
|
||||
fi
|
||||
|
||||
- name: Reproduce openclaw failure — pipe held OPEN, no EOF
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== keep-stdin-open pipe (the real openclaw / Claude Code case) ==="
|
||||
echo ""
|
||||
echo "Before the readline() fix this HANGS: main() did"
|
||||
echo " stdin.read(65536) -> on a pipe, blocks until 64KB OR EOF."
|
||||
echo "An MCP client sends one ~150B initialize and keeps stdin"
|
||||
echo "open waiting for the response, so the server never parsed"
|
||||
echo "the request and the client timed out (openclaw: 'MCP error"
|
||||
echo "-32000: Connection closed'). The earlier regular-file /"
|
||||
echo "heredoc-pipe steps PASSED through this bug because a file"
|
||||
echo "(or a closing heredoc) yields EOF immediately."
|
||||
echo ""
|
||||
|
||||
# Drive the server through a real pipe that stays OPEN: write
|
||||
# one initialize, do NOT close stdin, and require a response
|
||||
# within a hard timeout. read(65536) -> no output -> timeout
|
||||
# kills it -> FAIL. readline() -> immediate response -> PASS.
|
||||
python - <<'PYEOF'
|
||||
import json, subprocess, sys, time, select
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "a2a_mcp_server.py"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env={**__import__("os").environ},
|
||||
)
|
||||
req = json.dumps({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "keepopen", "version": "1"}},
|
||||
}) + "\n"
|
||||
proc.stdin.write(req.encode())
|
||||
proc.stdin.flush()
|
||||
# Deliberately DO NOT close proc.stdin — mirror a live MCP client.
|
||||
|
||||
deadline = time.time() + 15
|
||||
line = b""
|
||||
while time.time() < deadline:
|
||||
r, _, _ = select.select([proc.stdout], [], [], 1)
|
||||
if r:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
break
|
||||
proc.kill()
|
||||
|
||||
if not line:
|
||||
print("FAIL: no response within 15s on an open pipe — "
|
||||
"stdin.read(65536) regression is back")
|
||||
sys.exit(1)
|
||||
resp = json.loads(line.decode())
|
||||
assert resp.get("id") == 1 and "result" in resp, \
|
||||
f"unexpected response: {line[:200]!r}"
|
||||
assert resp["result"]["serverInfo"]["name"] == "molecule", \
|
||||
f"wrong serverInfo: {line[:200]!r}"
|
||||
print("PASS: server answered initialize on a still-open pipe")
|
||||
PYEOF
|
||||
|
||||
- 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
|
||||
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion tests/test_a2a_mcp_server.py::TestStdioKeepOpenPipe -v --no-cov
|
||||
|
||||
+17
-17
@@ -145,10 +145,10 @@ jobs:
|
||||
# the diagnostic step with its own continue-on-error: true (line 203).
|
||||
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
|
||||
continue-on-error: false
|
||||
# Job-level ceiling. The go test step below runs with a per-step 30m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 30m so
|
||||
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 10m so
|
||||
# the per-step timeout is the active constraint.
|
||||
timeout-minutes: 35
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
@@ -176,14 +176,12 @@ jobs:
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: always()
|
||||
name: Diagnostic — per-package verbose (300s timeout)
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
set +e
|
||||
# 300s allows handlers + pendinguploads packages to complete on cold
|
||||
# runners with -race instrumentation (~60-120s each vs ~14s non-race).
|
||||
go test -race -v -timeout 300s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
handlers_exit=$?
|
||||
go test -race -v -timeout 300s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
pu_exit=$?
|
||||
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-handlers.log
|
||||
@@ -196,10 +194,10 @@ jobs:
|
||||
- if: always()
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 30m per-step timeout
|
||||
# lets the suite complete on cold cache (~13-25m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (35m) is a backstop.
|
||||
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
# lets the suite complete on cold cache (~5-7m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: always()
|
||||
name: Per-file coverage report
|
||||
@@ -540,11 +538,13 @@ jobs:
|
||||
all-required:
|
||||
# Aggregator sentinel — RFC internal#219 §2 (Phase 4 — closes internal#286).
|
||||
#
|
||||
# Single stable required-status name that branch protection points at;
|
||||
# CI churns underneath in `needs:` without any protection edits. Mirrors
|
||||
# the molecule-controlplane Phase 2a impl shipped in CP PR#112 and
|
||||
# referenced by `internal#286` ("Phase 4 is a single small PR... mirrors
|
||||
# CP's existing one").
|
||||
# Emits `CI / all-required (<event>)` where <event> is the workflow trigger
|
||||
# (e.g. `CI / all-required (pull_request)`, `CI / all-required (push)`).
|
||||
# Branch protection MUST be updated to require the event-suffixed name —
|
||||
# requiring `CI / all-required` (bare, no suffix) silently blocks all merges
|
||||
# because Gitea treats absent status contexts as pending (not skipped), and
|
||||
# no workflow emits the bare name. Fixed: BP now requires
|
||||
# `CI / all-required (pull_request)` per issue #1473.
|
||||
#
|
||||
# Closes the failure mode where status_check_contexts on molecule-core/main
|
||||
# only listed `Secret scan` + `sop-tier-check` (the 2 meta-gates), so real
|
||||
|
||||
@@ -52,6 +52,30 @@ name: E2E Peer Visibility (literal MCP list_peers)
|
||||
# flip-to-required-ready (mirrors e2e-staging-saas.yml's proven shape;
|
||||
# real EC2-provisioning E2E is push/dispatch/cron only — it is 30+ min
|
||||
# and cannot run per-PR-update).
|
||||
#
|
||||
# LOCAL BACKEND (added 2026-05-15 — feedback_local_must_mimic_production,
|
||||
# feedback_mandatory_local_e2e_before_ship, feedback_local_test_before_
|
||||
# staging_e2e)
|
||||
# --------------------------------------------------------------------
|
||||
# The standing rule is that the local prod-mimic stack runs a MANDATORY
|
||||
# local-Postgres E2E BEFORE staging E2E. A staging-only peer-visibility
|
||||
# gate caught regressions late + expensively (cold EC2). The
|
||||
# `peer-visibility-local` job below runs the SAME byte-identical
|
||||
# assertion (tests/e2e/lib/peer_visibility_assert.sh) against the local
|
||||
# docker-compose stack — built + booted exactly like e2e-api.yml's
|
||||
# proven E2E API Smoke Test job (ephemeral pg/redis ports, go build,
|
||||
# background platform-server). It runs on PR + push (local boot is
|
||||
# minutes, not the 30+ min cold-EC2 path), so peer-visibility is part of
|
||||
# the local gate that fires before the staging E2E.
|
||||
#
|
||||
# It is its OWN non-required status context `E2E Peer Visibility (local)`
|
||||
# — same non-required-by-design decision as the staging job (red until
|
||||
# Hermes-401 #162 / OpenClaw-never-online #165 land; flip-to-required
|
||||
# tracked at molecule-core#1296). It is an HONEST gate: NO
|
||||
# continue-on-error mask (feedback_fix_root_not_symptom). It is kept a
|
||||
# distinct context (not folded into e2e-api.yml's required `E2E API
|
||||
# Smoke Test`) precisely so a deliberately-RED-today gate cannot wedge
|
||||
# the required local-E2E job or any unrelated merge.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -65,6 +89,8 @@ on:
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/platform_tools/registry.py'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
|
||||
- 'tests/e2e/lib/peer_visibility_assert.sh'
|
||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -77,6 +103,8 @@ on:
|
||||
- 'workspace/a2a_mcp_server.py'
|
||||
- 'workspace/platform_tools/registry.py'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
|
||||
- 'tests/e2e/test_peer_visibility_mcp_local.sh'
|
||||
- 'tests/e2e/lib/peer_visibility_assert.sh'
|
||||
- '.gitea/workflows/e2e-peer-visibility.yml'
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
@@ -108,16 +136,160 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Validate driving script
|
||||
- name: Validate driving scripts + shared assertion lib
|
||||
run: |
|
||||
bash -n tests/e2e/lib/peer_visibility_assert.sh
|
||||
echo "lib/peer_visibility_assert.sh — bash syntax OK"
|
||||
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
|
||||
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
|
||||
echo "Real fresh-provision MCP list_peers E2E runs on push to"
|
||||
bash -n tests/e2e/test_peer_visibility_mcp_local.sh
|
||||
echo "test_peer_visibility_mcp_local.sh — bash syntax OK"
|
||||
echo "Staging fresh-provision MCP list_peers E2E runs on push to"
|
||||
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
|
||||
echo "The LOCAL backend runs in the peer-visibility-local job"
|
||||
echo "below on this same PR (local docker-compose stack)."
|
||||
|
||||
# Real gate: provisions a throwaway org + sibling-per-runtime, drives
|
||||
# the LITERAL list_peers MCP call per runtime, asserts 200 + expected
|
||||
# peer set, then scoped teardown. push(main)/dispatch/cron only.
|
||||
# LOCAL gate: same byte-identical assertion against the local prod-mimic
|
||||
# docker-compose stack — the MANDATORY local-E2E that must run BEFORE
|
||||
# the staging E2E (feedback_mandatory_local_e2e_before_ship,
|
||||
# feedback_local_test_before_staging_e2e). Bootstrap mirrors
|
||||
# e2e-api.yml's proven E2E API Smoke Test job (per-run container names +
|
||||
# ephemeral host ports so concurrent host-network act_runner runs don't
|
||||
# collide; go build; background platform-server). Its OWN non-required
|
||||
# status context `E2E Peer Visibility (local)` — non-required-by-design
|
||||
# exactly like the staging job (red until #162/#165 land;
|
||||
# flip-to-required tracked at molecule-core#1296). HONEST gate, NO
|
||||
# continue-on-error mask (feedback_fix_root_not_symptom). Runs on PR +
|
||||
# push (local boot is minutes, not the 30+ min cold-EC2 path).
|
||||
# bp-required: pending #1296
|
||||
peer-visibility-local:
|
||||
name: E2E Peer Visibility (local)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
# Per-run names + ephemeral ports — same collision-avoidance as
|
||||
# e2e-api.yml (host-network act_runner; feedback_act_runner_*).
|
||||
PG_CONTAINER: pg-e2e-pv-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
REDIS_CONTAINER: redis-e2e-pv-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
# LLM keys so hermes/openclaw can actually boot. The local script
|
||||
# SKIPs (not fails) any runtime whose key is absent, so a partially
|
||||
# keyed CI env still exercises whatever it can.
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.E2E_CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
|
||||
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
|
||||
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
|
||||
PV_RUNTIMES: "hermes openclaw claude-code"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
- name: Pre-pull alpine + ensure provisioner network
|
||||
run: |
|
||||
docker pull alpine:latest >/dev/null
|
||||
docker network create molecule-core-net >/dev/null 2>&1 || true
|
||||
echo "alpine:latest pre-pulled; molecule-core-net ensured."
|
||||
- name: Start Postgres (docker, ephemeral port)
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
||||
-p 0:5432 postgres:16 >/dev/null
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
[ -n "$PG_PORT" ] || PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
||||
if [ -z "$PG_PORT" ]; then
|
||||
echo "::error::Could not resolve host port for $PG_CONTAINER"
|
||||
docker logs "$PG_CONTAINER" || true; exit 1
|
||||
fi
|
||||
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 30); do
|
||||
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && { echo "Postgres ready after ${i}s"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Postgres did not become ready in 30s"; docker logs "$PG_CONTAINER" || true; exit 1
|
||||
- name: Start Redis (docker, ephemeral port)
|
||||
run: |
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
[ -n "$REDIS_PORT" ] || REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
||||
if [ -z "$REDIS_PORT" ]; then
|
||||
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
|
||||
docker logs "$REDIS_CONTAINER" || true; exit 1
|
||||
fi
|
||||
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 15); do
|
||||
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && { echo "Redis ready after ${i}s"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Redis did not become ready in 15s"; docker logs "$REDIS_CONTAINER" || true; exit 1
|
||||
- name: Build platform
|
||||
working-directory: workspace-server
|
||||
run: go build -o platform-server ./cmd/server
|
||||
- name: Pick platform port
|
||||
run: |
|
||||
PLATFORM_PORT=$(python3 - <<'PY'
|
||||
import socket
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
PY
|
||||
)
|
||||
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||
echo "Platform host port: ${PLATFORM_PORT}"
|
||||
- name: Kill stale platform-server before start
|
||||
run: |
|
||||
killed=0
|
||||
for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do
|
||||
kpid="${pid%/comm}"; kpid="${kpid##*/}"
|
||||
cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ')
|
||||
if echo "$cmdline" | grep -q "platform-server"; then
|
||||
echo "Killing stale platform-server pid ${kpid}"
|
||||
kill "$kpid" 2>/dev/null || true; killed=$((killed + 1))
|
||||
fi
|
||||
done
|
||||
[ "$killed" -gt 0 ] && sleep 2 || true
|
||||
echo "stale-kill done ($killed killed)"
|
||||
- name: Start platform (background)
|
||||
working-directory: workspace-server
|
||||
run: |
|
||||
./platform-server > platform.log 2>&1 &
|
||||
echo $! > platform.pid
|
||||
- name: Wait for /health
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf "$BASE/health" > /dev/null && { echo "Platform up after ${i}s"; exit 0; }
|
||||
sleep 1
|
||||
done
|
||||
echo "::error::Platform did not become healthy in 30s"
|
||||
cat workspace-server/platform.log || true; exit 1
|
||||
- name: Run LOCAL fresh-provision peer-visibility E2E (literal MCP list_peers)
|
||||
# HONEST gate — NO continue-on-error. Red today (Hermes-401 #162 /
|
||||
# OpenClaw-never-online #165 not yet fixed); green when they land.
|
||||
# Non-required-by-design via its distinct status context until the
|
||||
# molecule-core#1296 flip-to-required.
|
||||
run: bash tests/e2e/test_peer_visibility_mcp_local.sh
|
||||
- name: Dump platform log on failure
|
||||
if: failure()
|
||||
run: cat workspace-server/platform.log || true
|
||||
- name: Stop platform
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f workspace-server/platform.pid ]; then
|
||||
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
||||
fi
|
||||
- name: Stop service containers
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
|
||||
# Real STAGING gate: provisions a throwaway org + sibling-per-runtime,
|
||||
# drives the LITERAL list_peers MCP call per runtime, asserts 200 +
|
||||
# expected peer set, then scoped teardown. push(main)/dispatch/cron only.
|
||||
peer-visibility:
|
||||
name: E2E Peer Visibility
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -52,5 +52,9 @@ jobs:
|
||||
# explicitly instead of the combined state avoids false-pause when
|
||||
# non-blocking jobs (continue-on-error: true) have failed — those
|
||||
# failures pollute combined state but do not gate merges.
|
||||
# NOTE: the event-suffixed context name is intentional — branch protection
|
||||
# MUST require `CI / all-required (pull_request)` (with suffix), NOT the
|
||||
# bare `CI / all-required`. Gitea treats absent contexts as pending, not
|
||||
# skipped; requiring the bare name silently blocks all merges (issue #1473).
|
||||
PUSH_REQUIRED_CONTEXTS: CI / all-required (push)
|
||||
run: python3 .gitea/scripts/gitea-merge-queue.py
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
name: Lint forbidden tenant-env keys
|
||||
|
||||
# RFC#523 Layer 3 (task #146): scan workspace_secrets-writer Go code
|
||||
# under workspace-server/ for new code that hardcodes a forbidden
|
||||
# operator-scope env var NAME (GITEA_TOKEN, CP_ADMIN_API_TOKEN,
|
||||
# RAILWAY_TOKEN, INFISICAL_OPERATOR_TOKEN, MOLECULE_OPERATOR_*, …).
|
||||
#
|
||||
# Catches the class "a new writer accidentally widens the propagation
|
||||
# set" — e.g. a future env-mutator plugin that sets envVars["GITEA_TOKEN"]
|
||||
# directly. Today the L1 runtime guard would abort the provision, but
|
||||
# this lint surfaces the offending code at PR review time instead of
|
||||
# at first provision attempt.
|
||||
#
|
||||
# Companion layers:
|
||||
# - L1: workspace-server/internal/handlers/workspace_provision_forbidden_env.go
|
||||
# (fail-closed abort at provision time)
|
||||
# - L2: workspace/entrypoint.sh top-of-file env-grep + exit 1
|
||||
#
|
||||
# Open-source-template-friendly: the deny pattern is generic. A fork
|
||||
# can copy this workflow and replace OPERATOR_KEY_PATTERN with its
|
||||
# own operator-scope key names.
|
||||
#
|
||||
# Path-filter discipline:
|
||||
# This workflow runs on every PR (no paths: filter — see
|
||||
# feedback_path_filtered_workflow_cant_be_required). The scan itself
|
||||
# targets workspace_secrets-writer paths via grep -r; it's fast
|
||||
# (sub-second) so unconditional run is fine.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches: [main, staging]
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan workspace_secrets writers for forbidden env keys
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Scan for forbidden operator-scope env key NAMES in writer paths
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Forbidden EXACT-MATCH env var names. Kept in lockstep with
|
||||
# workspace-server/internal/handlers/workspace_provision_forbidden_env.go
|
||||
# forbiddenTenantEnvKeys. The Go-side test
|
||||
# TestIsForbiddenTenantEnvKey_ExactMatches is the source of
|
||||
# truth — if Go-side adds a key, also add it here (and
|
||||
# vice-versa). Drift between the two is the failure mode this
|
||||
# entire 3-layer guardrail is designed to catch.
|
||||
FORBIDDEN_KEYS=(
|
||||
"GITEA_TOKEN" "GITEA_PAT"
|
||||
"GITHUB_TOKEN" "GITHUB_PAT" "GH_TOKEN"
|
||||
"GITLAB_TOKEN" "GL_TOKEN"
|
||||
"BITBUCKET_TOKEN"
|
||||
"CP_ADMIN_API_TOKEN" "CP_ADMIN_TOKEN"
|
||||
"INFISICAL_OPERATOR_TOKEN" "INFISICAL_BOOTSTRAP_TOKEN"
|
||||
"RAILWAY_TOKEN" "RAILWAY_PERSONAL_API_TOKEN"
|
||||
"HETZNER_TOKEN" "HETZNER_API_TOKEN"
|
||||
)
|
||||
|
||||
# Forbidden PREFIX patterns — operator-scope families.
|
||||
FORBIDDEN_PREFIXES=(
|
||||
"MOLECULE_OPERATOR_"
|
||||
)
|
||||
|
||||
# Writer paths: Go source under workspace-server/ that
|
||||
# writes to the env-vars map or to workspace_secrets DB rows.
|
||||
# Tests, the forbidden-env source itself, and the silent-
|
||||
# strip denylist are exempt (they LIST the keys by design).
|
||||
SCAN_ROOT="workspace-server/internal"
|
||||
# Exempt paths fall in two classes:
|
||||
# 1. The deny-set definitions + the silent-strip denylist:
|
||||
# they LIST the forbidden names by design.
|
||||
# 2. Pre-RFC#523 persona-merge / config-read paths that
|
||||
# already handle these names correctly (the silent-
|
||||
# strip downstream + the new L1 fail-closed cover the
|
||||
# runtime risk; these reads are unchanged).
|
||||
# New code MUST NOT be added to this list without reviewer
|
||||
# signoff and a one-line justification in this diff.
|
||||
EXEMPT_PATHS=(
|
||||
# Class 1 — deny-set definitions
|
||||
"workspace-server/internal/handlers/workspace_provision_forbidden_env.go"
|
||||
"workspace-server/internal/handlers/workspace_provision_forbidden_env_test.go"
|
||||
"workspace-server/internal/provisioner/provisioner.go"
|
||||
"workspace-server/internal/provisioner/provisioner_test.go"
|
||||
# Class 2 — pre-existing persona-fallback / org-helper paths
|
||||
# that set the GITEA_TOKEN fallback lane (stripped downstream
|
||||
# by provisioner.buildContainerEnv per forensic #145). The
|
||||
# new L1 fail-closed runs BEFORE these writers, so any
|
||||
# operator-scope leak via global/workspace_secrets is
|
||||
# already caught. See applyAgentGitHTTPCreds doc-comment.
|
||||
"workspace-server/internal/handlers/agent_git_identity.go"
|
||||
"workspace-server/internal/handlers/org_helpers.go"
|
||||
"workspace-server/internal/handlers/org.go"
|
||||
# Class 2 — CP→platform admin auth (NOT a tenant env write;
|
||||
# this is the control-plane HTTP auth header source).
|
||||
"workspace-server/internal/provisioner/cp_provisioner.go"
|
||||
)
|
||||
|
||||
# Build a single grep -F pattern: every forbidden key wrapped
|
||||
# in quotes (Go string-literal form, which is how env-map
|
||||
# writes appear). e.g. envVars["GITEA_TOKEN"] = ... or
|
||||
# `"GITEA_TOKEN":` in a literal-map declaration.
|
||||
#
|
||||
# We deliberately match the quoted form so a comment that
|
||||
# happens to spell the name without quotes (e.g. "see
|
||||
# GITEA_TOKEN below") doesn't trip the lint.
|
||||
PATTERN=""
|
||||
for k in "${FORBIDDEN_KEYS[@]}"; do
|
||||
PATTERN="${PATTERN}\"${k}\"\n"
|
||||
done
|
||||
for p in "${FORBIDDEN_PREFIXES[@]}"; do
|
||||
# Prefix match needs a regex; switch to grep -E below for
|
||||
# this slice. Kept conceptually here so the deny set lives
|
||||
# in one place; scan is run twice (literal + prefix).
|
||||
true
|
||||
done
|
||||
|
||||
# Build exempt-paths grep filter — `grep -v -f` style.
|
||||
EXEMPT_FILTER=$(mktemp)
|
||||
trap 'rm -f "$EXEMPT_FILTER"' EXIT
|
||||
for p in "${EXEMPT_PATHS[@]}"; do
|
||||
echo "$p" >> "$EXEMPT_FILTER"
|
||||
done
|
||||
|
||||
# --- Exact-match scan ---
|
||||
HITS=""
|
||||
for k in "${FORBIDDEN_KEYS[@]}"; do
|
||||
# Only .go files; skip _test.go for the writer-path scan
|
||||
# since tests legitimately reference the names. The
|
||||
# writer-path lint targets PRODUCTION code only.
|
||||
found=$(grep -rn --include='*.go' --exclude='*_test.go' "\"${k}\"" "$SCAN_ROOT" 2>/dev/null \
|
||||
| grep -v -F -f "$EXEMPT_FILTER" || true)
|
||||
if [ -n "$found" ]; then
|
||||
HITS="${HITS}${found}\n"
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Prefix scan ---
|
||||
for prefix in "${FORBIDDEN_PREFIXES[@]}"; do
|
||||
found=$(grep -rnE --include='*.go' --exclude='*_test.go' "\"${prefix}[A-Z0-9_]+\"" "$SCAN_ROOT" 2>/dev/null \
|
||||
| grep -v -F -f "$EXEMPT_FILTER" || true)
|
||||
if [ -n "$found" ]; then
|
||||
HITS="${HITS}${found}\n"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$HITS" ]; then
|
||||
echo "::error::RFC#523 Layer 3: forbidden operator-scope env var name(s) hardcoded in tenant-workspace writer paths:"
|
||||
printf "$HITS"
|
||||
echo ""
|
||||
echo "These env-var NAMES are on the operator-scope deny list (see"
|
||||
echo "workspace-server/internal/handlers/workspace_provision_forbidden_env.go)."
|
||||
echo "If your code legitimately needs to inject one of these for a"
|
||||
echo "non-tenant code path, add the file to EXEMPT_PATHS in this"
|
||||
echo "workflow with a one-line justification — reviewer signoff required."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK No forbidden operator-scope env key names hardcoded in writer paths."
|
||||
@@ -0,0 +1,88 @@
|
||||
name: Lint shellcheck (arm64 pilot)
|
||||
|
||||
# Mac-CI dual-track pilot (#233). ADDITIVE / NOT REQUIRED.
|
||||
#
|
||||
# Validates the arm64 self-hosted lane (no docker.sock, no privileged
|
||||
# ops) before any required gate moves onto it. Until a Mac arm64 runner
|
||||
# is registered with the `arm64` label, this workflow sits PENDING —
|
||||
# that is FINE: `arm64` is NOT in branch_protections required contexts.
|
||||
#
|
||||
# Pairs with internal#543 (RFC: Mac arm64 multi-arch runner-base).
|
||||
# No paths: filter on purpose (feedback_path_filtered_workflow_cant_be_required).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- staging
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
shellcheck-arm64:
|
||||
name: shellcheck-arm64 (pilot)
|
||||
runs-on: [self-hosted, arm64]
|
||||
# NOT a required check; safe to sit pending until Mac runner is up.
|
||||
# If the Mac runner has trouble pulling actions/checkout we fall
|
||||
# back to a plain git clone (see step 'fallback clone').
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
steps:
|
||||
- name: Identify runner
|
||||
run: |
|
||||
set -eu
|
||||
echo "arch=$(uname -m)"
|
||||
echo "kernel=$(uname -sr)"
|
||||
echo "shell=$BASH_VERSION"
|
||||
# Sanity: must actually be arm64. If amd64 sneaks in here,
|
||||
# fail fast — that means the label routing is wrong.
|
||||
case "$(uname -m)" in
|
||||
aarch64|arm64) echo "arm64 confirmed" ;;
|
||||
*) echo "ERROR: expected arm64, got $(uname -m)"; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install shellcheck (arm64)
|
||||
run: |
|
||||
set -eu
|
||||
if command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "shellcheck already present: $(shellcheck --version | head -1)"
|
||||
else
|
||||
# Prefer apt if the runner base ships it; else download arm64 binary.
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends shellcheck
|
||||
else
|
||||
SC_VER=v0.10.0
|
||||
curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${SC_VER}/shellcheck-${SC_VER}.linux.aarch64.tar.xz" \
|
||||
| tar -xJf - --strip-components=1
|
||||
sudo mv shellcheck /usr/local/bin/
|
||||
fi
|
||||
fi
|
||||
shellcheck --version | head -2
|
||||
|
||||
- name: Run shellcheck on .gitea/scripts/*.sh
|
||||
run: |
|
||||
set -eu
|
||||
# Only the scripts we control under .gitea/scripts. Pilot
|
||||
# scope is intentionally narrow — broaden in a follow-up
|
||||
# once the lane is proven.
|
||||
mapfile -t TARGETS < <(find .gitea/scripts -maxdepth 2 -type f -name '*.sh' | sort)
|
||||
if [ "${#TARGETS[@]}" -eq 0 ]; then
|
||||
echo "No .sh files found under .gitea/scripts — nothing to check"
|
||||
exit 0
|
||||
fi
|
||||
echo "Checking ${#TARGETS[@]} file(s):"
|
||||
printf ' %s\n' "${TARGETS[@]}"
|
||||
# SC1091 = couldn't follow non-constant source; expected for
|
||||
# CI-time analysis without the full runtime layout.
|
||||
shellcheck --severity=error --exclude=SC1091 "${TARGETS[@]}"
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Compute next version from PyPI latest
|
||||
- name: Compute next version from PyPI latest and existing tags
|
||||
id: bump
|
||||
run: |
|
||||
set -eu
|
||||
@@ -112,9 +112,24 @@ jobs:
|
||||
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
|
||||
MAJOR=$(echo "$LATEST" | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST" | cut -d. -f2)
|
||||
PATCH=$(echo "$LATEST" | cut -d. -f3)
|
||||
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
|
||||
echo "PyPI latest=$LATEST -> next=$VERSION"
|
||||
TAG_LATEST=$(git tag --list "runtime-v${MAJOR}.${MINOR}.*" \
|
||||
| sed -E 's/^runtime-v//' \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V \
|
||||
| tail -1 || true)
|
||||
VERSION=$(PYPI_LATEST="$LATEST" TAG_LATEST="$TAG_LATEST" python - <<'PY'
|
||||
import os
|
||||
|
||||
def parse(v):
|
||||
return tuple(int(part) for part in v.split("."))
|
||||
|
||||
pypi = os.environ["PYPI_LATEST"]
|
||||
tag = os.environ.get("TAG_LATEST") or pypi
|
||||
base = max(parse(pypi), parse(tag))
|
||||
print(f"{base[0]}.{base[1]}.{base[2] + 1}")
|
||||
PY
|
||||
)
|
||||
echo "PyPI latest=$LATEST, latest runtime tag=${TAG_LATEST:-none} -> next=$VERSION"
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::computed version $VERSION does not match PEP 440 X.Y.Z"
|
||||
exit 1
|
||||
|
||||
@@ -89,6 +89,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required.
|
||||
|
||||
@@ -30,6 +30,11 @@ jobs:
|
||||
scan:
|
||||
name: Scan diff for credential-shaped strings
|
||||
runs-on: ubuntu-latest
|
||||
# Hard CI gate — must complete or the PR is unmergable. 10-minute ceiling
|
||||
# is generous for a diff-scan against a single SHA. If this times out, the
|
||||
# runner is frozen and holding a slot — the step timeout triggers clean
|
||||
# failure, releasing the runner for the next job.
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -133,6 +138,14 @@ jobs:
|
||||
[ -z "$f" ] && continue
|
||||
[ "$f" = "$SELF_GITHUB" ] && continue
|
||||
[ "$f" = "$SELF_GITEA" ] && continue
|
||||
# Test-fixture exclude (internal#425): the secrets-detector's OWN
|
||||
# unit-test corpus deliberately embeds credential-SHAPED example
|
||||
# strings to exercise the detector. Verified 2026-05-18 synthetic
|
||||
# (fabricated ghp_* fixtures, not real). Without this the scanner
|
||||
# self-trips on its own fixtures and fail-closes every deploy.
|
||||
# Same rationale as the SELF_* excludes above; gate NOT weakened
|
||||
# (all other paths still fully scanned).
|
||||
[ "$f" = "workspace-server/internal/secrets/patterns_test.go" ] && continue
|
||||
if [ -n "$DIFF_RANGE" ]; then
|
||||
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||
else
|
||||
|
||||
@@ -16,6 +16,7 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
|
||||
|
||||
@@ -84,11 +84,8 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
# NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses.
|
||||
# Gitea 1.22.6 may not gate on this permission key (it just checks the
|
||||
# token), but listing it explicitly documents intent for the next
|
||||
# platform-version upgrade.
|
||||
statuses: write
|
||||
secrets: read
|
||||
|
||||
jobs:
|
||||
all-items-acked:
|
||||
|
||||
@@ -71,6 +71,7 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read
|
||||
steps:
|
||||
- name: Check out base branch (for the script)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
name: canary-verify
|
||||
|
||||
# Runs the canary smoke suite against the staging canary tenant fleet
|
||||
# after a new :staging-<sha> image lands in ECR. On green, calls the
|
||||
# CP redeploy-fleet endpoint to promote :staging-<sha> → :latest so
|
||||
# the prod tenant fleet's 5-minute auto-updater picks up the verified
|
||||
# digest. On red, :latest stays on the prior known-good digest and
|
||||
# prod is untouched.
|
||||
#
|
||||
# Registry note (2026-05-10): This workflow previously used GHCR
|
||||
# (ghcr.io/molecule-ai/platform-tenant) — that registry was retired
|
||||
# during the 2026-05-06 Gitea suspension migration when publish-
|
||||
# workspace-server-image.yml switched to the operator's ECR org
|
||||
# (153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/
|
||||
# platform-tenant). The GHCR → ECR migration was never applied to
|
||||
# this file, so canary-verify was silently smoke-testing the stale
|
||||
# GHCR image while the actual staging/prod tenants ran the ECR image.
|
||||
# Result: smoke tests could not catch a broken ECR build. Fix:
|
||||
# - Wait step: reads SHA from running canary /health (tenant-
|
||||
# agnostic, works regardless of registry).
|
||||
# - Promote step: calls CP redeploy-fleet endpoint with target_tag=
|
||||
# staging-<sha>, same mechanism as redeploy-tenants-on-main.yml.
|
||||
# No longer attempts GHCR crane ops.
|
||||
#
|
||||
# Dependencies:
|
||||
# - publish-workspace-server-image.yml publishes :staging-<sha>
|
||||
# to ECR on staging and main merges.
|
||||
# - Canary tenants are configured to pull :staging-<sha> from ECR
|
||||
# (TENANT_IMAGE env set to the ECR :staging-<sha> tag).
|
||||
# - Repo secrets CANARY_TENANT_URLS / CANARY_ADMIN_TOKENS /
|
||||
# CANARY_CP_SHARED_SECRET are populated.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["publish-workspace-server-image"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
actions: read
|
||||
|
||||
env:
|
||||
# ECR registry (post-2026-05-06 SSOT for tenant images).
|
||||
# publish-workspace-server-image.yml pushes here.
|
||||
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
|
||||
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
|
||||
# CP endpoint for redeploy-fleet (used in promote step below).
|
||||
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
|
||||
jobs:
|
||||
canary-smoke:
|
||||
# Skip when the upstream workflow failed — no image to test against.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
sha: ${{ steps.compute.outputs.sha }}
|
||||
smoke_ran: ${{ steps.smoke.outputs.ran }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Compute sha
|
||||
id: compute
|
||||
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Wait for canary tenants to pick up :staging-<sha>
|
||||
# Poll canary health endpoints every 30s for up to 7 min instead
|
||||
# of a fixed 6-min sleep. Exits as soon as ALL canaries report
|
||||
# the new SHA (~2-3 min typical vs 6 min fixed). Falls back to
|
||||
# proceeding after 7 min even if not all canaries responded —
|
||||
# the smoke suite will catch any that didn't update.
|
||||
#
|
||||
# NOTE: The SHA is read from the running tenant's /health response,
|
||||
# NOT from a registry lookup. This is registry-agnostic and works
|
||||
# regardless of whether the tenant pulls from ECR, GHCR, or any
|
||||
# other registry — the canary is telling us what it's actually
|
||||
# running, which is the ground truth for smoke testing.
|
||||
env:
|
||||
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
|
||||
EXPECTED_SHA: ${{ steps.compute.outputs.sha }}
|
||||
run: |
|
||||
if [ -z "$CANARY_TENANT_URLS" ]; then
|
||||
echo "No canary URLs configured — falling back to 60s wait"
|
||||
sleep 60
|
||||
exit 0
|
||||
fi
|
||||
IFS=',' read -ra URLS <<< "$CANARY_TENANT_URLS"
|
||||
MAX_WAIT=420 # 7 minutes
|
||||
INTERVAL=30
|
||||
ELAPSED=0
|
||||
while [ $ELAPSED -lt $MAX_WAIT ]; do
|
||||
ALL_READY=true
|
||||
for url in "${URLS[@]}"; do
|
||||
HEALTH=$(curl -s --max-time 5 "${url}/health" 2>/dev/null || echo "{}")
|
||||
SHA=$(echo "$HEALTH" | grep -o "\"sha\":\"[^\"]*\"" | head -1 | cut -d'"' -f4)
|
||||
if [ "$SHA" != "$EXPECTED_SHA" ]; then
|
||||
ALL_READY=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
if $ALL_READY; then
|
||||
echo "All canaries running staging-${EXPECTED_SHA} after ${ELAPSED}s"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for canaries... (${ELAPSED}s / ${MAX_WAIT}s)"
|
||||
sleep $INTERVAL
|
||||
ELAPSED=$((ELAPSED + INTERVAL))
|
||||
done
|
||||
echo "Timeout after ${MAX_WAIT}s — proceeding anyway (smoke suite will validate)"
|
||||
|
||||
- name: Run canary smoke suite
|
||||
id: smoke
|
||||
# Graceful-skip when no canary fleet is configured (Phase 2 not yet
|
||||
# stood up — see molecule-controlplane/docs/canary-tenants.md).
|
||||
# Sets `ran=false` on skip so promote-to-latest stays off (we don't
|
||||
# want every main merge auto-promoting without gating). Manual
|
||||
# promote-latest.yml is the release gate while canary is absent.
|
||||
# Once the fleet is real: delete the early-exit branch.
|
||||
env:
|
||||
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
|
||||
CANARY_ADMIN_TOKENS: ${{ secrets.CANARY_ADMIN_TOKENS }}
|
||||
CANARY_CP_BASE_URL: https://staging-api.moleculesai.app
|
||||
CANARY_CP_SHARED_SECRET: ${{ secrets.CANARY_CP_SHARED_SECRET }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${CANARY_TENANT_URLS:-}" ] \
|
||||
|| [ -z "${CANARY_ADMIN_TOKENS:-}" ] \
|
||||
|| [ -z "${CANARY_CP_SHARED_SECRET:-}" ]; then
|
||||
{
|
||||
echo "## ⚠️ canary-verify skipped"
|
||||
echo
|
||||
echo "One or more canary secrets are unset (\`CANARY_TENANT_URLS\`, \`CANARY_ADMIN_TOKENS\`, \`CANARY_CP_SHARED_SECRET\`)."
|
||||
echo "Phase 2 canary fleet has not been stood up yet —"
|
||||
echo "see [canary-tenants.md](https://git.moleculesai.app/molecule-ai/molecule-controlplane/blob/main/docs/canary-tenants.md)."
|
||||
echo
|
||||
echo "**Skipped — promote-to-latest will NOT auto-fire.** Dispatch \`promote-latest.yml\` manually when ready."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "ran=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::canary-verify: skipped — no canary fleet configured"
|
||||
exit 0
|
||||
fi
|
||||
bash scripts/canary-smoke.sh
|
||||
echo "ran=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Summary on failure
|
||||
if: ${{ failure() }}
|
||||
run: |
|
||||
{
|
||||
echo "## Canary smoke FAILED"
|
||||
echo
|
||||
echo "Canary tenants rejected image \`staging-${{ steps.compute.outputs.sha }}\`."
|
||||
echo ":latest stays pinned to the prior good digest — prod is untouched."
|
||||
echo
|
||||
echo "Fix forward and merge again, or investigate the specific failed"
|
||||
echo "assertions in the canary-smoke step log above."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
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
|
||||
# mechanism as redeploy-tenants-on-main.yml — no GHCR crane ops.
|
||||
#
|
||||
# Pre-fix history: the old GHCR promote step used `crane tag` against
|
||||
# ghcr.io/molecule-ai/platform-tenant, but publish-workspace-server-
|
||||
# image.yml had already migrated to ECR on 2026-05-07 (commit
|
||||
# 10e510f5). The GHCR tags were never updated, so this step was
|
||||
# silently promoting a stale GHCR image while actual prod tenants
|
||||
# pulled from ECR. Canary smoke tests were GHCR-targeted and could
|
||||
# not catch a broken ECR build.
|
||||
needs: canary-smoke
|
||||
if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
SHA: ${{ needs.canary-smoke.outputs.sha }}
|
||||
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
# CP_ADMIN_API_TOKEN gates write access to the redeploy endpoint.
|
||||
# Stored at the repo level so all workflows pick it up automatically.
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
# canary_slug pin: deploy the verified :staging-<sha> to the canary
|
||||
# first (soak 120s), then fan out to the rest of the fleet.
|
||||
CANARY_SLUG: ${{ vars.CANARY_PROMOTE_SLUG || '' }}
|
||||
SOAK_SECONDS: ${{ vars.CANARY_PROMOTE_SOAK || '120' }}
|
||||
BATCH_SIZE: ${{ vars.CANARY_PROMOTE_BATCH || '3' }}
|
||||
steps:
|
||||
- name: Check CP credentials
|
||||
run: |
|
||||
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
|
||||
echo "::error::CP_ADMIN_API_TOKEN secret is not set — promote step cannot call redeploy-fleet."
|
||||
echo "::error::Set it at: repo Settings → Actions → Variables and Secrets → New Secret."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Promote verified ECR image to :latest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
TARGET_TAG="staging-${SHA}"
|
||||
BODY=$(jq -nc \
|
||||
--arg tag "$TARGET_TAG" \
|
||||
--argjson soak "${SOAK_SECONDS:-120}" \
|
||||
--argjson batch "${BATCH_SIZE:-3}" \
|
||||
--argjson dry false \
|
||||
'{
|
||||
target_tag: $tag,
|
||||
soak_seconds: $soak,
|
||||
batch_size: $batch,
|
||||
dry_run: $dry
|
||||
}')
|
||||
|
||||
if [ -n "${CANARY_SLUG:-}" ]; then
|
||||
BODY=$(jq '. * {canary_slug: $slug}' --arg slug "$CANARY_SLUG" <<<"$BODY")
|
||||
fi
|
||||
|
||||
echo "Calling: POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " target_tag: $TARGET_TAG"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE=$(mktemp)
|
||||
HTTP_CODE_FILE=$(mktemp)
|
||||
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"
|
||||
CURL_EXIT=$?
|
||||
set -e
|
||||
|
||||
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE (curl exit $CURL_EXIT)"
|
||||
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
|
||||
|
||||
if [ "$HTTP_CODE" -ge 400 ]; then
|
||||
echo "::error::CP redeploy-fleet returned HTTP $HTTP_CODE — refusing to proceed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
{
|
||||
echo "## Canary verified — :latest promoted via CP redeploy-fleet"
|
||||
echo ""
|
||||
echo "- **Target tag:** \`staging-${{ needs.canary-smoke.outputs.sha }}\`"
|
||||
echo "- **Registry:** ECR (\`${TENANT_IMAGE_NAME}\`)"
|
||||
echo "- **Canary slug:** \`${CANARY_SLUG:-<none>}\` (soak ${SOAK_SECONDS}s)"
|
||||
echo "- **Batch size:** ${BATCH_SIZE:-3}"
|
||||
echo ""
|
||||
echo "CP redeploy-fleet is rolling out the verified image across the prod fleet."
|
||||
echo "The fleet's 5-minute health-check loop will pick up the update automatically."
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1,400 +0,0 @@
|
||||
name: redeploy-tenants-on-main
|
||||
|
||||
# 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,
|
||||
# but running tenants pulled their image once at boot and never re-pull.
|
||||
# Users see stale code indefinitely.
|
||||
#
|
||||
# This workflow closes the gap by calling the control-plane admin
|
||||
# endpoint that performs a canary-first, batched, health-gated rolling
|
||||
# redeploy across every live tenant. Implemented in molecule-ai/
|
||||
# molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet
|
||||
# (feat/tenant-auto-redeploy, landing alongside this workflow).
|
||||
#
|
||||
# Registry: ECR (153263036946.dkr.ecr.us-east-2.amazonaws.com/
|
||||
# molecule-ai/platform-tenant). GHCR was retired 2026-05-07 during the
|
||||
# Gitea suspension migration. The canary-verify.yml promote step now
|
||||
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
|
||||
#
|
||||
# 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:
|
||||
workflow_run:
|
||||
workflows: ['publish-workspace-server-image']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_tag:
|
||||
# Empty default → auto-trigger and dispatch-without-input both
|
||||
# resolve to `staging-<short_head_sha>` (the digest publish-image
|
||||
# just pushed). Pre-fix this defaulted to 'latest', which only
|
||||
# gets retagged by canary-verify's promote-to-latest job — and
|
||||
# that job soft-skips when CANARY_TENANT_URLS is unset (the
|
||||
# current state, until Phase 2 canary fleet is live). Result:
|
||||
# `:latest` had been pinned to a 4-day-old digest (2026-04-28)
|
||||
# while every main push pushed fresh `staging-<sha>` images;
|
||||
# every prod redeploy pulled the stale `:latest` and the verify
|
||||
# step correctly flagged 3/3 tenants STALE. Pulling the
|
||||
# just-published `staging-<sha>` directly skips the dead retag
|
||||
# path. When canary fleet is real, this workflow should chain
|
||||
# on canary-verify completion (workflow_run from canary-verify),
|
||||
# not publish-image — separate, smaller PR.
|
||||
description: 'Tenant image tag to deploy (e.g. "latest", "staging-a59f1a6c"). Empty = auto staging-<head_sha>.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
canary_slug:
|
||||
description: 'Tenant slug to deploy first + soak (empty = skip canary, fan out immediately).'
|
||||
required: false
|
||||
type: string
|
||||
# Must be an actual prod tenant slug (current: hongming,
|
||||
# chloe-dong, reno-stars). The previous default 'hongmingwang'
|
||||
# didn't match any tenant — CP soft-skipped the missing canary
|
||||
# and the fleet rolled out without the soak gate, defeating the
|
||||
# whole point of canary-first.
|
||||
default: 'hongming'
|
||||
soak_seconds:
|
||||
description: 'Seconds to wait after canary before fanning out.'
|
||||
required: false
|
||||
type: string
|
||||
default: '60'
|
||||
batch_size:
|
||||
description: 'How many tenants SSM redeploys in parallel per batch.'
|
||||
required: false
|
||||
type: string
|
||||
default: '3'
|
||||
dry_run:
|
||||
description: 'Plan only — do not actually redeploy.'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
# not the GitHub API.
|
||||
|
||||
# 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
|
||||
|
||||
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.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Note on ECR propagation
|
||||
# ECR image manifests are consistent immediately after push — no
|
||||
# CDN cache to wait for. The old GHCR-based workflow had a 30s
|
||||
# sleep to avoid race conditions; ECR makes that unnecessary.
|
||||
run: echo "ECR image available immediately after push — proceeding."
|
||||
|
||||
- name: Compute target tag
|
||||
id: tag
|
||||
# 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 (canary-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:
|
||||
INPUT_TAG: ${{ inputs.target_tag }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
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 auto tag: staging-$SHORT (head_sha=$HEAD_SHA)"
|
||||
fi
|
||||
|
||||
- name: Call CP redeploy-fleet
|
||||
# CP_ADMIN_API_TOKEN must be set as a repo/org secret on
|
||||
# molecule-ai/molecule-core, matching the staging/prod CP's
|
||||
# CP_ADMIN_API_TOKEN env. Stored in Railway, mirrored to this
|
||||
# repo's secrets for CI.
|
||||
env:
|
||||
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: ${{ 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
|
||||
|
||||
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
|
||||
echo "::error::CP_ADMIN_API_TOKEN secret not set — skipping redeploy"
|
||||
echo "::notice::Set CP_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BODY=$(jq -nc \
|
||||
--arg tag "$TARGET_TAG" \
|
||||
--arg canary "$CANARY_SLUG" \
|
||||
--argjson soak "$SOAK_SECONDS" \
|
||||
--argjson batch "$BATCH_SIZE" \
|
||||
--argjson dry "$DRY_RUN" \
|
||||
'{
|
||||
target_tag: $tag,
|
||||
canary_slug: $canary,
|
||||
soak_seconds: $soak,
|
||||
batch_size: $batch,
|
||||
dry_run: $dry
|
||||
}')
|
||||
|
||||
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE=$(mktemp)
|
||||
HTTP_CODE_FILE=$(mktemp)
|
||||
# Route -w into its own tempfile so curl's exit code (e.g. 56
|
||||
# on connection-reset, 22 on --fail-with-body 4xx/5xx) can't
|
||||
# pollute the captured stdout. The previous inline-substitution
|
||||
# shape produced "000000" on connection reset (curl wrote
|
||||
# "000" via -w, then the inline echo-fallback appended another
|
||||
# "000") — caught on the 2026-05-04 redeploy of sha 2b862f6.
|
||||
# set +e/-e keeps the non-zero curl exit from tripping the
|
||||
# outer pipeline. See lint-curl-status-capture.yml for the
|
||||
# CI gate that pins this fix shape.
|
||||
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
|
||||
# Stderr from curl (e.g. dial errors with -sS) goes to the runner
|
||||
# log so operators can see WHY a connection failed. Stdout is
|
||||
# captured to $HTTP_CODE_FILE because that's where -w writes.
|
||||
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
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
|
||||
# into the raw response.
|
||||
{
|
||||
echo "## Tenant redeploy fleet"
|
||||
echo ""
|
||||
echo "**Target tag:** \`$TARGET_TAG\`"
|
||||
echo "**Canary:** \`$CANARY_SLUG\` (soak ${SOAK_SECONDS}s)"
|
||||
echo "**Batch size:** $BATCH_SIZE"
|
||||
echo "**Dry run:** $DRY_RUN"
|
||||
echo "**HTTP:** $HTTP_CODE"
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
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
|
||||
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 (see summary for which tenant halted the rollout)"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Tenant fleet redeploy reported ssm_status=Success — verifying actual image roll on each tenant..."
|
||||
|
||||
# Stash the response for the verify step. $RUNNER_TEMP outlasts
|
||||
# the step boundary; $HTTP_RESPONSE doesn't.
|
||||
cp "$HTTP_RESPONSE" "$RUNNER_TEMP/redeploy-response.json"
|
||||
|
||||
- name: Verify each tenant /buildinfo matches published SHA
|
||||
# ROOT FIX FOR #2395.
|
||||
#
|
||||
# `redeploy-fleet`'s `ssm_status=Success` means "the SSM RPC
|
||||
# didn't error" — NOT "the new image is running on the tenant."
|
||||
# `:latest` lives in the local Docker daemon's image cache; if
|
||||
# the SSM document does `docker compose up -d` without an
|
||||
# explicit `docker pull`, the daemon serves the previously-
|
||||
# cached digest and the container restarts on stale code.
|
||||
# 2026-04-30 incident: hongmingwang's tenant reported
|
||||
# ssm_status=Success at 17:00:53Z but kept serving pre-501a42d7
|
||||
# chat_files for 30+ min — the lazy-heal fix never reached the
|
||||
# user despite green deploy + green redeploy.
|
||||
#
|
||||
# This step closes the gap by curling each tenant's /buildinfo
|
||||
# endpoint (added in workspace-server/internal/buildinfo +
|
||||
# /Dockerfile* GIT_SHA build-arg, this PR) and comparing the
|
||||
# returned git_sha to the SHA the workflow expects. Mismatches
|
||||
# fail the workflow, which is what `ok=true` should have
|
||||
# guaranteed all along.
|
||||
#
|
||||
# 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.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`;
|
||||
# staging CP issues `<slug>.staging.moleculesai.app`. This
|
||||
# workflow runs on main → prod CP → no `staging.` infix.
|
||||
TENANT_DOMAIN: 'moleculesai.app'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
EXPECTED_SHORT="${EXPECTED_SHA:0:7}"
|
||||
if [ "$TARGET_TAG" != "latest" ] \
|
||||
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
|
||||
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
|
||||
# workflow_dispatch with a pinned tag that isn't the head
|
||||
# SHA — operator is rolling back / pinning. Skip the
|
||||
# verification because we don't have the expected SHA in
|
||||
# this context (would need to crane-inspect the GHCR
|
||||
# manifest, which is a follow-up). Failing-open here is
|
||||
# safe: the operator chose the tag deliberately.
|
||||
#
|
||||
# `staging-<short_head_sha>` IS verified — it's the new
|
||||
# auto-trigger default (see Compute target tag step) and
|
||||
# the digest under that tag SHOULD match EXPECTED_SHA.
|
||||
echo "::notice::target_tag=$TARGET_TAG (operator-pinned) — skipping per-tenant SHA verification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESP="$RUNNER_TEMP/redeploy-response.json"
|
||||
if [ ! -s "$RESP" ]; then
|
||||
echo "::error::redeploy-response.json missing or empty — verify step ran without a response to read"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pull only successfully-redeployed tenants. Any tenant that
|
||||
# halted the rollout already failed the previous step, so we
|
||||
# don't double-count them here.
|
||||
mapfile -t SLUGS < <(jq -r '.results[]? | select(.healthz_ok == true) | .slug' "$RESP")
|
||||
if [ ${#SLUGS[@]} -eq 0 ]; then
|
||||
echo "::warning::No tenants reported healthz_ok — nothing to verify"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Verifying ${#SLUGS[@]} tenant(s) against EXPECTED_SHA=${EXPECTED_SHA:0:7}..."
|
||||
|
||||
# Two distinct failure modes — STALE (the #2395 bug class, hard-fail)
|
||||
# vs UNREACHABLE (teardown race, soft-warn). See the staging variant's
|
||||
# comment for the full rationale; same logic applies on prod even
|
||||
# though prod has fewer ephemeral tenants — the asymmetry would be a
|
||||
# gratuitous fork.
|
||||
STALE_COUNT=0
|
||||
UNREACHABLE_COUNT=0
|
||||
STALE_LINES=()
|
||||
UNREACHABLE_LINES=()
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
URL="https://${slug}.${TENANT_DOMAIN}/buildinfo"
|
||||
# 30s total: tenant just SSM-restarted, may still be coming
|
||||
# up. Retry-on-empty rather than retry-on-status — we want
|
||||
# to fail fast on "responded with wrong SHA", not "still
|
||||
# warming up".
|
||||
BODY=$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$URL" || true)
|
||||
ACTUAL_SHA=$(echo "$BODY" | jq -r '.git_sha // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$ACTUAL_SHA" ]; then
|
||||
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
|
||||
UNREACHABLE_LINES+=("| $slug | (no /buildinfo response) | ${EXPECTED_SHA:0:7} | ⚠ unreachable (likely teardown race) |")
|
||||
continue
|
||||
fi
|
||||
if [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ]; then
|
||||
echo " $slug: ${ACTUAL_SHA:0:7} ✓"
|
||||
else
|
||||
STALE_COUNT=$((STALE_COUNT + 1))
|
||||
STALE_LINES+=("| $slug | ${ACTUAL_SHA:0:7} | ${EXPECTED_SHA:0:7} | ❌ stale |")
|
||||
fi
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "### Per-tenant /buildinfo verification"
|
||||
echo ""
|
||||
echo "Expected SHA: \`${EXPECTED_SHA:0:7}\`"
|
||||
echo ""
|
||||
if [ $STALE_COUNT -gt 0 ]; then
|
||||
echo "**${STALE_COUNT} STALE tenant(s) — these did NOT pick up the new image despite ssm_status=Success:**"
|
||||
echo ""
|
||||
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
|
||||
echo "|------|----------------------|----------|--------|"
|
||||
for line in "${STALE_LINES[@]}"; do echo "$line"; done
|
||||
echo ""
|
||||
fi
|
||||
if [ $UNREACHABLE_COUNT -gt 0 ]; then
|
||||
echo "**${UNREACHABLE_COUNT} unreachable tenant(s) — likely teardown race (soft-warn, not failing):**"
|
||||
echo ""
|
||||
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
|
||||
echo "|------|----------------------|----------|--------|"
|
||||
for line in "${UNREACHABLE_LINES[@]}"; do echo "$line"; done
|
||||
echo ""
|
||||
fi
|
||||
if [ $STALE_COUNT -eq 0 ] && [ $UNREACHABLE_COUNT -eq 0 ]; then
|
||||
echo "All ${#SLUGS[@]} tenants returned matching SHA. ✓"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ $UNREACHABLE_COUNT -gt 0 ]; then
|
||||
echo "::warning::$UNREACHABLE_COUNT tenant(s) unreachable post-redeploy. Likely benign teardown race — CP healthz monitor catches real outages."
|
||||
fi
|
||||
|
||||
# Belt-and-suspenders sanity floor: same logic as the staging
|
||||
# variant — see that file's comment for the full rationale.
|
||||
# Floor only applies when fleet >= 4; below that, canary-verify
|
||||
# is the actual gate.
|
||||
TOTAL_VERIFIED=${#SLUGS[@]}
|
||||
if [ $TOTAL_VERIFIED -ge 4 ] && [ $UNREACHABLE_COUNT -gt $((TOTAL_VERIFIED / 2)) ]; then
|
||||
echo "::error::$UNREACHABLE_COUNT of $TOTAL_VERIFIED tenant(s) unreachable — exceeds 50% threshold on a fleet large enough that this signals a real outage, not teardown race."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $STALE_COUNT -gt 0 ]; then
|
||||
echo "::error::$STALE_COUNT tenant(s) returned a stale SHA. ssm_status=Success was misleading — see job summary."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice::Tenant fleet redeploy complete — all reachable tenants on ${EXPECTED_SHA:0:7} (${UNREACHABLE_COUNT} unreachable, soft-warned)."
|
||||
@@ -1,362 +0,0 @@
|
||||
name: redeploy-tenants-on-staging
|
||||
|
||||
# Auto-refresh staging tenant EC2s after every staging-branch merge.
|
||||
#
|
||||
# Mirror of redeploy-tenants-on-main.yml, with the staging-CP host and
|
||||
# the :staging-latest tag. Sister workflow exists for prod (rolls
|
||||
# :latest after canary-verify). Both share the same shape — just
|
||||
# different CP_URL + target_tag + admin token secret.
|
||||
#
|
||||
# Why this workflow exists: publish-workspace-server-image now builds
|
||||
# on every staging-branch push (PR #2335), pushing
|
||||
# platform-tenant:staging-latest to GHCR. Existing tenants pulled
|
||||
# their image once at boot and never re-pull, so the new image just
|
||||
# sits unused until the tenant is reprovisioned.
|
||||
#
|
||||
# This workflow closes the gap by calling staging-CP's
|
||||
# /cp/admin/tenants/redeploy-fleet, which performs a canary-first,
|
||||
# batched, health-gated SSM redeploy across every live staging tenant.
|
||||
# Same endpoint shape as prod CP — only the host differs.
|
||||
#
|
||||
# Runtime ordering:
|
||||
# 1. publish-workspace-server-image completes on staging branch →
|
||||
# new :staging-latest in GHCR.
|
||||
# 2. This workflow fires via workflow_run, waits 30s for GHCR's CDN
|
||||
# to propagate the new tag.
|
||||
# 3. Calls redeploy-fleet with no canary (staging IS canary; we don't
|
||||
# need a sub-canary inside it). Soak still applies to the first
|
||||
# tenant in case of bad-deploy detection.
|
||||
# 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 with workflow_dispatch + target_tag=staging-<sha>
|
||||
# of a known-good build.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['publish-workspace-server-image']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_tag:
|
||||
description: 'Tenant image tag to deploy (e.g. "staging-latest" or "staging-a59f1a6c"). Defaults to staging-latest when empty.'
|
||||
required: false
|
||||
type: string
|
||||
default: 'staging-latest'
|
||||
canary_slug:
|
||||
description: 'Tenant slug to deploy first + soak (empty = skip canary, fan out immediately). Default empty for staging since staging itself is the canary.'
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
soak_seconds:
|
||||
description: 'Seconds to wait after canary before fanning out. Only meaningful if canary_slug is set.'
|
||||
required: false
|
||||
type: string
|
||||
default: '60'
|
||||
batch_size:
|
||||
description: 'How many tenants SSM redeploys in parallel per batch.'
|
||||
required: false
|
||||
type: string
|
||||
default: '3'
|
||||
dry_run:
|
||||
description: 'Plan only — do not actually redeploy.'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
# not the GitHub API.
|
||||
|
||||
# Serialize per-branch so two rapid staging pushes' redeploys don't
|
||||
# overlap and cause confusing per-tenant SSM state. cancel-in-progress
|
||||
# is false because aborting a half-rolled-out fleet leaves tenants
|
||||
# stuck on whatever image they happened to be on when cancelled.
|
||||
concurrency:
|
||||
group: redeploy-tenants-on-staging
|
||||
cancel-in-progress: false
|
||||
|
||||
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.
|
||||
if: |
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Wait for GHCR tag propagation
|
||||
# GHCR's edge cache takes ~15-30s to consistently serve the new
|
||||
# :staging-latest manifest after the registry accepts the push.
|
||||
# Same rationale as redeploy-tenants-on-main.yml.
|
||||
run: sleep 30
|
||||
|
||||
- name: Call staging-CP redeploy-fleet
|
||||
# CP_STAGING_ADMIN_API_TOKEN must be set as a repo/org secret
|
||||
# on molecule-ai/molecule-core, matching staging-CP's
|
||||
# CP_ADMIN_API_TOKEN env var (visible in Railway controlplane
|
||||
# / staging environment). Stored separately from the prod
|
||||
# CP_ADMIN_API_TOKEN so a leak of one doesn't auth the other.
|
||||
env:
|
||||
CP_URL: ${{ vars.STAGING_CP_URL || 'https://staging-api.moleculesai.app' }}
|
||||
CP_STAGING_ADMIN_API_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
|
||||
TARGET_TAG: ${{ inputs.target_tag || 'staging-latest' }}
|
||||
CANARY_SLUG: ${{ inputs.canary_slug || '' }}
|
||||
SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
|
||||
BATCH_SIZE: ${{ inputs.batch_size || '3' }}
|
||||
DRY_RUN: ${{ inputs.dry_run || false }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Schedule-vs-dispatch hardening (mirrors sweep-cf-orphans
|
||||
# and sweep-cf-tunnels): hard-fail on auto-trigger when the
|
||||
# secret is missing so a misconfigured-repo doesn't silently
|
||||
# serve stale staging tenants. Soft-skip on operator dispatch.
|
||||
if [ -z "${CP_STAGING_ADMIN_API_TOKEN:-}" ]; then
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "::warning::CP_STAGING_ADMIN_API_TOKEN secret not set — skipping redeploy"
|
||||
echo "::warning::Set CP_STAGING_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
|
||||
echo "::notice::Pull the value from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::staging redeploy cannot run — CP_STAGING_ADMIN_API_TOKEN secret missing"
|
||||
echo "::error::set it at Settings → Secrets and Variables → Actions; pull from staging-CP's CP_ADMIN_API_TOKEN env in Railway."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BODY=$(jq -nc \
|
||||
--arg tag "$TARGET_TAG" \
|
||||
--arg canary "$CANARY_SLUG" \
|
||||
--argjson soak "$SOAK_SECONDS" \
|
||||
--argjson batch "$BATCH_SIZE" \
|
||||
--argjson dry "$DRY_RUN" \
|
||||
'{
|
||||
target_tag: $tag,
|
||||
canary_slug: $canary,
|
||||
soak_seconds: $soak,
|
||||
batch_size: $batch,
|
||||
dry_run: $dry
|
||||
}')
|
||||
|
||||
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE=$(mktemp)
|
||||
HTTP_CODE_FILE=$(mktemp)
|
||||
# Route -w into its own tempfile so curl's exit code (e.g. 56
|
||||
# on connection-reset) can't pollute the captured stdout. The
|
||||
# previous inline-substitution shape produced "000000" on
|
||||
# connection reset — caught on main variant 2026-05-04
|
||||
# redeploying sha 2b862f6. Same fix shape as the synth-E2E
|
||||
# §9c gate (PR #2797). See lint-curl-status-capture.yml for
|
||||
# the CI gate that pins this fix shape.
|
||||
set +e
|
||||
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
|
||||
-m 1200 \
|
||||
-H "Authorization: Bearer $CP_STAGING_ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
|
||||
-d "$BODY" >"$HTTP_CODE_FILE"
|
||||
set -e
|
||||
# Stderr from curl (-sS shows dial errors etc.) goes to the
|
||||
# runner log so operators can see WHY a connection failed.
|
||||
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
|
||||
echo "HTTP $HTTP_CODE"
|
||||
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
|
||||
|
||||
{
|
||||
echo "## Staging tenant redeploy fleet"
|
||||
echo ""
|
||||
echo "**Target tag:** \`$TARGET_TAG\`"
|
||||
echo "**Canary:** \`${CANARY_SLUG:-(none — staging is itself the canary)}\` (soak ${SOAK_SECONDS}s)"
|
||||
echo "**Batch size:** $BATCH_SIZE"
|
||||
echo "**Dry run:** $DRY_RUN"
|
||||
echo "**HTTP:** $HTTP_CODE"
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
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"
|
||||
|
||||
# Distinguish "real fleet failure" from "E2E teardown race".
|
||||
#
|
||||
# CP returns HTTP 500 + ok=false whenever ANY tenant in the
|
||||
# fleet failed SSM or healthz. In practice the recurring source
|
||||
# of these is ephemeral test tenants being torn down by their
|
||||
# parent E2E run mid-redeploy: the EC2 dies → SSM exit=2 or
|
||||
# healthz timeout → CP marks the fleet failed → this workflow
|
||||
# goes red even though every operator-facing tenant rolled fine.
|
||||
#
|
||||
# Ephemeral slug prefixes (kept in sync with sweep-stale-e2e-orgs.yml
|
||||
# — see that file for the source-of-truth list and rationale):
|
||||
# - e2e-* — canvas/saas/ext E2E suites
|
||||
# - rt-e2e-* — runtime-test harness fixtures (RFC #2251)
|
||||
# Long-lived prefixes that are NOT ephemeral and MUST hard-fail:
|
||||
# demo-prep, dryrun-*, dryrun2-*, plus all human tenant slugs.
|
||||
#
|
||||
# Filter: if HTTP=500/ok=false AND every failed slug matches an
|
||||
# ephemeral prefix, treat as soft-warn and let the verify step
|
||||
# downstream handle unreachable-vs-stale (#2402). Any non-ephemeral
|
||||
# failure or a non-500 HTTP response remains a hard failure.
|
||||
OK=$(jq -r '.ok // "false"' "$HTTP_RESPONSE")
|
||||
FAILED_SLUGS=$(jq -r '
|
||||
.results[]?
|
||||
| select((.healthz_ok != true) or (.ssm_status != "Success"))
|
||||
| .slug' "$HTTP_RESPONSE" 2>/dev/null || true)
|
||||
EPHEMERAL_PREFIX_RE='^(e2e-|rt-e2e-)'
|
||||
NON_EPHEMERAL_FAILED=$(printf '%s\n' "$FAILED_SLUGS" | grep -v '^$' | grep -Ev "$EPHEMERAL_PREFIX_RE" || true)
|
||||
|
||||
if [ "$HTTP_CODE" = "200" ] && [ "$OK" = "true" ]; then
|
||||
: # happy path — fall through to verification
|
||||
elif [ "$HTTP_CODE" = "500" ] && [ -z "$NON_EPHEMERAL_FAILED" ] && [ -n "$FAILED_SLUGS" ]; then
|
||||
COUNT=$(printf '%s\n' "$FAILED_SLUGS" | grep -Ec "$EPHEMERAL_PREFIX_RE" || true)
|
||||
echo "::warning::redeploy-fleet returned HTTP 500 but every failed tenant ($COUNT) is ephemeral (e2e-*/rt-e2e-*) — treating as teardown race, soft-warning."
|
||||
printf '%s\n' "$FAILED_SLUGS" | sed 's/^/::warning:: failed: /'
|
||||
elif [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
|
||||
if [ -n "$NON_EPHEMERAL_FAILED" ]; then
|
||||
echo "::error::non-ephemeral tenant(s) failed:"
|
||||
printf '%s\n' "$NON_EPHEMERAL_FAILED" | sed 's/^/::error:: /'
|
||||
fi
|
||||
exit 1
|
||||
else
|
||||
# HTTP=200 but ok=false (shouldn't happen with current CP
|
||||
# but keep the gate for completeness).
|
||||
echo "::error::redeploy-fleet reported ok=false (see summary for which tenant halted the rollout)"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Staging tenant fleet redeploy reported ssm_status=Success — verifying actual image roll on each tenant..."
|
||||
|
||||
cp "$HTTP_RESPONSE" "$RUNNER_TEMP/redeploy-response.json"
|
||||
|
||||
- name: Verify each staging tenant /buildinfo matches published SHA
|
||||
# Mirror of the verify step in redeploy-tenants-on-main.yml — see
|
||||
# there for the rationale (#2395 root fix). Staging has the same
|
||||
# ssm_status-success-but-stale-image hazard and benefits from the
|
||||
# same gate. Diff: TENANT_DOMAIN includes the `staging.` infix.
|
||||
env:
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
TARGET_TAG: ${{ inputs.target_tag || 'staging-latest' }}
|
||||
TENANT_DOMAIN: 'staging.moleculesai.app'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# staging-latest is the staging-side moving tag; treat it the
|
||||
# same way main treats `latest`. Operator-pinned SHAs skip
|
||||
# verification (see main variant for why).
|
||||
if [ "$TARGET_TAG" != "staging-latest" ] && [ "$TARGET_TAG" != "latest" ] && [ "$TARGET_TAG" != "$EXPECTED_SHA" ]; then
|
||||
echo "::notice::target_tag=$TARGET_TAG (operator-pinned) — skipping per-tenant SHA verification."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESP="$RUNNER_TEMP/redeploy-response.json"
|
||||
if [ ! -s "$RESP" ]; then
|
||||
echo "::error::redeploy-response.json missing or empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mapfile -t SLUGS < <(jq -r '.results[]? | select(.healthz_ok == true) | .slug' "$RESP")
|
||||
if [ ${#SLUGS[@]} -eq 0 ]; then
|
||||
echo "::warning::No staging tenants reported healthz_ok — nothing to verify"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Verifying ${#SLUGS[@]} staging tenant(s) against EXPECTED_SHA=${EXPECTED_SHA:0:7}..."
|
||||
|
||||
# Two distinct failure modes here:
|
||||
# STALE_COUNT — tenant returned a SHA that doesn't match. THIS is
|
||||
# the #2395 bug class: tenant up + serving old code.
|
||||
# Always hard-fail the workflow.
|
||||
# UNREACHABLE_COUNT — tenant didn't respond. Almost always a benign
|
||||
# teardown race: redeploy-fleet snapshot says
|
||||
# healthz_ok=true, then the E2E suite tears the
|
||||
# ephemeral tenant down before this step runs (the
|
||||
# e2e-* fixtures churn 5-10/hour on staging). Soft-
|
||||
# warn so we don't block staging→main on cleanup.
|
||||
# Real "tenant up but unreachable" is caught by CP's
|
||||
# own healthz monitor + the post-redeploy alert; we
|
||||
# don't need to double-count it here.
|
||||
STALE_COUNT=0
|
||||
UNREACHABLE_COUNT=0
|
||||
STALE_LINES=()
|
||||
UNREACHABLE_LINES=()
|
||||
for slug in "${SLUGS[@]}"; do
|
||||
URL="https://${slug}.${TENANT_DOMAIN}/buildinfo"
|
||||
BODY=$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$URL" || true)
|
||||
ACTUAL_SHA=$(echo "$BODY" | jq -r '.git_sha // ""' 2>/dev/null || echo "")
|
||||
if [ -z "$ACTUAL_SHA" ]; then
|
||||
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
|
||||
UNREACHABLE_LINES+=("| $slug | (no /buildinfo response) | ${EXPECTED_SHA:0:7} | ⚠ unreachable (likely teardown race) |")
|
||||
continue
|
||||
fi
|
||||
if [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ]; then
|
||||
echo " $slug: ${ACTUAL_SHA:0:7} ✓"
|
||||
else
|
||||
STALE_COUNT=$((STALE_COUNT + 1))
|
||||
STALE_LINES+=("| $slug | ${ACTUAL_SHA:0:7} | ${EXPECTED_SHA:0:7} | ❌ stale |")
|
||||
fi
|
||||
done
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "### Per-tenant /buildinfo verification (staging)"
|
||||
echo ""
|
||||
echo "Expected SHA: \`${EXPECTED_SHA:0:7}\`"
|
||||
echo ""
|
||||
if [ $STALE_COUNT -gt 0 ]; then
|
||||
echo "**${STALE_COUNT} STALE tenant(s) — these did NOT pick up the new image despite ssm_status=Success:**"
|
||||
echo ""
|
||||
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
|
||||
echo "|------|----------------------|----------|--------|"
|
||||
for line in "${STALE_LINES[@]}"; do echo "$line"; done
|
||||
echo ""
|
||||
fi
|
||||
if [ $UNREACHABLE_COUNT -gt 0 ]; then
|
||||
echo "**${UNREACHABLE_COUNT} unreachable tenant(s) — likely E2E teardown race (soft-warn, not failing):**"
|
||||
echo ""
|
||||
echo "| Slug | Actual /buildinfo SHA | Expected | Status |"
|
||||
echo "|------|----------------------|----------|--------|"
|
||||
for line in "${UNREACHABLE_LINES[@]}"; do echo "$line"; done
|
||||
echo ""
|
||||
fi
|
||||
if [ $STALE_COUNT -eq 0 ] && [ $UNREACHABLE_COUNT -eq 0 ]; then
|
||||
echo "All ${#SLUGS[@]} staging tenants returned matching SHA. ✓"
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ $UNREACHABLE_COUNT -gt 0 ]; then
|
||||
echo "::warning::$UNREACHABLE_COUNT staging tenant(s) unreachable post-redeploy. Likely benign teardown race — CP healthz monitor catches real outages."
|
||||
fi
|
||||
|
||||
# Belt-and-suspenders sanity floor: if MORE than half the fleet is
|
||||
# unreachable AND the fleet is large enough that "half down" is
|
||||
# statistically meaningful, this is a real outage (e.g. new image
|
||||
# crashes on startup), not a teardown race. Hard-fail.
|
||||
#
|
||||
# Floor only applies when TOTAL_VERIFIED >= 4 — below that, the
|
||||
# canary-verify step is the actual gate for "all tenants down"
|
||||
# detection (it runs against the canary first and aborts the
|
||||
# rollout if the canary fails to come up). Without the >=4 gate,
|
||||
# a 1-tenant fleet (e.g. a single ephemeral e2e-* tenant on a
|
||||
# quiet staging push) would re-flake on the exact teardown-race
|
||||
# condition #2402 fixed: 1 of 1 unreachable = 100% > 50% → fail.
|
||||
TOTAL_VERIFIED=${#SLUGS[@]}
|
||||
if [ $TOTAL_VERIFIED -ge 4 ] && [ $UNREACHABLE_COUNT -gt $((TOTAL_VERIFIED / 2)) ]; then
|
||||
echo "::error::$UNREACHABLE_COUNT of $TOTAL_VERIFIED staging tenant(s) unreachable — exceeds 50% threshold on a fleet large enough that this signals a real outage, not teardown race."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ $STALE_COUNT -gt 0 ]; then
|
||||
echo "::error::$STALE_COUNT staging tenant(s) returned a stale SHA. ssm_status=Success was misleading — see job summary."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice::Staging tenant fleet redeploy complete — all reachable tenants on ${EXPECTED_SHA:0:7} (${UNREACHABLE_COUNT} unreachable, soft-warned)."
|
||||
+12
-12
@@ -57,24 +57,24 @@ See `CLAUDE.md` for a full list of environment variables and their purposes.
|
||||
|
||||
This repo is scoped to **code** (canvas, workspace, workspace-server, related
|
||||
infra). Public content (blog posts, marketing copy, OG images, SEO briefs,
|
||||
DevRel demos) lives in [`Molecule-AI/docs`](https://git.moleculesai.app/molecule-ai/docs).
|
||||
DevRel demos) lives in [`molecule-ai/docs`](https://git.moleculesai.app/molecule-ai/docs).
|
||||
The `Block forbidden paths` CI gate fails any PR that writes to `marketing/`
|
||||
or other removed paths — open against `Molecule-AI/docs` instead.
|
||||
or other removed paths — open against `molecule-ai/docs` instead.
|
||||
|
||||
| Content type | Target |
|
||||
|---|---|
|
||||
| Blog posts | `Molecule-AI/docs` → `content/blog/<YYYY-MM-DD-slug>/` |
|
||||
| Doc pages | `Molecule-AI/docs` → `content/docs/` |
|
||||
| Marketing copy / PMM positioning | `Molecule-AI/docs` → `marketing/` |
|
||||
| OG images, visual assets | `Molecule-AI/docs` → `app/` or `marketing/` |
|
||||
| SEO briefs | `Molecule-AI/docs` → `marketing/` |
|
||||
| DevRel demos (runnable code) | Standalone repo under `Molecule-AI/`, OR embedded in `Molecule-AI/docs` |
|
||||
| Blog posts | `molecule-ai/docs` → `content/blog/<YYYY-MM-DD-slug>/` |
|
||||
| Doc pages | `molecule-ai/docs` → `content/docs/` |
|
||||
| Marketing copy / PMM positioning | `molecule-ai/docs` → `marketing/` |
|
||||
| OG images, visual assets | `molecule-ai/docs` → `app/` or `marketing/` |
|
||||
| SEO briefs | `molecule-ai/docs` → `marketing/` |
|
||||
| DevRel demos (runnable code) | Standalone repo under `molecule-ai/`, OR embedded in `molecule-ai/docs` |
|
||||
| Launch checklists, internal tracking | GitHub Issues — **not** committed files |
|
||||
| Engineering docs (`docs/adr/`, `docs/architecture/`, `docs/incidents/`) | This repo (internal, not published) |
|
||||
| Live product pages (e.g. `canvas/src/app/pricing/page.tsx`) | This repo (these are app code, not marketing copy) |
|
||||
|
||||
If a PR fails the `Block forbidden paths` check, the contents belong in
|
||||
`Molecule-AI/docs`. No CI drag, no Canvas E2E, content lands in minutes.
|
||||
`molecule-ai/docs`. No CI drag, no Canvas E2E, content lands in minutes.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -190,9 +190,9 @@ Runs the full regression suite against a fixture HTTP server. No network access
|
||||
Code in this repo lands in molecule-core. Some related runtime artifacts
|
||||
live in their own repos:
|
||||
|
||||
- [`Molecule-AI/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
|
||||
- [`Molecule-AI/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
|
||||
- [`Molecule-AI/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install with `claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel`.
|
||||
- [`molecule-ai/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
|
||||
- [`molecule-ai/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
|
||||
- [`molecule-ai/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install inside Claude Code via `/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel`.
|
||||
|
||||
When extending the **A2A surface** in molecule-core (`workspace-server/internal/handlers/a2a_proxy.go` etc.), consider whether the change has a downstream impact on the runtime SDK or the channel plugin — they're versioned independently but share the wire shape.
|
||||
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
# use this Makefile; CI calls docker compose / go test directly so the
|
||||
# Makefile can evolve without breaking the build.
|
||||
|
||||
.PHONY: help dev up down logs build test
|
||||
.PHONY: help dev up down logs build test e2e-peer-visibility
|
||||
|
||||
help: ## Show this help.
|
||||
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-12s\033[0m %s\n", $$1, $$2}'
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-22s\033[0m %s\n", $$1, $$2}'
|
||||
|
||||
dev: ## Start the full stack with air hot-reload for the platform service.
|
||||
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
||||
@@ -26,3 +26,13 @@ build: ## Force a fresh build of the platform image (no cache).
|
||||
|
||||
test: ## Run Go unit tests in workspace-server/.
|
||||
cd workspace-server && go test -race ./...
|
||||
|
||||
# ─── Local prod-mimic E2E gates ────────────────────────────────────────
|
||||
# Run the LITERAL peer-visibility MCP list_peers gate against the
|
||||
# already-running local stack (`make up` or `make dev`). Same byte-
|
||||
# identical assertion as the staging gate — only provisioning differs.
|
||||
# Skips any runtime whose provider key is absent (partially-keyed env
|
||||
# is fine). See tests/e2e/test_peer_visibility_mcp_local.sh for the
|
||||
# env contract (CLAUDE_CODE_OAUTH_TOKEN / E2E_MINIMAX_API_KEY / etc).
|
||||
e2e-peer-visibility: ## Run the LOCAL peer-visibility MCP gate vs the running stack (needs `make up` first).
|
||||
bash tests/e2e/test_peer_visibility_mcp_local.sh
|
||||
|
||||
@@ -238,7 +238,7 @@ The result is not just “an agent that learns.” It is **an organization that
|
||||
- subscribe to one or more workspaces; peer messages surface as conversation turns; replies route back through Molecule's A2A
|
||||
- no tunnel, no public endpoint — the plugin self-registers each watched workspace as `delivery_mode=poll` and long-polls `/activity?since_id=…`
|
||||
- multi-tenant friendly: one plugin install can watch workspaces across multiple Molecule tenants (`MOLECULE_PLATFORM_URLS` per-workspace)
|
||||
- install via the standard marketplace flow: `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
|
||||
- install via the standard marketplace flow: `/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel`
|
||||
|
||||
## Built For Teams That Need More Than A Demo
|
||||
|
||||
|
||||
+1
-1
@@ -237,7 +237,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
- 订阅一个或多个 workspace;peer 的消息会以 user-turn 出现,回复会经 Molecule A2A 路由出去
|
||||
- 无需公网隧道、无需公开端点 —— 插件启动时自动把每个 watched workspace 注册成 `delivery_mode=poll`,长轮询 `/activity?since_id=…`
|
||||
- 多租户友好:单次安装即可同时 watch 跨多个 Molecule 租户的 workspace(`MOLECULE_PLATFORM_URLS` 按 workspace 配置)
|
||||
- 通过标准 marketplace 流程安装:`/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
|
||||
- 通过标准 marketplace 流程安装:`/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`,然后用 `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel` 启动
|
||||
|
||||
## 适合什么团队
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). These tests pin the public crawler
|
||||
// contract: anything that flips public marketing routes to disallow,
|
||||
// drops the sitemap from robots.txt, or removes the OG image
|
||||
// reference from root metadata should fail loudly here.
|
||||
|
||||
// next/font and the rest of the layout's runtime tree are not
|
||||
// vitest-compatible (next/font expects the Next.js compiler swc
|
||||
// transform). We import layout.tsx only for its exported `metadata`
|
||||
// constant — mock the font module to a constructor-returning stub.
|
||||
vi.mock("next/font/google", () => ({
|
||||
Inter: () => ({ variable: "--font-inter" }),
|
||||
JetBrains_Mono: () => ({ variable: "--font-jetbrains" }),
|
||||
}));
|
||||
|
||||
import robots from "../robots";
|
||||
import sitemap from "../sitemap";
|
||||
import { metadata } from "../layout";
|
||||
|
||||
describe("robots.ts", () => {
|
||||
it("allows public marketing routes and blocks authed/app routes", () => {
|
||||
const r = robots();
|
||||
expect(r.rules).toBeDefined();
|
||||
const rule = Array.isArray(r.rules) ? r.rules[0] : r.rules!;
|
||||
expect(rule.userAgent).toBe("*");
|
||||
const allow = Array.isArray(rule.allow) ? rule.allow : [rule.allow];
|
||||
expect(allow).toEqual(expect.arrayContaining(["/", "/pricing", "/blog"]));
|
||||
const disallow = Array.isArray(rule.disallow)
|
||||
? rule.disallow
|
||||
: [rule.disallow];
|
||||
expect(disallow).toEqual(
|
||||
expect.arrayContaining(["/api/", "/orgs", "/cp/"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("declares the sitemap URL", () => {
|
||||
const r = robots();
|
||||
expect(r.sitemap).toMatch(/\/sitemap\.xml$/);
|
||||
});
|
||||
|
||||
it("declares a canonical host", () => {
|
||||
const r = robots();
|
||||
expect(r.host).toMatch(/^https:\/\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sitemap.ts", () => {
|
||||
it("includes apex, pricing, and the live blog post", () => {
|
||||
const entries = sitemap();
|
||||
const urls = entries.map((e) => e.url);
|
||||
expect(urls.some((u) => u.endsWith("/"))).toBe(true);
|
||||
expect(urls.some((u) => u.endsWith("/pricing"))).toBe(true);
|
||||
expect(
|
||||
urls.some((u) => u.includes("/blog/2026-04-20-chrome-devtools-mcp")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT include authed/app routes", () => {
|
||||
const entries = sitemap();
|
||||
const urls = entries.map((e) => e.url);
|
||||
expect(urls.some((u) => u.includes("/orgs"))).toBe(false);
|
||||
expect(urls.some((u) => u.includes("/api/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("sets a non-zero priority and a valid changeFrequency on every entry", () => {
|
||||
const valid = new Set([
|
||||
"always",
|
||||
"hourly",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"yearly",
|
||||
"never",
|
||||
]);
|
||||
for (const e of sitemap()) {
|
||||
expect(e.priority).toBeGreaterThan(0);
|
||||
expect(valid.has(String(e.changeFrequency))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("root layout metadata", () => {
|
||||
it("sets a templated title + non-empty description", () => {
|
||||
const t = metadata.title as { default: string; template: string };
|
||||
expect(t.default).toMatch(/Molecule AI/);
|
||||
expect(t.template).toMatch(/%s/);
|
||||
expect((metadata.description ?? "").length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("declares OG + Twitter text fields (image comes from opengraph-image.tsx)", () => {
|
||||
const og = metadata.openGraph;
|
||||
expect(og).toBeDefined();
|
||||
expect((og as { title: string }).title).toMatch(/Molecule AI/);
|
||||
expect((og as { description: string }).description.length).toBeGreaterThan(
|
||||
50,
|
||||
);
|
||||
const tw = metadata.twitter;
|
||||
expect(tw).toBeDefined();
|
||||
// Next.js typings narrow twitter.card to a union — assert via cast.
|
||||
expect((tw as { card: string }).card).toBe("summary_large_image");
|
||||
});
|
||||
|
||||
it("sets a canonical alternate", () => {
|
||||
expect(metadata.alternates?.canonical).toBe("/");
|
||||
});
|
||||
|
||||
it("enables indexing at the metadata level (robots.ts owns per-route)", () => {
|
||||
const r = metadata.robots as { index: boolean; follow: boolean };
|
||||
expect(r.index).toBe(true);
|
||||
expect(r.follow).toBe(true);
|
||||
});
|
||||
});
|
||||
+140
-2
@@ -27,9 +27,78 @@ import {
|
||||
themeBootScript,
|
||||
} from "@/lib/theme-cookie";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Canonical apex is app.moleculesai.app —
|
||||
// tenant subdomains (<slug>.moleculesai.app) reuse the same Next.js build
|
||||
// but are gated behind auth (AuthGate redirects anonymous → /cp/auth/login)
|
||||
// and are de-indexed in robots.ts. The metadata here applies to the
|
||||
// public marketing surface served from the apex host.
|
||||
//
|
||||
// Override per-route by exporting a page-level `metadata`/`generateMetadata`
|
||||
// — Next.js merges page metadata over layout metadata using
|
||||
// `title.template` for "<page> | Molecule AI" composition.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Molecule AI",
|
||||
description: "AI Org Chart Canvas",
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "Molecule AI — the AI org chart canvas",
|
||||
template: "%s | Molecule AI",
|
||||
},
|
||||
description:
|
||||
"Molecule AI is an org-chart canvas for AI agent teams. Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace with credit metering, audit, and one-click runtime provisioning.",
|
||||
applicationName: "Molecule AI",
|
||||
keywords: [
|
||||
"AI agents",
|
||||
"multi-agent",
|
||||
"agent orchestration",
|
||||
"AI org chart",
|
||||
"Claude Code",
|
||||
"Codex",
|
||||
"MCP",
|
||||
"agent governance",
|
||||
"A2A",
|
||||
"agent runtime",
|
||||
],
|
||||
authors: [{ name: "Molecule AI" }],
|
||||
creator: "Molecule AI",
|
||||
publisher: "Molecule AI",
|
||||
alternates: { canonical: "/" },
|
||||
// OG + Twitter images come from the file-convention sibling
|
||||
// `opengraph-image.tsx` — Next.js auto-attaches them to og:image
|
||||
// and twitter:image when present at the segment root. We keep the
|
||||
// text fields here so they win over per-page metadata when a page
|
||||
// doesn't override them. `images: []` as the structural fallback
|
||||
// for hosts that won't follow the file convention; the real URL
|
||||
// is injected by Next.js at build time from opengraph-image.tsx.
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "Molecule AI",
|
||||
url: SITE_URL,
|
||||
title: "Molecule AI — the AI org chart canvas",
|
||||
description:
|
||||
"Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace. Credit metering, audit, and one-click runtime provisioning.",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Molecule AI — the AI org chart canvas",
|
||||
description:
|
||||
"Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace.",
|
||||
},
|
||||
icons: {
|
||||
icon: "/molecule-icon.png",
|
||||
apple: "/molecule-icon.png",
|
||||
},
|
||||
// robots.ts owns the per-route allow/disallow contract; this is the
|
||||
// header-level fallback for routes the crawler reaches before
|
||||
// robots.txt resolves. Default = index public marketing routes;
|
||||
// app/auth/api/orgs are noindex'd by robots.ts.
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: { index: true, follow: true, "max-image-preview": "large" },
|
||||
},
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
@@ -94,6 +163,75 @@ export default async function RootLayout({
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: themeBootScript }}
|
||||
/>
|
||||
{/*
|
||||
* JSON-LD structured data (mc#1486). Two graph nodes:
|
||||
*
|
||||
* - Organization: surfaces the brand to Google Knowledge
|
||||
* Graph + Bing entity index. URL+logo+sameAs are the
|
||||
* minimum recommended set for new brands without a
|
||||
* Wikipedia page.
|
||||
*
|
||||
* - WebSite: enables the sitelinks search box and tells
|
||||
* crawlers the canonical site URL when the same content
|
||||
* is reachable via multiple subdomains (apex + tenant).
|
||||
*
|
||||
* Type-application/ld+json runs synchronously without
|
||||
* executing JS, so 'strict-dynamic' isn't required — we still
|
||||
* carry the nonce because production CSP's default-src 'self'
|
||||
* applies to any <script> element. The "type" attribute is
|
||||
* what keeps the browser from running the body as JS, but
|
||||
* CSP nonces are gated on the element not the type, so we
|
||||
* include the nonce too.
|
||||
*/}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}#organization`,
|
||||
name: "Molecule AI",
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/molecule-icon.png`,
|
||||
sameAs: [
|
||||
"https://github.com/molecule-ai",
|
||||
"https://x.com/moleculeai",
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${SITE_URL}#website`,
|
||||
url: SITE_URL,
|
||||
name: "Molecule AI",
|
||||
publisher: { "@id": `${SITE_URL}#organization` },
|
||||
inLanguage: "en-US",
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${SITE_URL}#software`,
|
||||
name: "Molecule AI",
|
||||
applicationCategory: "DeveloperApplication",
|
||||
operatingSystem: "Web",
|
||||
description:
|
||||
"Org-chart canvas for AI agent teams with credit metering, audit, and one-click runtime provisioning.",
|
||||
url: SITE_URL,
|
||||
offers: {
|
||||
"@type": "AggregateOffer",
|
||||
priceCurrency: "USD",
|
||||
lowPrice: "0",
|
||||
highPrice: "99",
|
||||
offerCount: "3",
|
||||
url: `${SITE_URL}/pricing`,
|
||||
},
|
||||
publisher: { "@id": `${SITE_URL}#organization` },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`bg-surface text-ink ${interFont.variable} ${monoFont.variable}`}>
|
||||
<ThemeProvider initialTheme={theme}>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Next.js App-Router file-system OG
|
||||
// convention: served as `/opengraph-image` and auto-attached as
|
||||
// `og:image` + `twitter:image`. Dynamic (not a static PNG in /public)
|
||||
// so we can iterate the brand mark + tagline pre-launch without
|
||||
// churning a binary blob in git history.
|
||||
export const runtime = "edge";
|
||||
|
||||
export const alt = "Molecule AI — the AI org chart canvas";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default function OG() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
padding: "80px",
|
||||
background:
|
||||
"linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 60%, #16213e 100%)",
|
||||
color: "#ffffff",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: "#a3a3c2",
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Molecule AI
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 76,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.05,
|
||||
letterSpacing: "-0.02em",
|
||||
maxWidth: 980,
|
||||
}}
|
||||
>
|
||||
The AI org chart canvas
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "#c8c8d8",
|
||||
marginTop: 32,
|
||||
lineHeight: 1.3,
|
||||
maxWidth: 980,
|
||||
}}
|
||||
>
|
||||
Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed
|
||||
multi-agent workspace.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 80,
|
||||
bottom: 80,
|
||||
fontSize: 22,
|
||||
color: "#7a7a96",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
moleculesai.app
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ export default function Home() {
|
||||
setHydrationError(null);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -115,7 +115,9 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Canvas />
|
||||
<main aria-label="Agent canvas">
|
||||
<Canvas />
|
||||
</main>
|
||||
<Legend />
|
||||
<CommunicationOverlay />
|
||||
{hydrationError && (
|
||||
@@ -134,7 +136,7 @@ export default function Home() {
|
||||
setHydrationError(null);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -176,7 +178,7 @@ brew services start redis`}</pre>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Next.js App-Router robots convention:
|
||||
// this file is served as `/robots.txt` at build time and is the single
|
||||
// source of truth for crawler allow/disallow.
|
||||
//
|
||||
// Contract:
|
||||
// - Public marketing routes (/, /pricing, /blog/*) are crawlable.
|
||||
// - Authed/app routes (/orgs, /api/*) are noindex'd. They render
|
||||
// useful content only after a session round-trip, so a crawler hit
|
||||
// just wastes our crawl budget and exposes endpoint shapes.
|
||||
// - Tenant subdomains (<slug>.moleculesai.app) share this build but
|
||||
// are blocked at the host level by the canvas middleware sending
|
||||
// an `X-Robots-Tag: noindex` header — robots.txt is per-host and
|
||||
// this file's `host` field claims the apex as canonical.
|
||||
//
|
||||
// Note: `sitemap` is published via the sibling `sitemap.ts` route; we
|
||||
// reference it explicitly here so crawlers don't have to guess.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/pricing", "/blog"],
|
||||
// Authed app surface + API + transient checkout returns. The
|
||||
// /orgs route boots the org-selector behind AuthGate; even
|
||||
// though SSR returns markup, that markup is a login wall when
|
||||
// hit by an unauthenticated crawler, so indexing it dilutes
|
||||
// brand searches with a "Please sign in" snippet.
|
||||
disallow: [
|
||||
"/orgs",
|
||||
"/orgs/",
|
||||
"/api/",
|
||||
"/cp/",
|
||||
"/checkout/",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
host: SITE_URL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). App-Router sitemap convention: this
|
||||
// file is served as `/sitemap.xml` and enumerates the public marketing
|
||||
// surface for search crawlers + AI training pipelines.
|
||||
//
|
||||
// Scope deliberately narrow:
|
||||
// - Apex landing, pricing, and the (currently single) blog post.
|
||||
// - Authed app routes are excluded — they're disallowed in robots.ts
|
||||
// and would appear as "Please sign in" wall to a crawler.
|
||||
//
|
||||
// `lastModified` uses a build-time timestamp rather than per-route
|
||||
// fs.stat so the same value applies regardless of where the build
|
||||
// runs (Vercel/Railway/local). When we add CMS-backed blog content,
|
||||
// swap to a per-entry timestamp from the source-of-truth metadata.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
const BUILD_DATE = new Date();
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: `${SITE_URL}/`,
|
||||
lastModified: BUILD_DATE,
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/pricing`,
|
||||
lastModified: BUILD_DATE,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog/2026-04-20-chrome-devtools-mcp`,
|
||||
lastModified: new Date("2026-04-20"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center h-32">
|
||||
<span className="text-xs text-ink-mid">Loading audit trail…</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -133,13 +133,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading && (
|
||||
<div className="text-xs text-ink-mid text-center py-8">
|
||||
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">
|
||||
Loading trace from all workspaces...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && entries.length === 0 && (
|
||||
<div className="text-xs text-ink-mid text-center py-8">
|
||||
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">
|
||||
No activity found
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function EmptyState() {
|
||||
|
||||
{/* Template grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-ink-mid py-4">
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-mid py-4">
|
||||
<Spinner />
|
||||
Loading templates...
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// ($AGENT_URL). They ARE NOT filled in server-side because the
|
||||
// server doesn't know where the operator's agent will live.
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "kimi" | "fields";
|
||||
@@ -84,6 +84,33 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
: "python";
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const tabRefs = useRef<Map<Tab, HTMLButtonElement | null>>(new Map());
|
||||
|
||||
const handleTabKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>, current: Tab, tabs: Tab[]) => {
|
||||
const idx = tabs.indexOf(current);
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const next = tabs[(idx + 1) % tabs.length];
|
||||
setTab(next);
|
||||
tabRefs.current.get(next)?.focus();
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prev = tabs[(idx - 1 + tabs.length) % tabs.length];
|
||||
setTab(prev);
|
||||
tabRefs.current.get(prev)?.focus();
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setTab(tabs[0]);
|
||||
tabRefs.current.get(tabs[0])?.focus();
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
setTab(tabs[tabs.length - 1]);
|
||||
tabRefs.current.get(tabs[tabs.length - 1])?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const copy = useCallback(async (value: string, key: string) => {
|
||||
try {
|
||||
@@ -160,6 +187,19 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
`MOLECULE_WORKSPACE_TOKEN=${info.auth_token}`,
|
||||
);
|
||||
|
||||
// Build the tab list once so both the tab bar and keyboard handler
|
||||
// share the same ordered array. Computed here (after all filled* vars)
|
||||
// so TypeScript's block-scoping analysis can reach them.
|
||||
const tabList: Tab[] = [];
|
||||
if (filledUniversalMcp) tabList.push("mcp");
|
||||
tabList.push("python");
|
||||
if (filledChannel) tabList.push("claude");
|
||||
if (filledHermes) tabList.push("hermes");
|
||||
if (filledCodex) tabList.push("codex");
|
||||
if (filledOpenClaw) tabList.push("openclaw");
|
||||
if (filledKimi) tabList.push("kimi");
|
||||
tabList.push("curl", "fields");
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog.Portal>
|
||||
@@ -180,34 +220,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
aria-label="Connection snippet format"
|
||||
className="mt-4 flex gap-1 border-b border-line"
|
||||
>
|
||||
{(() => {
|
||||
// Build the tab order dynamically. Claude Code first
|
||||
// (when offered) since it's the simplest setup; Python
|
||||
// SDK second (full register+heartbeat+inbound); Universal
|
||||
// MCP third (any MCP-aware runtime, outbound-only); curl
|
||||
// for one-shot register; Fields for raw values.
|
||||
// Tab order: Universal MCP first (default, runtime-
|
||||
// agnostic primitives), then runtime-specific channel/
|
||||
// SDK tabs, then curl + Fields. Each runtime tab only
|
||||
// appears when the platform supplies the snippet — no
|
||||
// dead "tab missing snippet" UX.
|
||||
const tabs: Tab[] = [];
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
if (filledKimi) tabs.push("kimi");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
{tabList.map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`tab-${t}`}
|
||||
aria-selected={tab === t}
|
||||
aria-controls={`panel-${t}`}
|
||||
tabIndex={tab === t ? 0 : -1}
|
||||
ref={(el) => { tabRefs.current.set(t, el); }}
|
||||
onClick={() => setTab(t)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, t, tabList)}
|
||||
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
|
||||
tab === t
|
||||
? "border-accent text-ink"
|
||||
@@ -235,18 +259,39 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Snippet area */}
|
||||
<div className="mt-3">
|
||||
{tab === "claude" && filledChannel && (
|
||||
<SnippetBlock
|
||||
value={filledChannel}
|
||||
label="Claude Code channel — polls workspace's A2A; no tunnel needed"
|
||||
copyKey="claude"
|
||||
copied={copiedKey === "claude"}
|
||||
onCopy={() => copy(filledChannel, "claude")}
|
||||
/>
|
||||
)}
|
||||
{tab === "python" && (
|
||||
{/* Snippet area — all panels always in the DOM so aria-controls
|
||||
targets are stable. Hidden panels use aria-hidden so screen
|
||||
readers skip them; active panel uses role=tabpanel with
|
||||
aria-labelledby pointing to the tab button. */}
|
||||
<div className="mt-3" data-testid="snippet-panels">
|
||||
{/* Claude Code tab */}
|
||||
<div
|
||||
id="panel-claude"
|
||||
data-testid="panel-claude"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-claude"
|
||||
hidden={tab !== "claude" || !filledChannel}
|
||||
className={tab === "claude" && filledChannel ? "" : "hidden"}
|
||||
>
|
||||
{filledChannel && (
|
||||
<SnippetBlock
|
||||
value={filledChannel}
|
||||
label="Claude Code channel — polls workspace's A2A; no tunnel needed"
|
||||
copyKey="claude"
|
||||
copied={copiedKey === "claude"}
|
||||
onCopy={() => copy(filledChannel, "claude")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Python SDK tab */}
|
||||
<div
|
||||
id="panel-python"
|
||||
data-testid="panel-python"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-python"
|
||||
hidden={tab !== "python"}
|
||||
className={tab === "python" ? "" : "hidden"}
|
||||
>
|
||||
<SnippetBlock
|
||||
value={filledPython}
|
||||
label="Python SDK — includes heartbeat loop (push-mode, needs public URL)"
|
||||
@@ -254,8 +299,16 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
copied={copiedKey === "python"}
|
||||
onCopy={() => copy(filledPython, "python")}
|
||||
/>
|
||||
)}
|
||||
{tab === "curl" && (
|
||||
</div>
|
||||
{/* curl tab */}
|
||||
<div
|
||||
id="panel-curl"
|
||||
data-testid="panel-curl"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-curl"
|
||||
hidden={tab !== "curl"}
|
||||
className={tab === "curl" ? "" : "hidden"}
|
||||
>
|
||||
<SnippetBlock
|
||||
value={filledCurl}
|
||||
label="curl — one-shot register only (no heartbeat)"
|
||||
@@ -263,53 +316,111 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
copied={copiedKey === "curl"}
|
||||
onCopy={() => copy(filledCurl, "curl")}
|
||||
/>
|
||||
)}
|
||||
{tab === "mcp" && filledUniversalMcp && (
|
||||
<SnippetBlock
|
||||
value={filledUniversalMcp}
|
||||
label="Universal MCP — standalone register + heartbeat + tools for any MCP-aware runtime (Claude Code, hermes, codex). Pair with Python or Claude Code tab if you need inbound A2A delivery."
|
||||
copyKey="mcp"
|
||||
copied={copiedKey === "mcp"}
|
||||
onCopy={() => copy(filledUniversalMcp, "mcp")}
|
||||
/>
|
||||
)}
|
||||
{tab === "hermes" && filledHermes && (
|
||||
<SnippetBlock
|
||||
value={filledHermes}
|
||||
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
|
||||
copyKey="hermes"
|
||||
copied={copiedKey === "hermes"}
|
||||
onCopy={() => copy(filledHermes, "hermes")}
|
||||
/>
|
||||
)}
|
||||
{tab === "codex" && filledCodex && (
|
||||
<SnippetBlock
|
||||
value={filledCodex}
|
||||
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
|
||||
copyKey="codex"
|
||||
copied={copiedKey === "codex"}
|
||||
onCopy={() => copy(filledCodex, "codex")}
|
||||
/>
|
||||
)}
|
||||
{tab === "openclaw" && filledOpenClaw && (
|
||||
<SnippetBlock
|
||||
value={filledOpenClaw}
|
||||
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
|
||||
copyKey="openclaw"
|
||||
copied={copiedKey === "openclaw"}
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
{tab === "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>
|
||||
{/* Universal MCP tab */}
|
||||
<div
|
||||
id="panel-mcp"
|
||||
data-testid="panel-mcp"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-mcp"
|
||||
hidden={tab !== "mcp" || !filledUniversalMcp}
|
||||
className={tab === "mcp" && filledUniversalMcp ? "" : "hidden"}
|
||||
>
|
||||
{filledUniversalMcp && (
|
||||
<SnippetBlock
|
||||
value={filledUniversalMcp}
|
||||
label="Universal MCP — standalone register + heartbeat + tools for any MCP-aware runtime (Claude Code, hermes, codex). Pair with Python or Claude Code tab if you need inbound A2A delivery."
|
||||
copyKey="mcp"
|
||||
copied={copiedKey === "mcp"}
|
||||
onCopy={() => copy(filledUniversalMcp, "mcp")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Hermes tab */}
|
||||
<div
|
||||
id="panel-hermes"
|
||||
data-testid="panel-hermes"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-hermes"
|
||||
hidden={tab !== "hermes" || !filledHermes}
|
||||
className={tab === "hermes" && filledHermes ? "" : "hidden"}
|
||||
>
|
||||
{filledHermes && (
|
||||
<SnippetBlock
|
||||
value={filledHermes}
|
||||
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
|
||||
copyKey="hermes"
|
||||
copied={copiedKey === "hermes"}
|
||||
onCopy={() => copy(filledHermes, "hermes")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Codex tab */}
|
||||
<div
|
||||
id="panel-codex"
|
||||
data-testid="panel-codex"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-codex"
|
||||
hidden={tab !== "codex" || !filledCodex}
|
||||
className={tab === "codex" && filledCodex ? "" : "hidden"}
|
||||
>
|
||||
{filledCodex && (
|
||||
<SnippetBlock
|
||||
value={filledCodex}
|
||||
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
|
||||
copyKey="codex"
|
||||
copied={copiedKey === "codex"}
|
||||
onCopy={() => copy(filledCodex, "codex")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* OpenClaw tab */}
|
||||
<div
|
||||
id="panel-openclaw"
|
||||
data-testid="panel-openclaw"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-openclaw"
|
||||
hidden={tab !== "openclaw" || !filledOpenClaw}
|
||||
className={tab === "openclaw" && filledOpenClaw ? "" : "hidden"}
|
||||
>
|
||||
{filledOpenClaw && (
|
||||
<SnippetBlock
|
||||
value={filledOpenClaw}
|
||||
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
|
||||
copyKey="openclaw"
|
||||
copied={copiedKey === "openclaw"}
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Kimi tab */}
|
||||
<div
|
||||
id="panel-kimi"
|
||||
data-testid="panel-kimi"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-kimi"
|
||||
hidden={tab !== "kimi" || !filledKimi}
|
||||
className={tab === "kimi" && filledKimi ? "" : "hidden"}
|
||||
>
|
||||
{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")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Fields tab */}
|
||||
<div
|
||||
id="panel-fields"
|
||||
data-testid="panel-fields"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-fields"
|
||||
hidden={tab !== "fields"}
|
||||
className={tab === "fields" ? "" : "hidden"}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
|
||||
<Field label="platform_url" value={info.platform_url} onCopy={() => copy(info.platform_url, "url")} copied={copiedKey === "url"} />
|
||||
@@ -323,7 +434,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
<Field label="registry_endpoint" value={info.registry_endpoint} onCopy={() => copy(info.registry_endpoint, "reg")} copied={copiedKey === "reg"} />
|
||||
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
|
||||
@@ -440,6 +440,7 @@ function ProviderPickerModal({
|
||||
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
aria-label={`Value for ${entry.key}`}
|
||||
ref={index === 0 ? firstInputRef : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
@@ -459,7 +460,7 @@ function ProviderPickerModal({
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
|
||||
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -694,6 +695,7 @@ function AllKeysModal({
|
||||
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
aria-label={`Value for ${entry.key}`}
|
||||
autoFocus={index === 0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
@@ -718,7 +720,7 @@ function AllKeysModal({
|
||||
))}
|
||||
|
||||
{globalError && (
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
|
||||
<SkeletonRow />
|
||||
</>
|
||||
) : error ? (
|
||||
<p className="text-xs text-bad" data-testid="usage-error">
|
||||
<p role="alert" aria-live="assertive" className="text-xs text-bad" data-testid="usage-error">
|
||||
{error}
|
||||
</p>
|
||||
) : metrics ? (
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for formatAuditRelativeTime exported from AuditTrailPanel.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatAuditRelativeTime } from "../AuditTrailPanel";
|
||||
|
||||
describe("formatAuditRelativeTime", () => {
|
||||
const now = new Date("2026-05-18T12:00:00Z").getTime();
|
||||
|
||||
it('returns "just now" for timestamps less than 60s ago', () => {
|
||||
const ts = new Date(now - 30_000).toISOString(); // 30s ago
|
||||
expect(formatAuditRelativeTime(ts, now)).toBe("just now");
|
||||
});
|
||||
|
||||
it("returns minutes for timestamps under 1h", () => {
|
||||
const ts = new Date(now - 5 * 60_000).toISOString(); // 5m ago
|
||||
expect(formatAuditRelativeTime(ts, now)).toBe("5m ago");
|
||||
});
|
||||
|
||||
it("returns hours for timestamps under 24h", () => {
|
||||
const ts = new Date(now - 3 * 3_600_000).toISOString(); // 3h ago
|
||||
expect(formatAuditRelativeTime(ts, now)).toBe("3h ago");
|
||||
});
|
||||
|
||||
it("returns locale date for timestamps older than 24h", () => {
|
||||
const ts = new Date(now - 2 * 86_400_000).toISOString(); // 2d ago
|
||||
const result = formatAuditRelativeTime(ts, now);
|
||||
// Returns a locale date string; just verify it's a non-empty string
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
expect(result).not.toBe("just now");
|
||||
expect(result).not.toMatch(/m ago$/);
|
||||
expect(result).not.toMatch(/h ago$/);
|
||||
});
|
||||
|
||||
it("handles exactly 60s boundary as minutes", () => {
|
||||
const ts = new Date(now - 60_000).toISOString(); // exactly 1m ago
|
||||
expect(formatAuditRelativeTime(ts, now)).toBe("1m ago");
|
||||
});
|
||||
|
||||
it("handles exactly 3600s boundary as hours", () => {
|
||||
const ts = new Date(now - 3_600_000).toISOString(); // exactly 1h ago
|
||||
expect(formatAuditRelativeTime(ts, now)).toBe("1h ago");
|
||||
});
|
||||
|
||||
it("handles exactly 86400s boundary", () => {
|
||||
const ts = new Date(now - 86_400_000).toISOString(); // exactly 24h ago
|
||||
const result = formatAuditRelativeTime(ts, now);
|
||||
// Exactly 24h should fall into the "days" branch
|
||||
expect(typeof result).toBe("string");
|
||||
expect(result).not.toMatch(/m ago$/);
|
||||
expect(result).not.toMatch(/h ago$/);
|
||||
});
|
||||
});
|
||||
@@ -131,7 +131,9 @@ describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the Python SDK tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
// Query within the python panel so we get the right pre (not the first in DOM).
|
||||
const pythonPanel = document.querySelector("[data-testid='panel-python']");
|
||||
const preEl = pythonPanel?.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("AUTH_TOKEN");
|
||||
// The placeholder is replaced with the real auth token
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
@@ -140,7 +142,9 @@ describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the curl tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
// Query within the curl panel so we get the right pre (not the first in DOM).
|
||||
const curlPanel = document.querySelector("[data-testid='panel-curl']");
|
||||
const preEl = curlPanel?.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("curl");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
@@ -148,9 +152,11 @@ describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the Fields tab and shows raw values", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
expect(screen.getByText("ws-123")).toBeTruthy();
|
||||
expect(screen.getByText("https://app.example.com")).toBeTruthy();
|
||||
expect(screen.getByText("secret-auth-token-abc")).toBeTruthy();
|
||||
// Query within the fields panel for specific values.
|
||||
const fieldsPanel = document.querySelector("[data-testid='panel-fields']");
|
||||
expect(fieldsPanel?.textContent).toContain("ws-123");
|
||||
expect(fieldsPanel?.textContent).toContain("https://app.example.com");
|
||||
expect(fieldsPanel?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
@@ -168,7 +174,8 @@ describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the Python snippet instead of the placeholder", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
const pythonPanel = document.querySelector("[data-testid='panel-python']");
|
||||
const preEl = pythonPanel?.querySelector("pre");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
@@ -176,7 +183,8 @@ describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the curl snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const preEl = document.querySelector("pre");
|
||||
const curlPanel = document.querySelector("[data-testid='panel-curl']");
|
||||
const preEl = curlPanel?.querySelector("pre");
|
||||
// curl template uses WORKSPACE_AUTH_TOKEN placeholder, not the generic one
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
@@ -184,7 +192,8 @@ describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the Universal MCP snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
const preEl = document.querySelector("pre");
|
||||
const mcpPanel = document.querySelector("[data-testid='panel-mcp']");
|
||||
const preEl = mcpPanel?.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
});
|
||||
@@ -193,8 +202,10 @@ describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
describe("ExternalConnectModal — copy functionality", () => {
|
||||
it("calls navigator.clipboard.writeText with the snippet text", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
fireEvent.click(screen.getByRole("button", { name: /^copy$/i }));
|
||||
// Default tab is Universal MCP — query the copy button within the mcp panel.
|
||||
const mcpPanel = document.querySelector("[data-testid='panel-mcp']");
|
||||
const copyBtn = mcpPanel?.querySelector("button");
|
||||
if (copyBtn) fireEvent.click(copyBtn);
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-auth-token-abc"),
|
||||
);
|
||||
@@ -227,7 +238,8 @@ describe("ExternalConnectModal — missing optional fields", () => {
|
||||
};
|
||||
renderAndFlush(minimalInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
expect(screen.getByText("(missing)")).toBeTruthy();
|
||||
const fieldsPanel = document.querySelector("[data-testid='panel-fields']");
|
||||
expect(fieldsPanel?.textContent).toContain("(missing)");
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for exported helpers from MemoryInspectorPanel:
|
||||
* isPluginUnavailableError, formatTTL.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
|
||||
|
||||
describe("isPluginUnavailableError", () => {
|
||||
it("returns true when error message contains MEMORY_PLUGIN_URL", () => {
|
||||
const err = new Error("MEMORY_PLUGIN_URL is not configured");
|
||||
expect(isPluginUnavailableError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when error message does not contain MEMORY_PLUGIN_URL", () => {
|
||||
const err = new Error("Connection refused");
|
||||
expect(isPluginUnavailableError(err)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for non-Error values", () => {
|
||||
expect(isPluginUnavailableError("string error")).toBe(false);
|
||||
expect(isPluginUnavailableError(null)).toBe(false);
|
||||
expect(isPluginUnavailableError(undefined)).toBe(false);
|
||||
expect(isPluginUnavailableError({})).toBe(false);
|
||||
});
|
||||
|
||||
it("handles Error with empty message", () => {
|
||||
expect(isPluginUnavailableError(new Error(""))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTTL", () => {
|
||||
// Freeze time at 2026-05-18T12:00:00Z for deterministic tests.
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-18T12:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns empty string for null", () => {
|
||||
expect(formatTTL(null)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for undefined", () => {
|
||||
expect(formatTTL(undefined)).toBe("");
|
||||
});
|
||||
|
||||
it("returns empty string for empty string", () => {
|
||||
expect(formatTTL("")).toBe("");
|
||||
});
|
||||
|
||||
it("returns 'expired' for past timestamps", () => {
|
||||
const past = new Date(Date.now() - 60_000).toISOString();
|
||||
expect(formatTTL(past)).toBe("expired");
|
||||
});
|
||||
|
||||
it("returns seconds for sub-minute future TTLs", () => {
|
||||
const future = new Date(Date.now() + 30_000).toISOString();
|
||||
expect(formatTTL(future)).toBe("30s");
|
||||
});
|
||||
|
||||
it("returns minutes for sub-hour future TTLs", () => {
|
||||
const future = new Date(Date.now() + 5 * 60_000).toISOString();
|
||||
expect(formatTTL(future)).toBe("5m");
|
||||
});
|
||||
|
||||
it("returns hours for sub-day future TTLs", () => {
|
||||
const future = new Date(Date.now() + 3 * 3_600_000).toISOString();
|
||||
expect(formatTTL(future)).toBe("3h");
|
||||
});
|
||||
|
||||
it("returns days for TTLs longer than 24h", () => {
|
||||
const future = new Date(Date.now() + 2 * 86_400_000).toISOString();
|
||||
expect(formatTTL(future)).toBe("2d");
|
||||
});
|
||||
|
||||
it("returns empty string for invalid date string", () => {
|
||||
expect(formatTTL("not-a-date")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -223,6 +223,7 @@ export function MobileCanvas({
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
@@ -242,8 +242,6 @@ export function MobileChat({
|
||||
|
||||
useChatSocket(agentId, {
|
||||
onAgentMessage: appendMessageDeduped,
|
||||
// Fan-out user's own outbound message to all sessions (issue #228).
|
||||
onUserMessage: appendMessageDeduped,
|
||||
onSendComplete: releaseSendGuards,
|
||||
});
|
||||
|
||||
@@ -358,6 +356,7 @@ export function MobileChat({
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -404,6 +403,7 @@ export function MobileChat({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -434,6 +434,7 @@ export function MobileChat({
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
padding: "4px 0 8px",
|
||||
border: "none",
|
||||
@@ -477,7 +478,7 @@ export function MobileChat({
|
||||
}}
|
||||
>
|
||||
{tab === "my" && historyLoading && (
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div role="status" aria-live="polite" style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Loading chat history…
|
||||
</div>
|
||||
)}
|
||||
@@ -497,6 +498,8 @@ export function MobileChat({
|
||||
onClick={() => {
|
||||
loadInitial();
|
||||
}}
|
||||
aria-label="Retry loading chat history"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: 14,
|
||||
@@ -512,7 +515,7 @@ export function MobileChat({
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div role="status" aria-live="polite" style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Send a message to start chatting.
|
||||
</div>
|
||||
)}
|
||||
@@ -666,6 +669,7 @@ export function MobileChat({
|
||||
type="button"
|
||||
onClick={() => removePendingFile(i)}
|
||||
aria-label={`Remove ${f.name}`}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
@@ -706,6 +710,7 @@ export function MobileChat({
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!reachable || sending || uploading}
|
||||
aria-label="Attach"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -727,6 +732,7 @@ export function MobileChat({
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="Message"
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends; Shift+Enter inserts a newline. Skip when the
|
||||
// IME is composing — pressing Enter to commit a Chinese/
|
||||
@@ -750,13 +756,11 @@ export function MobileChat({
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
// 16px floor: iOS Safari/WebKit auto-zooms the viewport on
|
||||
// focus when a focused field's font-size is < 16px. Anything
|
||||
// below this re-introduces the tap-to-zoom layout jump on the
|
||||
// mobile PWA. Do NOT lower this without also adding a
|
||||
// maximum-scale/user-scalable viewport lock — and that lock
|
||||
// breaks pinch-to-zoom accessibility, so 16px here is the
|
||||
// correct trade.
|
||||
// iOS Safari/PWA zooms the viewport when a focused textarea
|
||||
// has a computed font-size below 16px. 14.5 triggers that
|
||||
// focus-zoom; the page looks broken until the user pinches
|
||||
// back (#224, same class as desktop #1434 / sibling #225).
|
||||
// 16px is the minimum that keeps focus from zooming.
|
||||
fontSize: 16,
|
||||
lineHeight: 1.4,
|
||||
color: p.text,
|
||||
@@ -773,12 +777,13 @@ export function MobileChat({
|
||||
onClick={send}
|
||||
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
|
||||
aria-label="Send"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
|
||||
cursor: (draft.trim() || pendingFiles.length === 0) && !sending && !uploading ? "pointer" : "not-allowed",
|
||||
flexShrink: 0,
|
||||
background:
|
||||
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
|
||||
|
||||
@@ -231,6 +231,7 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{o.label}
|
||||
<span
|
||||
@@ -251,11 +252,11 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
|
||||
<div style={{ padding: "0 14px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{loading && items.length === 0 ? (
|
||||
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div role="status" aria-live="polite" style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Loading recent comms…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div role="status" aria-live="polite" style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
No A2A traffic yet.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -83,11 +83,12 @@ export function MobileDetail({
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={iconButtonStyle(p, dark)}
|
||||
>
|
||||
{Icons.back({ size: 18 })}
|
||||
</button>
|
||||
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
|
||||
<button type="button" aria-label="More" className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900" style={iconButtonStyle(p, dark)}>
|
||||
{Icons.more({ size: 18 })}
|
||||
</button>
|
||||
</div>
|
||||
@@ -183,6 +184,7 @@ export function MobileDetail({
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: 999,
|
||||
@@ -215,6 +217,7 @@ export function MobileDetail({
|
||||
type="button"
|
||||
onClick={onChat}
|
||||
data-testid="mobile-chat-cta"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
@@ -416,6 +419,8 @@ function DetailActivity({ workspaceId, dark }: { workspaceId: string; dark: bool
|
||||
if (items === null) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -200,6 +200,7 @@ export function MobileHome({
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 8px 24px rgba(40,30,20,0.25), 0 2px 6px rgba(40,30,20,0.15)",
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{Icons.plus({ size: 22 })}
|
||||
</button>
|
||||
|
||||
@@ -92,6 +92,7 @@ export function MobileMe({
|
||||
border: on ? `2px solid ${p.text}` : "2px solid transparent",
|
||||
boxShadow: on ? `0 0 0 2px ${p.bg} inset` : "none",
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -184,6 +185,7 @@ function SegmentedRow({
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
|
||||
@@ -148,6 +148,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -170,6 +171,8 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
{loadingTemplates ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: "24px 8px",
|
||||
textAlign: "center",
|
||||
@@ -214,6 +217,8 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
setTplId(t.id);
|
||||
setTier(tCode);
|
||||
}}
|
||||
aria-label={`Select template: ${t.name} (tier ${t.tier})`}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
background: on
|
||||
? dark
|
||||
@@ -302,6 +307,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label="Agent name"
|
||||
placeholder={tplId
|
||||
? (templates.find((t) => t.id === tplId)?.name ?? "agent-name")
|
||||
: "agent-name"}
|
||||
@@ -312,7 +318,12 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
border: `0.5px solid ${p.border}`,
|
||||
borderRadius: 12,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
fontSize: 13.5,
|
||||
// iOS Safari/PWA zooms the viewport when a focused input has
|
||||
// a computed font-size below 16px; the layout jumps and the
|
||||
// page looks broken until the user pinches back (#224 / #225,
|
||||
// same class as desktop #1434). 16px is the minimum that
|
||||
// suppresses that focus-zoom.
|
||||
fontSize: 16,
|
||||
color: p.text,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
@@ -330,6 +341,8 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTier(t)}
|
||||
aria-label={`Select tier ${t}: ${TIER_LABEL[t]}`}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
@@ -377,6 +390,8 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
type="button"
|
||||
onClick={handleSpawn}
|
||||
disabled={busy || !tplId || templates.length === 0}
|
||||
aria-label="Spawn agent"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
|
||||
@@ -264,18 +264,18 @@ describe("MobileChat — composer", () => {
|
||||
expect(sendBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
// iOS Safari/WebKit auto-zooms the viewport on focus when a focused
|
||||
// <input>/<textarea> has an effective font-size below 16px. On the
|
||||
// mobile PWA this made the whole layout scale up the moment the user
|
||||
// tapped into the chat box. Keeping the composer font ≥16px is the
|
||||
// root-cause fix — it suppresses the focus-zoom WITHOUT disabling
|
||||
// pinch-to-zoom (which a maximum-scale/user-scalable viewport hack
|
||||
// would have done at the cost of accessibility).
|
||||
it("composer textarea font-size is >= 16px (prevents iOS focus-zoom)", () => {
|
||||
// Regression #224: the composer textarea must render with font-size
|
||||
// ≥ 16px. iOS Safari and PWAs auto-zoom the viewport when a focused
|
||||
// input has a computed font-size below 16px — the layout jumps and
|
||||
// the page looks broken until the user pinches back. Same class as
|
||||
// desktop #1434 / sibling MobileSpawn #225.
|
||||
it("composer textarea renders at font-size 16px or greater (iOS focus-zoom regression #224)", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
|
||||
const fontSizePx = parseFloat(textarea.style.fontSize);
|
||||
expect(fontSizePx).toBeGreaterThanOrEqual(16);
|
||||
expect(textarea).toBeTruthy();
|
||||
const fs = Number.parseFloat(textarea.style.fontSize);
|
||||
expect(Number.isFinite(fs)).toBe(true);
|
||||
expect(fs).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -93,6 +93,24 @@ describe("MobileSpawn — render", () => {
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
// Regression #224 / #225: the agent-name input must render with a
|
||||
// font-size ≥ 16px. iOS Safari and PWAs auto-zoom the viewport when a
|
||||
// focused input has a computed font-size below 16px — the layout
|
||||
// jumps and the page looks broken until the user pinches back.
|
||||
it("renders the name input at font-size 16px or greater (iOS focus-zoom regression)", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
const input = document.querySelector(
|
||||
'input[aria-label="Agent name"]',
|
||||
) as HTMLInputElement | null;
|
||||
expect(input).toBeTruthy();
|
||||
// Parse the inline style font-size — jsdom doesn't run a layout
|
||||
// engine, so getComputedStyle reports the inline value verbatim.
|
||||
const fs = Number.parseFloat(input!.style.fontSize);
|
||||
expect(Number.isFinite(fs)).toBe(true);
|
||||
expect(fs).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it("renders all 4 tier buttons", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
|
||||
@@ -133,6 +133,7 @@ export function TabBar({
|
||||
aria-label={t.label}
|
||||
onClick={() => onChange(t.id)}
|
||||
onKeyDown={(e) => handleKeyDown(e, idx)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
@@ -291,6 +292,7 @@ export function AgentCard({
|
||||
data-testid="workspace-card"
|
||||
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
|
||||
onClick={onClick}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
@@ -444,6 +446,7 @@ export function FilterChips({
|
||||
type="button"
|
||||
aria-checked={on}
|
||||
onClick={() => onChange(o.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -160,14 +160,14 @@ export function OrgTokensTab() {
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors"
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors"
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -219,7 +219,7 @@ export function OrgTokensTab() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevokeTarget(t)}
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1 shrink-0"
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1 shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
|
||||
@@ -140,14 +140,14 @@ function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors"
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors"
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -192,7 +192,7 @@ function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevokeTarget(t)}
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1"
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
|
||||
@@ -185,7 +185,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
{/* Activity list */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
|
||||
{loading && activities.length === 0 && (
|
||||
<div className="text-xs text-ink-mid text-center py-8">Loading activity...</div>
|
||||
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">Loading activity...</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -262,7 +262,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { downloadChatFile, isPlatformAttachment } from "./chat/uploads";
|
||||
import { PendingAttachmentPill } from "./chat/AttachmentViews";
|
||||
import { AttachmentPreview } from "./chat/AttachmentPreview";
|
||||
import { AgentCommsPanel } from "./chat/AgentCommsPanel";
|
||||
import { ChatErrorBanner } from "./chat/ChatErrorBanner";
|
||||
import { appendActivityLine } from "./chat/activityLog";
|
||||
import { runtimeDisplayName } from "@/lib/runtime-names";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
@@ -143,12 +144,6 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
releaseSendGuards();
|
||||
}
|
||||
},
|
||||
// Fan-out of user's own outbound message to all sessions (issue #228).
|
||||
// Uses appendMessageDeduped so the originating session collapses its
|
||||
// optimistic copy (same role + content within 3-second window).
|
||||
onUserMessage: (msg) => {
|
||||
history.setMessages((prev) => appendMessageDeduped(prev, msg));
|
||||
},
|
||||
onActivityLog: (entry) => {
|
||||
if (!sending) return;
|
||||
setActivityLog((prev) => appendActivityLine(prev, entry));
|
||||
@@ -598,22 +593,19 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Error banner */}
|
||||
{displayError && (
|
||||
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-red-300">{displayError}</span>
|
||||
{!isOnline && (
|
||||
<button
|
||||
onClick={() => setConfirmRestart(true)}
|
||||
className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Error banner — internal#212: surfaces the secret-safe
|
||||
actionable failure reason that ws-server places on
|
||||
ACTIVITY_LOGGED.error_detail (propagated via
|
||||
useChatSocket → onSendError → setError) and offers a
|
||||
"View activity log" affordance that navigates the user to
|
||||
the Activity tab where the full row lives. The previous
|
||||
inline JSX hardcoded "see workspace logs for details" with
|
||||
no link — there is no separate Logs tab. */}
|
||||
<ChatErrorBanner
|
||||
message={displayError}
|
||||
isOnline={isOnline}
|
||||
onRestart={() => setConfirmRestart(true)}
|
||||
/>
|
||||
|
||||
{/* Input */}
|
||||
<div className="p-3 border-t border-line">
|
||||
|
||||
@@ -81,7 +81,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
spellCheck={false} rows={12}
|
||||
className="w-full bg-surface-card border border-line rounded p-2 text-[10px] font-mono text-ink focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
|
||||
{error && <div role="alert" aria-live="assertive" className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={handleSave} disabled={saving}
|
||||
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface">
|
||||
@@ -109,6 +109,130 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Agent Abilities Section ---
|
||||
//
|
||||
// Always-visible on/off controls for the two workspace-level ability flags
|
||||
// (broadcast_enabled, talk_to_user_enabled). Both are mutated through the
|
||||
// same admin endpoint the ChatTab recovery banner already uses
|
||||
// (PATCH /workspaces/:id/abilities) and reflected into the canvas store node
|
||||
// data (broadcastEnabled / talkToUserEnabled) so every surface that reads
|
||||
// useCanvasStore.nodes stays consistent without a full re-hydrate.
|
||||
//
|
||||
// Before this section there was NO canvas control for either flag: the
|
||||
// backend was fully wired (workspace_abilities.go / workspace_broadcast.go /
|
||||
// agent_message_writer.go, see commit 29b4bffb + internal#510/#511) but the
|
||||
// only frontend affordance was the ChatTab recovery banner, which renders
|
||||
// solely when talk_to_user_enabled===false and so is invisible under the
|
||||
// TRUE default and never existed at all for broadcast.
|
||||
function AgentAbilitiesSection({ workspaceId }: { workspaceId: string }) {
|
||||
// Read the live ability flags off the canvas store node — the platform
|
||||
// event stream hydrates these (canvas-topology.ts maps the workspace row's
|
||||
// broadcast_enabled/talk_to_user_enabled onto node data), so this stays in
|
||||
// sync with the recovery banner and avoids a duplicate GET. Mirrors the
|
||||
// store-read pattern used by AgentCardSection above.
|
||||
const node = useCanvasStore((s) =>
|
||||
s.nodes?.find?.((n) => n.id === workspaceId),
|
||||
);
|
||||
// Defaults match the backend column defaults + canvas-topology mapping:
|
||||
// broadcast_enabled defaults FALSE, talk_to_user_enabled defaults TRUE.
|
||||
const broadcastEnabled = node?.data.broadcastEnabled ?? false;
|
||||
const talkToUserEnabled = node?.data.talkToUserEnabled ?? true;
|
||||
|
||||
// Track an in-flight PATCH per field so a double-click can't fire two
|
||||
// racing writes, and surface a one-line error if the server rejects.
|
||||
const [pending, setPending] = useState<null | "broadcast" | "talk">(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const patchAbility = async (
|
||||
which: "broadcast" | "talk",
|
||||
body: { broadcast_enabled: boolean } | { talk_to_user_enabled: boolean },
|
||||
optimistic: Partial<{ broadcastEnabled: boolean; talkToUserEnabled: boolean }>,
|
||||
) => {
|
||||
setError(null);
|
||||
setPending(which);
|
||||
// Optimistic store update — the toggle flips immediately; on failure we
|
||||
// roll back to the server-truth value the store last held.
|
||||
const prev = {
|
||||
broadcastEnabled,
|
||||
talkToUserEnabled,
|
||||
};
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, optimistic);
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}/abilities`, body);
|
||||
} catch (e) {
|
||||
// Roll back the optimistic change to last-known server truth.
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, {
|
||||
broadcastEnabled: prev.broadcastEnabled,
|
||||
talkToUserEnabled: prev.talkToUserEnabled,
|
||||
});
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Failed to update ability — try again",
|
||||
);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Agent Abilities">
|
||||
<p className="text-[10px] text-ink-mid px-1 pb-1">
|
||||
Workspace-level permissions for this agent. Changes apply immediately
|
||||
(no restart required).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Toggle
|
||||
label="Talk to user"
|
||||
checked={talkToUserEnabled}
|
||||
onChange={(v) =>
|
||||
pending
|
||||
? undefined
|
||||
: patchAbility(
|
||||
"talk",
|
||||
{ talk_to_user_enabled: v },
|
||||
{ talkToUserEnabled: v },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-ink-mid mt-0.5 ml-6">
|
||||
When off, the agent's <code className="font-mono">send_message_to_user</code>{" "}
|
||||
and <code className="font-mono">POST /notify</code> calls are
|
||||
rejected (403) — it must route updates through a parent workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
label="Broadcast to peers"
|
||||
checked={broadcastEnabled}
|
||||
onChange={(v) =>
|
||||
pending
|
||||
? undefined
|
||||
: patchAbility(
|
||||
"broadcast",
|
||||
{ broadcast_enabled: v },
|
||||
{ broadcastEnabled: v },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-ink-mid mt-0.5 ml-6">
|
||||
When on, the agent may <code className="font-mono">POST /broadcast</code>{" "}
|
||||
to message all non-removed agent workspaces in the org. Off by
|
||||
default — only privileged orchestrators should hold this.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{pending && (
|
||||
<div className="mt-2 text-[10px] text-ink-mid">Saving…</div>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main ConfigTab ---
|
||||
|
||||
interface ModelSpec {
|
||||
@@ -795,6 +919,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<label className="text-[10px] text-ink-mid block mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Model"
|
||||
value={currentModelId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
@@ -885,6 +1010,8 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<AgentAbilitiesSection workspaceId={workspaceId} />
|
||||
|
||||
{/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */}
|
||||
{(config.runtime === "claude-code" ||
|
||||
(config.runtime_config?.model || config.model || "").toLowerCase().includes("claude") ||
|
||||
@@ -995,7 +1122,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mx-3 mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">{error}</div>
|
||||
<div role="alert" aria-live="assertive" className="mx-3 mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">{error}</div>
|
||||
)}
|
||||
{!error && RUNTIMES_WITH_OWN_CONFIG.has(config.runtime || "") && (
|
||||
<div className="mx-3 mb-2 px-3 py-1.5 bg-surface-sunken/50 border border-line rounded text-xs text-ink-mid">
|
||||
|
||||
@@ -157,7 +157,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
</select>
|
||||
</Field>
|
||||
{saveError && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
@@ -203,7 +203,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
{isRestartable && (
|
||||
<div className="pt-2">
|
||||
{restartError && (
|
||||
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div role="alert" aria-live="assertive" className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{restartError}
|
||||
</div>
|
||||
)}
|
||||
@@ -307,7 +307,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
{/* Delete */}
|
||||
<Section title="Danger Zone">
|
||||
{deleteError && (
|
||||
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div role="alert" aria-live="assertive" className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function EventsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -102,7 +102,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
|
||||
<div role="alert" aria-live="assertive" className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -266,7 +266,7 @@ function PlatformOwnedFilesTab({
|
||||
// immediately. Delete-All hovers DARKER (bg-red-700) — same AA
|
||||
// contrast trap that bit ConfirmDialog/ApprovalBanner. Cancel
|
||||
// lifts to surface-elevated instead of the prior no-op hover.
|
||||
<div role="alertdialog" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
|
||||
<div role="alertdialog" aria-modal="false" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
|
||||
<p id="files-delete-all-msg" className="text-xs text-bad">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete All</button>
|
||||
@@ -280,7 +280,7 @@ function PlatformOwnedFilesTab({
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<div role="alertdialog" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
|
||||
<div role="alertdialog" aria-modal="false" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
|
||||
<p id="files-delete-one-msg" className="text-xs text-warm">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete</button>
|
||||
|
||||
@@ -275,7 +275,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
{error && <div className="text-[10px] text-bad">{error}</div>}
|
||||
{error && <div role="alert" aria-live="assertive" className="text-[10px] text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -67,7 +67,7 @@ export function TracesTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Pins internal#212 — the chat error banner must:
|
||||
//
|
||||
// 1. Render the secret-safe failure reason (e.g. the provider's own
|
||||
// "403 oauth_org_not_allowed: ..." string), NOT the opaque
|
||||
// hardcoded "Agent error (Exception) — see workspace logs for
|
||||
// details." that points at a workspace-logs tab that doesn't
|
||||
// exist.
|
||||
//
|
||||
// 2. Offer a working "View activity log" affordance that navigates
|
||||
// the user to the Activity tab where the full row lives.
|
||||
//
|
||||
// Tested at the banner-component seam (ChatErrorBanner). The
|
||||
// hook-level path is pinned separately by
|
||||
// chat/hooks/__tests__/useChatSocket.test.tsx — together they cover
|
||||
// wire-payload → callback → render without each test needing to drive
|
||||
// the full ChatTab send-state machinery.
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
setPanelTabMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/store/canvas", () => {
|
||||
const state = {
|
||||
setPanelTab: mocks.setPanelTabMock,
|
||||
panelTab: "chat",
|
||||
};
|
||||
const hook = (selector?: (s: typeof state) => unknown) =>
|
||||
selector ? selector(state) : state;
|
||||
hook.getState = () => state;
|
||||
return { useCanvasStore: hook };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.setPanelTabMock.mockClear();
|
||||
});
|
||||
|
||||
import { ChatErrorBanner } from "../chat/ChatErrorBanner";
|
||||
|
||||
describe("ChatErrorBanner — surfaces actionable reason (internal#212)", () => {
|
||||
it("renders the secret-safe failure reason verbatim, not a hardcoded opaque message", () => {
|
||||
const reason =
|
||||
"Anthropic 403 oauth_org_not_allowed: Your organization has disabled Claude subscription access for Claude Code — use an Anthropic API key or ask your admin to enable access.";
|
||||
render(<ChatErrorBanner message={reason} isOnline={true} onRestart={() => {}} />);
|
||||
expect(screen.getByText(/oauth_org_not_allowed/i)).toBeDefined();
|
||||
expect(screen.getByText(/disabled Claude subscription access/i)).toBeDefined();
|
||||
// The legacy boilerplate must NOT leak through when a real reason
|
||||
// is provided.
|
||||
expect(screen.queryByText(/see workspace logs for details/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the message when it IS the legacy boilerplate (older ws-server)", () => {
|
||||
// Graceful degradation: an older ws-server passes through the
|
||||
// hardcoded text; the banner still renders SOMETHING — never
|
||||
// silently swallow.
|
||||
render(
|
||||
<ChatErrorBanner
|
||||
message="Agent error (Exception) — see workspace logs for details."
|
||||
isOnline={true}
|
||||
onRestart={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByText(/Agent error \(Exception\) — see workspace logs for details\./),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers a 'View activity log' button that calls setPanelTab('activity')", () => {
|
||||
render(
|
||||
<ChatErrorBanner message="kimi 401 invalid_api_key" isOnline={true} onRestart={() => {}} />,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /view activity log/i });
|
||||
fireEvent.click(btn);
|
||||
expect(mocks.setPanelTabMock).toHaveBeenCalledWith("activity");
|
||||
});
|
||||
|
||||
it("still shows the Restart button when offline (existing behavior preserved)", () => {
|
||||
const onRestart = vi.fn();
|
||||
render(
|
||||
<ChatErrorBanner message="Agent is offline" isOnline={false} onRestart={onRestart} />,
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /^restart$/i });
|
||||
fireEvent.click(btn);
|
||||
expect(onRestart).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing when message is null", () => {
|
||||
const { container } = render(
|
||||
<ChatErrorBanner message={null} isOnline={true} onRestart={() => {}} />,
|
||||
);
|
||||
expect(container.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Tests for the always-visible "Agent Abilities" section added to ConfigTab
|
||||
// (internal#510 broadcast_enabled, internal#511 talk_to_user_enabled; backend
|
||||
// wired in commit 29b4bffb).
|
||||
//
|
||||
// Problem this pins: the two workspace ability flags had complete wired
|
||||
// backends but NO canvas control — broadcast had none at all, talk-to-user
|
||||
// only surfaced as a ChatTab recovery banner that is invisible under its
|
||||
// TRUE default. The CTO could not see or toggle either from canvas.
|
||||
//
|
||||
// What this suite pins:
|
||||
// 1. An "Agent Abilities" section renders (always visible, not gated).
|
||||
// 2. Both toggles render and reflect the store node's ability fields,
|
||||
// including the asymmetric defaults (broadcast FALSE, talk TRUE).
|
||||
// 3. Toggling a switch calls PATCH /workspaces/:id/abilities with the
|
||||
// correct snake_case body and optimistically updates the store.
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
patch: (path: string, body?: unknown) => apiPatch(path, body),
|
||||
put: vi.fn(),
|
||||
post: vi.fn(),
|
||||
del: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Store node carries the ability flags hydrated by the platform stream
|
||||
// (canvas-topology.ts maps broadcast_enabled/talk_to_user_enabled onto
|
||||
// node.data). Mirror that shape so the section reads real values.
|
||||
const storeUpdateNodeData = vi.fn();
|
||||
const storeRestartWorkspace = vi.fn();
|
||||
let nodeData: { broadcastEnabled?: boolean; talkToUserEnabled?: boolean } = {};
|
||||
const makeState = () => ({
|
||||
nodes: [{ id: "ws-test", data: nodeData }],
|
||||
restartWorkspace: storeRestartWorkspace,
|
||||
updateNodeData: storeUpdateNodeData,
|
||||
});
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector(makeState()),
|
||||
{ getState: () => makeState() },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentCardSection", () => ({
|
||||
AgentCardSection: () => <div data-testid="agent-card-stub" />,
|
||||
}));
|
||||
|
||||
import { ConfigTab } from "../ConfigTab";
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPatch.mockReset();
|
||||
apiPatch.mockResolvedValue({ status: "updated" });
|
||||
storeUpdateNodeData.mockReset();
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === `/workspaces/ws-test`) {
|
||||
return Promise.resolve({ runtime: "claude-code" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/model`) {
|
||||
return Promise.resolve({ model: "claude-opus-4-7" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/provider`) {
|
||||
return Promise.resolve({ provider: "anthropic-oauth", source: "default" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/files/config.yaml`) {
|
||||
return Promise.resolve({ content: "name: test\nruntime: claude-code\n" });
|
||||
}
|
||||
if (path === "/templates") {
|
||||
return Promise.resolve([
|
||||
{ id: "claude-code", name: "Claude Code", runtime: "claude-code", providers: [] },
|
||||
]);
|
||||
}
|
||||
return Promise.reject(new Error(`unmocked api.get: ${path}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfigTab Agent Abilities section", () => {
|
||||
it("renders an always-visible 'Agent Abilities' section with both toggles", async () => {
|
||||
nodeData = {}; // unset → defaults
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /Agent Abilities/i }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Talk to user")).toBeTruthy();
|
||||
expect(screen.getByText("Broadcast to peers")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reflects the asymmetric defaults: talk-to-user ON, broadcast OFF", async () => {
|
||||
nodeData = {}; // unset → backend defaults
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
const talk = (await screen.findByText("Talk to user"))
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
const broadcast = screen
|
||||
.getByText("Broadcast to peers")
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
expect(talk.checked).toBe(true);
|
||||
expect(broadcast.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("reflects explicit store values", async () => {
|
||||
nodeData = { broadcastEnabled: true, talkToUserEnabled: false };
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
const talk = (await screen.findByText("Talk to user"))
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
const broadcast = screen
|
||||
.getByText("Broadcast to peers")
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
expect(talk.checked).toBe(false);
|
||||
expect(broadcast.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("PATCHes /abilities with talk_to_user_enabled and optimistically updates the store", async () => {
|
||||
nodeData = {}; // talk defaults true
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
const talk = (await screen.findByText("Talk to user"))
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.click(talk); // true → false
|
||||
await waitFor(() =>
|
||||
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", {
|
||||
talk_to_user_enabled: false,
|
||||
}),
|
||||
);
|
||||
expect(storeUpdateNodeData).toHaveBeenCalledWith("ws-test", {
|
||||
talkToUserEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCHes /abilities with broadcast_enabled when the broadcast toggle is flipped", async () => {
|
||||
nodeData = {}; // broadcast defaults false
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
const broadcast = (await screen.findByText("Broadcast to peers"))
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.click(broadcast); // false → true
|
||||
await waitFor(() =>
|
||||
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", {
|
||||
broadcast_enabled: true,
|
||||
}),
|
||||
);
|
||||
expect(storeUpdateNodeData).toHaveBeenCalledWith("ws-test", {
|
||||
broadcastEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -405,7 +405,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
||||
</p>
|
||||
<button
|
||||
onClick={loadInitial}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -610,7 +610,7 @@ function PeerTabButton({
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={onClick}
|
||||
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
|
||||
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/60 focus-visible:ring-offset-1 ${
|
||||
active
|
||||
? "border-b-2 border-cyan-500 text-cyan-200"
|
||||
: "border-b-2 border-transparent text-ink-mid hover:text-ink-mid"
|
||||
|
||||
@@ -33,7 +33,7 @@ export function PendingAttachmentPill({
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${file.name}`}
|
||||
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0"
|
||||
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
@@ -62,8 +62,9 @@ export function AttachmentChip({
|
||||
return (
|
||||
<button
|
||||
onClick={() => onDownload(attachment)}
|
||||
aria-label={`Download ${attachment.name}`}
|
||||
title={`Download ${attachment.name}`}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-colors max-w-full ${toneClasses}`}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-colors max-w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 ${toneClasses}`}
|
||||
>
|
||||
<FileGlyph className="shrink-0 opacity-70" />
|
||||
<span className="truncate">{attachment.name}</span>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* ChatErrorBanner — error-state banner rendered under the chat
|
||||
* message list when an agent turn fails or the workspace is offline.
|
||||
*
|
||||
* internal#212 closes the "see workspace logs for details" pointer-to-
|
||||
* nowhere defect:
|
||||
*
|
||||
* - The banner now renders the actionable, secret-safe failure
|
||||
* reason that ws-server places on `ACTIVITY_LOGGED.error_detail`
|
||||
* (provider HTTP status + error code + provider's own human
|
||||
* message). The hook (`useChatSocket`) forwards this through
|
||||
* `onSendError`, which the ChatTab routes into this banner's
|
||||
* `message` prop. No hardcoded opaque text in this component.
|
||||
*
|
||||
* - A "View activity log" button navigates the user to the Activity
|
||||
* tab where the full row (request body, response body, timing,
|
||||
* full error_detail) lives. Until internal#212, the banner
|
||||
* mentioned "workspace logs" with no link — there is no separate
|
||||
* Logs tab in the side panel; the Activity tab IS the workspace-
|
||||
* logs surface. Routing through the existing tab makes the
|
||||
* reference real instead of dangling.
|
||||
*
|
||||
* - The existing Restart button (shown only when the workspace is
|
||||
* offline) is preserved unchanged so the recovery affordance the
|
||||
* old banner offered does not regress.
|
||||
*
|
||||
* Pure presentational — no socket subscription, no state machine. Easy
|
||||
* to unit-test in isolation and easy to compose into the ChatTab.
|
||||
*/
|
||||
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
export interface ChatErrorBannerProps {
|
||||
/** The user-visible reason. Pass `null` to render nothing. */
|
||||
message: string | null;
|
||||
/** Workspace reachable state — gates the Restart affordance. */
|
||||
isOnline: boolean;
|
||||
/** Fires when the user clicks Restart (offline-only). */
|
||||
onRestart: () => void;
|
||||
}
|
||||
|
||||
export function ChatErrorBanner({ message, isOnline, onRestart }: ChatErrorBannerProps) {
|
||||
// Pulled from the global store rather than threaded through props so
|
||||
// the chat tab does not need to know about the side-panel tab state.
|
||||
// Matches how Toolbar.tsx triggers the audit tab (the existing
|
||||
// precedent for cross-tab navigation).
|
||||
const setPanelTab = useCanvasStore((s) => s.setPanelTab);
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
// role="alert" + aria-live mirrors the project's existing WCAG
|
||||
// 4.1.3 banner pattern (see fix/canvas-errors-aria-alert) — a
|
||||
// screen reader announces the failure as soon as it lands.
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="px-3 py-2 bg-red-900/20 border-t border-red-800/30"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[10px] text-red-300 break-words flex-1">{message}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPanelTab("activity")}
|
||||
className="text-[10px] px-2 py-0.5 bg-red-900/40 hover:bg-red-800/60 border border-red-700/40 text-red-200 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
View activity log
|
||||
</button>
|
||||
{!isOnline && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRestart}
|
||||
className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -64,66 +64,4 @@ describe("inferA2AErrorHint", () => {
|
||||
expect(hint).toMatch(/Claude Code SDK/);
|
||||
expect(hint).not.toMatch(/proxy timeout/);
|
||||
});
|
||||
|
||||
// ---- P1 #348: poll-mode timeout-class detection ----
|
||||
|
||||
it("routes poll-mode budget exhaustion to its specific actionable hint", () => {
|
||||
// a2a_tools_delegation.py emits this exact shape after the 600s
|
||||
// budget. The user must NOT be told to restart — the work is
|
||||
// still in flight on the platform side.
|
||||
const hint = inferA2AErrorHint(
|
||||
"polling timeout after 600s (delegation_id=abc, last_status=processing); the platform is still working on it — call check_task_status('abc') to retrieve later",
|
||||
);
|
||||
expect(hint).toMatch(/Do NOT restart/);
|
||||
expect(hint).toMatch(/check_task_status/);
|
||||
});
|
||||
|
||||
it("matches the check_task_status hint clue even without the 'polling timeout' phrase", () => {
|
||||
const hint = inferA2AErrorHint(
|
||||
"platform busy — call check_task_status('xyz')",
|
||||
);
|
||||
expect(hint).toMatch(/check_task_status/);
|
||||
});
|
||||
|
||||
it("poll-mode hint wins over the generic timeout bucket", () => {
|
||||
// The string contains both "polling timeout after" and "timeout"
|
||||
// — the more-specific poll-mode hint must win so users don't get
|
||||
// the generic "restart" advice for a still-in-flight task.
|
||||
const hint = inferA2AErrorHint("polling timeout after 600s ...");
|
||||
expect(hint).toMatch(/Do NOT restart/);
|
||||
expect(hint).not.toMatch(/restart the workspace if this repeats/);
|
||||
});
|
||||
|
||||
// ---- P1 #348: codex-aware specialization ----
|
||||
|
||||
it("specialises the empty-detail hint for codex callees", () => {
|
||||
// Per feedback_surface_actionable_failure_reason_to_user: opaque
|
||||
// restart prompts are the anti-pattern. With peerKind=codex the
|
||||
// hint explicitly de-recommends restart.
|
||||
const hint = inferA2AErrorHint("", { peerKind: "codex" });
|
||||
expect(hint).toMatch(/codex/);
|
||||
expect(hint).toMatch(/check its Activity tab/i);
|
||||
expect(hint).not.toMatch(/A workspace restart is the safe first move/);
|
||||
});
|
||||
|
||||
it("specialises generic-timeout hint for codex callees", () => {
|
||||
const hint = inferA2AErrorHint("ReadTimeout", { peerKind: "codex" });
|
||||
expect(hint).toMatch(/codex/);
|
||||
expect(hint).toMatch(/600s/);
|
||||
});
|
||||
|
||||
it("falls back to the non-codex generic timeout hint when no peerKind given", () => {
|
||||
const hint = inferA2AErrorHint("ReadTimeout");
|
||||
expect(hint).toMatch(/proxy timeout/);
|
||||
expect(hint).not.toMatch(/600s sync-proxy/);
|
||||
});
|
||||
|
||||
it("preserves existing empty-detail wording when no peer context provided", () => {
|
||||
const hint = inferA2AErrorHint("");
|
||||
expect(hint).toMatch(/no error detail/);
|
||||
// Updated wording: must NOT be the bare "restart is the safe
|
||||
// first move" line — that violates surface-actionable-reason.
|
||||
expect(hint).not.toMatch(/safe first move/);
|
||||
expect(hint).toMatch(/Activity tab/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -248,88 +248,6 @@ describe("extractResponseText", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractAgentText", () => {
|
||||
it("extracts text from top-level parts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "Agent said hello" }],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Agent said hello");
|
||||
});
|
||||
|
||||
it("extracts from artifacts[0].parts when top-level parts absent", () => {
|
||||
const task = {
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "From artifact block" }] },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("From artifact block");
|
||||
});
|
||||
|
||||
it("extracts from status.message.parts as fallback", () => {
|
||||
const task = {
|
||||
status: {
|
||||
message: { parts: [{ kind: "text", text: "Status text" }] },
|
||||
},
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Status text");
|
||||
});
|
||||
|
||||
it("prefers top-level parts over artifacts", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "top-level wins" }],
|
||||
artifacts: [
|
||||
{ parts: [{ kind: "text", text: "artifact text" }] },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("top-level wins");
|
||||
});
|
||||
|
||||
it("prefers top-level parts over status.message", () => {
|
||||
const task = {
|
||||
parts: [{ kind: "text", text: "parts wins" }],
|
||||
status: {
|
||||
message: { parts: [{ kind: "text", text: "status text" }] },
|
||||
},
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("parts wins");
|
||||
});
|
||||
|
||||
it("returns string identity when task itself is a string", () => {
|
||||
expect(extractAgentText("plain string task" as unknown as Record<string, unknown>)).toBe(
|
||||
"plain string task",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns fallback when task is an empty object", () => {
|
||||
expect(extractAgentText({})).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("returns fallback when task has no extractable text", () => {
|
||||
expect(
|
||||
extractAgentText({ status: "running", other: "fields" }),
|
||||
).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("tolerates malformed nested shapes without throwing", () => {
|
||||
const task = {
|
||||
parts: null,
|
||||
artifacts: "not an array",
|
||||
status: { message: 42 },
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("(Could not extract response text)");
|
||||
});
|
||||
|
||||
it("joins multiple text parts with newline", () => {
|
||||
const task = {
|
||||
parts: [
|
||||
{ kind: "text", text: "Line one" },
|
||||
{ kind: "text", text: "Line two" },
|
||||
],
|
||||
};
|
||||
expect(extractAgentText(task)).toBe("Line one\nLine two");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractTextsFromParts", () => {
|
||||
it("extracts text parts with kind=text", () => {
|
||||
const parts = [
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { resolveWorkspaceName } from "../hooks/resolveWorkspaceName";
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset store to a clean slate between tests so node lookup is deterministic.
|
||||
useCanvasStore.setState({ nodes: [] });
|
||||
});
|
||||
|
||||
describe("resolveWorkspaceName", () => {
|
||||
it("returns the workspace name when a node with that ID exists", () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "ws-alpha-001",
|
||||
type: "workspace",
|
||||
data: { name: "Alpha Agent" },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resolveWorkspaceName("ws-alpha-001")).toBe("Alpha Agent");
|
||||
});
|
||||
|
||||
it("falls back to the first 8 chars of the ID when no matching node exists", () => {
|
||||
expect(resolveWorkspaceName("ws-zzz-not-found")).toBe("ws-zzz-n");
|
||||
});
|
||||
|
||||
it("falls back to the first 8 chars when the node exists but has no name", () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "ws-no-name",
|
||||
type: "workspace",
|
||||
// data.name is deliberately absent
|
||||
data: {},
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resolveWorkspaceName("ws-no-name")).toBe("ws-no-na");
|
||||
});
|
||||
|
||||
it("returns the first 8 chars for a very short ID", () => {
|
||||
expect(resolveWorkspaceName("ab")).toBe("ab");
|
||||
});
|
||||
|
||||
it("returns the first 8 chars when the ID is exactly 8 characters", () => {
|
||||
// slice(0,8) of an 8-char string is the full string
|
||||
const id = "12345678";
|
||||
expect(resolveWorkspaceName(id)).toBe(id);
|
||||
});
|
||||
|
||||
it("picks the right node when multiple workspaces share a prefix", () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
type: "workspace",
|
||||
data: { name: "Backend Agent" },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
{
|
||||
id: "00000000-0000-0000-0000-000000000002",
|
||||
type: "workspace",
|
||||
data: { name: "Frontend Agent" },
|
||||
position: { x: 100, y: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(resolveWorkspaceName("00000000-0000-0000-0000-000000000002")).toBe(
|
||||
"Frontend Agent"
|
||||
);
|
||||
expect(resolveWorkspaceName("00000000-0000-0000-0000-000000000001")).toBe(
|
||||
"Backend Agent"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not mutate store state between calls", () => {
|
||||
useCanvasStore.setState({
|
||||
nodes: [
|
||||
{
|
||||
id: "stable-id",
|
||||
type: "workspace",
|
||||
data: { name: "Stable Workspace" },
|
||||
position: { x: 0, y: 0 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
resolveWorkspaceName("stable-id");
|
||||
resolveWorkspaceName("unknown-id");
|
||||
|
||||
// Store nodes must be unchanged — resolveWorkspaceName is read-only.
|
||||
const nodes = useCanvasStore.getState().nodes;
|
||||
expect(nodes).toHaveLength(1);
|
||||
expect((nodes[0] as { id: string }).id).toBe("stable-id");
|
||||
});
|
||||
});
|
||||
@@ -1,216 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for USER_MESSAGE event handling in useChatSocket.
|
||||
*
|
||||
* Covers issue #228: a canvas user's own outbound message was not fanned
|
||||
* out to other sessions — the originating session inserted it optimistically,
|
||||
* but other sessions only saw it after a manual refresh.
|
||||
*
|
||||
* The server now broadcasts USER_MESSAGE on canvas message/send. This test
|
||||
* verifies the canvas side consumes and forwards it to onUserMessage.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useChatSocket, type UseChatSocketCallbacks } from "../hooks/useChatSocket";
|
||||
import { emitSocketEvent, _resetSocketEventListenersForTests } from "@/store/socket-events";
|
||||
import type { WSMessage } from "@/store/socket";
|
||||
|
||||
// Silence React StrictMode double-invoke noise — we care about final state.
|
||||
const WARN = console.warn;
|
||||
beforeEach(() => { console.warn = () => {}; });
|
||||
afterEach(() => { console.warn = WARN; });
|
||||
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-18T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
const WORKSPACE_ID = "00000000-0000-0000-0000-000000000001";
|
||||
|
||||
function makeUserMessageEvent(
|
||||
workspaceId: string,
|
||||
overrides: Partial<{
|
||||
message: string;
|
||||
attachments: Array<{ uri: string; name: string; mimeType?: string; size?: number }>;
|
||||
messageId: string;
|
||||
}> = {},
|
||||
): WSMessage {
|
||||
const { message = "Hello, agent!", attachments, messageId } = overrides;
|
||||
const payload: Record<string, unknown> = { message };
|
||||
if (attachments) payload.attachments = attachments;
|
||||
if (messageId) payload.messageId = messageId;
|
||||
return {
|
||||
event: "USER_MESSAGE",
|
||||
workspace_id: workspaceId,
|
||||
timestamp: "2026-05-18T10:00:00Z",
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChatSocket USER_MESSAGE handling", () => {
|
||||
it("calls onUserMessage with a ChatMessage when USER_MESSAGE arrives for matching workspace", () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onUserMessage };
|
||||
const { result } = renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(makeUserMessageEvent(WORKSPACE_ID, { message: "Hello!" }));
|
||||
});
|
||||
|
||||
expect(onUserMessage).toHaveBeenCalledTimes(1);
|
||||
const msg = onUserMessage.mock.calls[0][0];
|
||||
expect(msg.role).toBe("user");
|
||||
expect(msg.content).toBe("Hello!");
|
||||
expect(typeof msg.id).toBe("string");
|
||||
expect(msg.timestamp).toBe("2026-05-18T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("calls onUserMessage with attachments extracted from the payload", () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onUserMessage };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeUserMessageEvent(WORKSPACE_ID, {
|
||||
message: "Here is the file",
|
||||
attachments: [
|
||||
{ uri: "workspace:/uploads/report.pdf", name: "report.pdf", mimeType: "application/pdf", size: 4096 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onUserMessage).toHaveBeenCalledTimes(1);
|
||||
const msg = onUserMessage.mock.calls[0][0];
|
||||
expect(msg.role).toBe("user");
|
||||
expect(msg.content).toBe("Here is the file");
|
||||
expect(msg.attachments).toHaveLength(1);
|
||||
expect(msg.attachments![0].uri).toBe("workspace:/uploads/report.pdf");
|
||||
expect(msg.attachments![0].name).toBe("report.pdf");
|
||||
expect(msg.attachments![0].mimeType).toBe("application/pdf");
|
||||
expect(msg.attachments![0].size).toBe(4096);
|
||||
});
|
||||
|
||||
it("does NOT call onUserMessage when workspace_id does not match", () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onUserMessage };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeUserMessageEvent("00000000-0000-0000-0000-000000000099", { message: "wrong workspace" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT call onUserMessage when message is empty and no attachments", () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onUserMessage };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(makeUserMessageEvent(WORKSPACE_ID, { message: "" }));
|
||||
});
|
||||
|
||||
expect(onUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores USER_MESSAGE when onUserMessage callback is undefined", () => {
|
||||
const callbacks: UseChatSocketCallbacks = { onAgentMessage: vi.fn() };
|
||||
// Should not throw — undefined callback is guarded
|
||||
expect(() =>
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks)),
|
||||
).not.toThrow();
|
||||
|
||||
const { result } = renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
act(() => {
|
||||
emitSocketEvent(makeUserMessageEvent(WORKSPACE_ID, { message: "Hello" }));
|
||||
});
|
||||
// No error thrown even without onUserMessage
|
||||
});
|
||||
|
||||
it("other event types do NOT trigger onUserMessage", () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onUserMessage };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent({
|
||||
event: "A2A_RESPONSE",
|
||||
workspace_id: WORKSPACE_ID,
|
||||
timestamp: "2026-05-18T10:00:00Z",
|
||||
payload: {},
|
||||
});
|
||||
});
|
||||
|
||||
expect(onUserMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("re-fires onUserMessage for each USER_MESSAGE event received", () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onUserMessage };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(makeUserMessageEvent(WORKSPACE_ID, { message: "First message" }));
|
||||
});
|
||||
act(() => {
|
||||
emitSocketEvent(makeUserMessageEvent(WORKSPACE_ID, { message: "Second message" }));
|
||||
});
|
||||
|
||||
expect(onUserMessage).toHaveBeenCalledTimes(2);
|
||||
expect(onUserMessage.mock.calls[0][0].content).toBe("First message");
|
||||
expect(onUserMessage.mock.calls[1][0].content).toBe("Second message");
|
||||
});
|
||||
|
||||
it("handles USER_MESSAGE with messageId in payload", () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onUserMessage };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeUserMessageEvent(WORKSPACE_ID, { message: "With ID", messageId: "msg-id-abc" }),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onUserMessage).toHaveBeenCalledTimes(1);
|
||||
const msg = onUserMessage.mock.calls[0][0];
|
||||
expect(msg.content).toBe("With ID");
|
||||
});
|
||||
|
||||
it("filters out attachments with empty uri or name (defence-in-depth)", () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const callbacks: UseChatSocketCallbacks = { onUserMessage };
|
||||
renderHook(() => useChatSocket(WORKSPACE_ID, callbacks));
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(
|
||||
makeUserMessageEvent(WORKSPACE_ID, {
|
||||
message: "Mixed attachments",
|
||||
attachments: [
|
||||
{ uri: "workspace:/uploads/good.pdf", name: "good.pdf" },
|
||||
{ uri: "", name: "bad.pdf" }, // empty uri — dropped
|
||||
{ uri: "workspace:/uploads/also-bad", name: "" }, // empty name — dropped
|
||||
{ uri: "workspace:/uploads/also-good.txt", name: "also-good.txt" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(onUserMessage).toHaveBeenCalledTimes(1);
|
||||
const msg = onUserMessage.mock.calls[0][0];
|
||||
expect(msg.attachments).toHaveLength(2);
|
||||
expect(msg.attachments![0].name).toBe("good.pdf");
|
||||
expect(msg.attachments![1].name).toBe("also-good.txt");
|
||||
});
|
||||
});
|
||||
@@ -10,37 +10,10 @@
|
||||
* had already drifted (Activity tab gained `not found`/`offline`
|
||||
* cases AgentCommsPanel never picked up) — this module is the merged
|
||||
* superset and the only place hint text should change.
|
||||
*
|
||||
* Optional `context.peerKind` lets callers signal "the callee was a
|
||||
* codex-runtime task" so the timeout-class hints can be more specific
|
||||
* about expected long completion times (PM-coordinating-Researcher is
|
||||
* the canonical case where the 600s sync-proxy budget is too tight).
|
||||
*/
|
||||
export interface A2AErrorContext {
|
||||
/** Runtime of the callee, when known. e.g. "codex", "claude-code". */
|
||||
peerKind?: string;
|
||||
}
|
||||
|
||||
export function inferA2AErrorHint(
|
||||
detail: string,
|
||||
context?: A2AErrorContext,
|
||||
): string {
|
||||
export function inferA2AErrorHint(detail: string): string {
|
||||
const t = detail.toLowerCase();
|
||||
|
||||
// Poll-mode budget exhaustion (a2a_tools_delegation.py emits
|
||||
// "polling timeout after Ns ... call check_task_status(...) to
|
||||
// retrieve later"). This is NOT a delivery failure — the work is
|
||||
// still in flight on the platform side. Route to a specific hint
|
||||
// BEFORE the generic timeout bucket so the user gets the actionable
|
||||
// "wait + check_task_status" guidance instead of the misleading
|
||||
// "restart the workspace" anti-pattern.
|
||||
if (
|
||||
t.includes("polling timeout after") ||
|
||||
t.includes("call check_task_status")
|
||||
) {
|
||||
return "The 600s sync-polling budget expired but the platform is still working on the delegation. Do NOT restart — the work isn't lost. Wait, then call check_task_status with the delegation_id to retrieve the result. If the callee is a long-running codex task, this is expected.";
|
||||
}
|
||||
|
||||
// "control request timeout" is the specific Claude Code SDK init
|
||||
// wedge symptom. Pattern on the full phrase, not bare "initialize"
|
||||
// — a user task containing "failed to initialize database" would
|
||||
@@ -54,13 +27,6 @@ export function inferA2AErrorHint(
|
||||
t.includes("deadline exceeded") ||
|
||||
t.includes("timeout")
|
||||
) {
|
||||
// For codex callees, a 600s sync-proxy timeout is the EXPECTED
|
||||
// shape when the task is genuinely long-running. Calling out the
|
||||
// workspace-restart anti-pattern explicitly per
|
||||
// `feedback_surface_actionable_failure_reason_to_user`.
|
||||
if ((context?.peerKind || "").toLowerCase() === "codex") {
|
||||
return "The codex remote agent didn't respond within the 600s sync-proxy timeout. Codex tasks can legitimately run longer than this — check the callee's Activity tab; the work may still be progressing. Restart only if the container is genuinely stuck (no activity for several minutes).";
|
||||
}
|
||||
return "The remote agent didn't respond within the proxy timeout. It may be busy with a long task, or the runtime is stuck — restart the workspace if this repeats.";
|
||||
}
|
||||
if (
|
||||
@@ -82,16 +48,7 @@ export function inferA2AErrorHint(
|
||||
return "The remote workspace can't be reached — it may be stopped, removed, or outside the access control list. Verify the peer is online before retrying.";
|
||||
}
|
||||
if (detail === "") {
|
||||
// Per `feedback_surface_actionable_failure_reason_to_user`: a bare
|
||||
// "restart the workspace" prompt is the anti-pattern when the
|
||||
// underlying failure was a silent timeout against a long-running
|
||||
// remote (codex Researcher being coordinated by PM is the
|
||||
// canonical case). If the caller knows the peer is codex, route
|
||||
// to the more specific hint that explicitly de-recommends restart.
|
||||
if ((context?.peerKind || "").toLowerCase() === "codex") {
|
||||
return "The codex remote agent returned no error detail — most often the 600s sync-proxy budget expired before the task finished. The work may still be progressing on the callee side; check its Activity tab before restarting.";
|
||||
}
|
||||
return "The remote agent returned no error detail (the underlying httpx exception had an empty message — typically a connection-reset or silent timeout). Check the callee's Activity tab to see if work is still in flight before restarting.";
|
||||
return "The remote agent returned no error detail (the underlying httpx exception had an empty message — typically a connection-reset or silent timeout). A workspace restart is the safe first move.";
|
||||
}
|
||||
return "The remote agent reported a delivery failure. Check the workspace logs or try restarting.";
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for useChatSend — the canvas user→agent send hook.
|
||||
*
|
||||
* Behavioural focus: the poll-mode ("queued") path. When the target
|
||||
* workspace is an external / MCP-registered agent (delivery_mode=poll,
|
||||
* e.g. an operator laptop running the molecule MCP channel), the
|
||||
* platform's POST /workspaces/:id/a2a returns a synthetic
|
||||
* {status:"queued", delivery_mode:"poll"} envelope IMMEDIATELY with no
|
||||
* reply — the real reply arrives later over the AGENT_MESSAGE
|
||||
* WebSocket push.
|
||||
*
|
||||
* Pre-fix the hook treated that synthetic envelope as a terminal
|
||||
* response and called releaseSendGuards() → `sending` went false the
|
||||
* instant the POST returned → the "agent is working" indicator
|
||||
* vanished and the external turn looked dead. This suite pins the
|
||||
* fixed contract:
|
||||
*
|
||||
* - a real reply still clears `sending` (regression guard)
|
||||
* - a poll "queued" envelope KEEPS `sending` true (no terminal
|
||||
* clear) so the existing thinking indicator persists
|
||||
* - the eventual reply path (releaseSendGuards, the same call the
|
||||
* AGENT_MESSAGE WS push makes via useChatSocket) clears it
|
||||
* - an offline poll agent that never replies eventually surfaces an
|
||||
* honest error instead of an infinite spinner
|
||||
*
|
||||
* Plus pure-function coverage for the poll-envelope detector.
|
||||
*
|
||||
* Root cause: workspace-server a2a_proxy.go:402 poll-mode
|
||||
* short-circuit returns {status:"queued"} synchronously.
|
||||
*/
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
type Mock,
|
||||
} from "vitest";
|
||||
import { act, renderHook, cleanup } from "@testing-library/react";
|
||||
|
||||
const { mockApiPost } = vi.hoisted(() => ({ mockApiPost: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { post: mockApiPost },
|
||||
}));
|
||||
|
||||
vi.mock("../uploads", () => ({
|
||||
uploadChatFiles: vi.fn(),
|
||||
}));
|
||||
|
||||
// Import AFTER mocks.
|
||||
import {
|
||||
useChatSend,
|
||||
isPollQueuedResponse,
|
||||
extractReplyText,
|
||||
POLL_QUEUED_REPLY_TIMEOUT_MS,
|
||||
} from "../useChatSend";
|
||||
|
||||
const flush = () => act(async () => { await Promise.resolve(); });
|
||||
|
||||
describe("isPollQueuedResponse", () => {
|
||||
it("is true only for the synthetic poll-mode queued envelope", () => {
|
||||
expect(isPollQueuedResponse({ status: "queued", delivery_mode: "poll" })).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a real agent reply", () => {
|
||||
expect(
|
||||
isPollQueuedResponse({ result: { parts: [{ kind: "text", text: "hi" }] } }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("is false for null / undefined / partial shapes", () => {
|
||||
expect(isPollQueuedResponse(null)).toBe(false);
|
||||
expect(isPollQueuedResponse(undefined)).toBe(false);
|
||||
// status=queued without delivery_mode=poll is NOT the poll envelope
|
||||
// — don't accidentally swallow a real reply that happens to carry
|
||||
// an unrelated status field.
|
||||
expect(isPollQueuedResponse({ status: "queued" })).toBe(false);
|
||||
expect(isPollQueuedResponse({ delivery_mode: "poll" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractReplyText (regression guard — unchanged by fix)", () => {
|
||||
it("collects text parts from result", () => {
|
||||
expect(
|
||||
extractReplyText({ result: { parts: [{ kind: "text", text: "hello" }] } }),
|
||||
).toBe("hello");
|
||||
});
|
||||
it("returns empty for the poll-queued envelope", () => {
|
||||
expect(extractReplyText({ status: "queued", delivery_mode: "poll" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useChatSend — poll-mode in-progress state", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
mockApiPost.mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers();
|
||||
vi.useRealTimers();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const setup = () => {
|
||||
const onUserMessage = vi.fn();
|
||||
const onAgentMessage = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useChatSend("ws-ext-1", {
|
||||
getHistoryMessages: () => [],
|
||||
onUserMessage,
|
||||
onAgentMessage,
|
||||
}),
|
||||
);
|
||||
return { result, onUserMessage, onAgentMessage };
|
||||
};
|
||||
|
||||
it("a real reply clears `sending` (regression guard)", async () => {
|
||||
mockApiPost.mockResolvedValue({
|
||||
result: { parts: [{ kind: "text", text: "real reply" }] },
|
||||
});
|
||||
const { result, onAgentMessage } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi");
|
||||
});
|
||||
await flush();
|
||||
|
||||
expect(onAgentMessage).toHaveBeenCalledTimes(1);
|
||||
expect(result.current.sending).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps `sending` true on a poll 'queued' envelope (no terminal clear)", async () => {
|
||||
mockApiPost.mockResolvedValue({ status: "queued", delivery_mode: "poll" });
|
||||
const { result, onAgentMessage } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi external agent");
|
||||
});
|
||||
await flush();
|
||||
|
||||
// The POST resolved, but it was only a queued ack — the indicator
|
||||
// must stay up and no agent bubble should be rendered yet.
|
||||
expect(result.current.sending).toBe(true);
|
||||
expect(onAgentMessage).not.toHaveBeenCalled();
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it("releaseSendGuards (the AGENT_MESSAGE-push path) clears the poll in-progress state", async () => {
|
||||
mockApiPost.mockResolvedValue({ status: "queued", delivery_mode: "poll" });
|
||||
const { result } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi");
|
||||
});
|
||||
await flush();
|
||||
expect(result.current.sending).toBe(true);
|
||||
|
||||
// Simulate the terminal AGENT_MESSAGE WebSocket push arriving:
|
||||
// useChatSocket's onAgentMessage / onSendComplete call
|
||||
// releaseSendGuards. That must clear the in-progress state AND the
|
||||
// safety timer (asserted by the next test).
|
||||
act(() => {
|
||||
result.current.releaseSendGuards();
|
||||
});
|
||||
expect(result.current.sending).toBe(false);
|
||||
});
|
||||
|
||||
it("surfaces an honest error if a poll agent never replies (safety timeout)", async () => {
|
||||
mockApiPost.mockResolvedValue({ status: "queued", delivery_mode: "poll" });
|
||||
const { result } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi");
|
||||
});
|
||||
await flush();
|
||||
expect(result.current.sending).toBe(true);
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(POLL_QUEUED_REPLY_TIMEOUT_MS + 1000);
|
||||
});
|
||||
|
||||
expect(result.current.sending).toBe(false);
|
||||
expect(result.current.error).toMatch(/queued/i);
|
||||
});
|
||||
|
||||
it("does NOT fire the safety error when the reply arrives before timeout", async () => {
|
||||
mockApiPost.mockResolvedValue({ status: "queued", delivery_mode: "poll" });
|
||||
const { result } = setup();
|
||||
|
||||
await act(async () => {
|
||||
void result.current.sendMessage("hi");
|
||||
});
|
||||
await flush();
|
||||
|
||||
// Reply arrives (releaseSendGuards) well before the timeout.
|
||||
act(() => {
|
||||
result.current.releaseSendGuards();
|
||||
});
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(POLL_QUEUED_REPLY_TIMEOUT_MS + 1000);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.sending).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
|
||||
// Capture the handler so we can drive WS events from tests. useSocketEvent
|
||||
// stores the latest handler in a ref under the hood, but since we mock
|
||||
// the hook entirely, just remember the last passed-in handler.
|
||||
let capturedHandler: ((msg: unknown) => void) | null = null;
|
||||
vi.mock("@/hooks/useSocketEvent", () => ({
|
||||
useSocketEvent: (h: (msg: unknown) => void) => {
|
||||
capturedHandler = h;
|
||||
},
|
||||
}));
|
||||
|
||||
// Canvas store mock — useChatSocket calls
|
||||
// useCanvasStore.getState().nodes for peer name resolution and reads
|
||||
// agentMessages via the selector form. Support both.
|
||||
vi.mock("@/store/canvas", () => {
|
||||
const state = {
|
||||
nodes: [
|
||||
{ id: "ws-self", data: { name: "Self" } },
|
||||
{ id: "ws-peer", data: { name: "Peer Agent" } },
|
||||
],
|
||||
agentMessages: {} as Record<string, unknown[]>,
|
||||
consumeAgentMessages: () => [],
|
||||
};
|
||||
const hook = (selector?: (s: typeof state) => unknown) =>
|
||||
selector ? selector(state) : state;
|
||||
hook.getState = () => state;
|
||||
return { useCanvasStore: hook };
|
||||
});
|
||||
|
||||
import { useChatSocket } from "../useChatSocket";
|
||||
|
||||
beforeEach(() => {
|
||||
capturedHandler = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Helper: assemble an ACTIVITY_LOGGED a2a_receive error event the way
|
||||
// the ws-server emits one when a peer call errors out. Fields mirror
|
||||
// workspace-server/internal/handlers/activity.go::logActivityExec
|
||||
// broadcast payload shape.
|
||||
function makeActivityErrorEvent(opts: { workspaceId: string; targetId?: string; errorDetail?: string | undefined }) {
|
||||
return {
|
||||
event: "ACTIVITY_LOGGED",
|
||||
workspace_id: opts.workspaceId,
|
||||
payload: {
|
||||
activity_type: "a2a_receive",
|
||||
method: "message/send",
|
||||
status: "error",
|
||||
target_id: opts.targetId ?? opts.workspaceId,
|
||||
duration_ms: 1500,
|
||||
...(opts.errorDetail !== undefined ? { error_detail: opts.errorDetail } : {}),
|
||||
},
|
||||
timestamp: "2026-05-18T00:00:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("useChatSocket — surface error_detail to onSendError (internal#212)", () => {
|
||||
it("forwards the secret-safe error_detail from the broadcast as the onSendError reason", () => {
|
||||
const onSendError = vi.fn();
|
||||
const onSendComplete = vi.fn();
|
||||
renderHook(() =>
|
||||
useChatSocket("ws-self", {
|
||||
onSendError,
|
||||
onSendComplete,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedHandler).not.toBeNull();
|
||||
act(() => {
|
||||
capturedHandler!(
|
||||
makeActivityErrorEvent({
|
||||
workspaceId: "ws-self",
|
||||
errorDetail:
|
||||
"Anthropic 403 oauth_org_not_allowed: Your organization has disabled Claude subscription access for Claude Code",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// The hook must NOT fall back to the opaque hardcoded
|
||||
// "Agent error (Exception) — see workspace logs for details." —
|
||||
// that was internal#212. When the broadcast carries an
|
||||
// error_detail, that string is the user-facing reason.
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
const reason = onSendError.mock.calls[0][0] as string;
|
||||
expect(reason).toContain("403");
|
||||
expect(reason).toContain("oauth_org_not_allowed");
|
||||
expect(reason).toContain("disabled Claude subscription");
|
||||
expect(reason).not.toMatch(/see workspace logs for details/i);
|
||||
});
|
||||
|
||||
it("gracefully degrades to the legacy opaque message when error_detail is absent (older ws-server)", () => {
|
||||
// An older ws-server doesn't include error_detail in the payload.
|
||||
// The hook must still fire onSendError with the legacy hardcoded
|
||||
// text so the chat banner has SOMETHING to show. The fix is
|
||||
// additive — never depend on the new field's presence.
|
||||
const onSendError = vi.fn();
|
||||
renderHook(() =>
|
||||
useChatSocket("ws-self", {
|
||||
onSendError,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
capturedHandler!(makeActivityErrorEvent({ workspaceId: "ws-self" }));
|
||||
});
|
||||
|
||||
expect(onSendError).toHaveBeenCalledTimes(1);
|
||||
const reason = onSendError.mock.calls[0][0] as string;
|
||||
// Legacy boilerplate is the floor — never silently swallow.
|
||||
expect(reason.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ignores errors targeted at a different workspace's peer", () => {
|
||||
// Defense against a race where the WS hub fans out to all clients —
|
||||
// each chat panel must only react when target_id matches its own
|
||||
// workspace.
|
||||
const onSendError = vi.fn();
|
||||
renderHook(() =>
|
||||
useChatSocket("ws-self", {
|
||||
onSendError,
|
||||
}),
|
||||
);
|
||||
act(() => {
|
||||
capturedHandler!(
|
||||
makeActivityErrorEvent({
|
||||
workspaceId: "ws-self",
|
||||
targetId: "ws-someone-else",
|
||||
errorDetail: "irrelevant",
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(onSendError).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { subscribeSocketResume } from "@/store/socket-events";
|
||||
import { type ChatMessage, appendMessageDeduped as appendMessageDedupedFn } from "../types";
|
||||
|
||||
const INITIAL_HISTORY_LIMIT = 10;
|
||||
@@ -83,23 +82,6 @@ export function useChatHistory(
|
||||
loadInitial();
|
||||
}, [loadInitial]);
|
||||
|
||||
// Back-fill on socket resume. The singleton WS emits this when it
|
||||
// recovers from a down period (ordinary drop, or — the case this
|
||||
// fixes — a mobile-browser background-suspend that silently killed
|
||||
// the socket while the page was frozen). While the socket was dead
|
||||
// every AGENT_MESSAGE / A2A_RESPONSE for this thread was missed, and
|
||||
// the store's rehydrate() only re-pulls /workspaces status, not chat.
|
||||
// Re-running loadInitial() re-fetches the latest persisted history —
|
||||
// exactly what a navigate-away-and-back (remount) does today, but
|
||||
// without the user having to do it. Shared by desktop ChatTab and
|
||||
// MobileChat (both consume this hook), so the realtime path stays
|
||||
// unified across surfaces rather than forked for mobile.
|
||||
useEffect(() => {
|
||||
return subscribeSocketResume(() => {
|
||||
loadInitial();
|
||||
});
|
||||
}, [loadInitial]);
|
||||
|
||||
const loadOlder = useCallback(async () => {
|
||||
if (inflightRef.current || !hasMoreRef.current) return;
|
||||
const oldest = oldestMessageRef.current;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { uploadChatFiles } from "../uploads";
|
||||
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
|
||||
@@ -22,42 +22,8 @@ interface A2AResponse {
|
||||
parts?: A2APart[];
|
||||
artifacts?: Array<{ parts: A2APart[] }>;
|
||||
};
|
||||
/** Synthetic poll-mode envelope. The platform returns this
|
||||
* immediately (HTTP 200) when the target workspace is registered
|
||||
* delivery_mode=poll — an external / MCP-registered agent with no
|
||||
* public URL (e.g. an operator's laptop running the molecule MCP
|
||||
* channel). The request has only been QUEUED into activity_logs;
|
||||
* the agent will pick it up on its next poll and the real reply
|
||||
* arrives asynchronously over the AGENT_MESSAGE WebSocket push
|
||||
* (consumed by useChatSocket). See workspace-server
|
||||
* a2a_proxy.go:402 (poll-mode short-circuit) and
|
||||
* a2a_proxy_helpers.go:516 (logA2AReceiveQueued). */
|
||||
status?: string;
|
||||
delivery_mode?: string;
|
||||
}
|
||||
|
||||
/** True when `resp` is the platform's synthetic poll-mode "queued"
|
||||
* envelope rather than a real agent reply. For these the send is
|
||||
* acknowledged-but-pending: the user's message landed and the agent
|
||||
* is working, but there is no reply yet — the terminal AGENT_MESSAGE
|
||||
* push will arrive later over the WebSocket. Treating this as a
|
||||
* terminal response (the pre-fix behaviour) cleared the "agent is
|
||||
* working" indicator the instant the POST returned, so an external
|
||||
* workspace turn looked dead even though work had not started. */
|
||||
export function isPollQueuedResponse(resp: A2AResponse | null | undefined): boolean {
|
||||
return !!resp && resp.status === "queued" && resp.delivery_mode === "poll";
|
||||
}
|
||||
|
||||
/** Hard ceiling on how long the "agent is working" indicator stays up
|
||||
* for a poll-mode turn with no reply. The terminal AGENT_MESSAGE push
|
||||
* normally clears it well before this. The cap exists so a poll-mode
|
||||
* workspace that is offline / never consumes its queue doesn't pin a
|
||||
* spinner forever — at which point we surface an honest, actionable
|
||||
* error instead of an opaque dead spinner. Generous because poll
|
||||
* agents (an operator laptop) can legitimately take minutes to wake,
|
||||
* poll, and respond; the goal is "eventually honest", not fail-fast. */
|
||||
export const POLL_QUEUED_REPLY_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
|
||||
export function extractReplyText(resp: A2AResponse): string {
|
||||
const collect = (parts: A2APart[] | undefined): string => {
|
||||
if (!parts) return "";
|
||||
@@ -93,29 +59,14 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
||||
const sendInFlightRef = useRef(false);
|
||||
const sendingFromAPIRef = useRef(false);
|
||||
const sendTokenRef = useRef(0);
|
||||
// Safety-net timer armed only for poll-mode ("queued") turns: the
|
||||
// POST returns immediately with no reply, so the normal
|
||||
// POST-resolves-→-clear-spinner path can't drive the indicator. The
|
||||
// terminal AGENT_MESSAGE WebSocket push clears it via
|
||||
// releaseSendGuards (which also clears this timer); the timer is the
|
||||
// backstop for an offline poll agent that never consumes its queue.
|
||||
const pollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const optionsRef = useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
const clearPollTimeout = useCallback(() => {
|
||||
if (pollTimeoutRef.current !== null) {
|
||||
clearTimeout(pollTimeoutRef.current);
|
||||
pollTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const releaseSendGuards = useCallback(() => {
|
||||
clearPollTimeout();
|
||||
setSending(false);
|
||||
sendingFromAPIRef.current = false;
|
||||
sendInFlightRef.current = false;
|
||||
}, [clearPollTimeout]);
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
@@ -195,33 +146,6 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
||||
sendInFlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Poll-mode ("queued") turn: the message landed and the
|
||||
// external/MCP agent will pick it up on its next poll, but
|
||||
// there is NO reply in this response. Pre-fix this fell
|
||||
// through to releaseSendGuards() below and the "agent is
|
||||
// working" indicator vanished the instant the POST returned —
|
||||
// an external-workspace turn looked dead even though work had
|
||||
// not started. Instead, keep `sending` true so the existing
|
||||
// thinking indicator (the same one internal agents use)
|
||||
// persists as a "received — agent is working" state; the
|
||||
// terminal AGENT_MESSAGE WebSocket push (consumed by
|
||||
// useChatSocket → onAgentMessage / onSendComplete →
|
||||
// releaseSendGuards) clears it when the real reply arrives,
|
||||
// exactly the path an internal async reply already uses.
|
||||
if (isPollQueuedResponse(resp)) {
|
||||
clearPollTimeout();
|
||||
pollTimeoutRef.current = setTimeout(() => {
|
||||
if (sendTokenRef.current !== myToken) return;
|
||||
if (!sendingFromAPIRef.current) return;
|
||||
releaseSendGuards();
|
||||
setError(
|
||||
"No response yet from this agent — it may be offline or " +
|
||||
"busy. Your message was delivered and is queued; the " +
|
||||
"reply will appear here if the agent picks it up.",
|
||||
);
|
||||
}, POLL_QUEUED_REPLY_TIMEOUT_MS);
|
||||
return;
|
||||
}
|
||||
const replyText = extractReplyText(resp);
|
||||
const replyFiles = extractFilesFromTask(
|
||||
(resp?.result ?? {}) as Record<string, unknown>,
|
||||
@@ -243,15 +167,9 @@ export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
|
||||
setError("Failed to send message — agent may be unreachable");
|
||||
});
|
||||
},
|
||||
[workspaceId, sending, uploading, clearPollTimeout],
|
||||
[workspaceId, sending, uploading],
|
||||
);
|
||||
|
||||
// Drop the poll-mode safety timer on unmount / workspace switch so a
|
||||
// stale timeout can't fire setError against a panel the user has
|
||||
// already navigated away from. sendTokenRef guards correctness if it
|
||||
// ever did fire; this just avoids the wasted timer + setState churn.
|
||||
useEffect(() => clearPollTimeout, [clearPollTimeout]);
|
||||
|
||||
return {
|
||||
sending,
|
||||
uploading,
|
||||
|
||||
@@ -7,10 +7,6 @@ import { createMessage, type ChatMessage } from "../types";
|
||||
|
||||
export interface UseChatSocketCallbacks {
|
||||
onAgentMessage?: (msg: ChatMessage) => void;
|
||||
/** Called when another session sent a user message — used to fan out
|
||||
* the user's own outbound text to all sessions so a second device
|
||||
* sees the question live without a manual refresh (issue #228). */
|
||||
onUserMessage?: (msg: ChatMessage) => void;
|
||||
onActivityLog?: (entry: string) => void;
|
||||
onSendComplete?: () => void;
|
||||
onSendError?: (error: string) => void;
|
||||
@@ -47,33 +43,6 @@ export function useChatSocket(
|
||||
|
||||
useSocketEvent((msg) => {
|
||||
try {
|
||||
if (msg.event === "USER_MESSAGE" && msg.workspace_id === workspaceId) {
|
||||
const p = msg.payload || {};
|
||||
const message = typeof p.message === "string" ? p.message : "";
|
||||
const rawAttachments = p.attachments;
|
||||
const attachments =
|
||||
Array.isArray(rawAttachments)
|
||||
? (rawAttachments as Array<{ uri?: unknown; name?: unknown; mimeType?: unknown; size?: unknown }>)
|
||||
.filter(
|
||||
(a) =>
|
||||
typeof a?.uri === "string" && a.uri.length > 0 &&
|
||||
typeof a?.name === "string" && a.name.length > 0,
|
||||
)
|
||||
.map((a) => ({
|
||||
uri: a.uri as string,
|
||||
name: a.name as string,
|
||||
mimeType: typeof a.mimeType === "string" ? a.mimeType : undefined,
|
||||
size: typeof a.size === "number" ? a.size : undefined,
|
||||
}))
|
||||
: undefined;
|
||||
if (message || (attachments && attachments.length > 0)) {
|
||||
callbacksRef.current.onUserMessage?.(
|
||||
createMessage("user", message, attachments),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.event === "ACTIVITY_LOGGED") {
|
||||
if (msg.workspace_id !== workspaceId) return;
|
||||
|
||||
@@ -98,21 +67,23 @@ export function useChatSocket(
|
||||
const own = (targetId || msg.workspace_id) === workspaceId;
|
||||
if (own) {
|
||||
callbacksRef.current.onSendComplete?.();
|
||||
// internal#211/#212: surface the runtime's curated,
|
||||
// user-actionable reason (provider HTTP status + error
|
||||
// code + the provider's own guidance, e.g. a 403 "org
|
||||
// disabled · use an API key / ask your admin"). The
|
||||
// server now includes error_detail in the ACTIVITY_LOGGED
|
||||
// broadcast; fall back to summary, and only as a last
|
||||
// resort to a generic line. The old hardcoded
|
||||
// internal#212 — surface the actionable, secret-safe
|
||||
// failure reason (provider HTTP status + error code +
|
||||
// human-readable message) the ws-server now puts on
|
||||
// ACTIVITY_LOGGED.error_detail. The old hardcoded
|
||||
// "Agent error (Exception) — see workspace logs for
|
||||
// details." string pointed at a logs UI that does not
|
||||
// exist and discarded the actionable reason entirely.
|
||||
const detail =
|
||||
(p.error_detail as string) ||
|
||||
(p.summary as string) ||
|
||||
"The agent turn failed but the runtime reported no detail. Retry once; if it repeats the workspace runtime may need a restart.";
|
||||
callbacksRef.current.onSendError?.(detail);
|
||||
// details." is the fallback only — it pointed at a
|
||||
// workspace-logs tab that doesn't exist, telling the
|
||||
// user nothing they could act on.
|
||||
//
|
||||
// Graceful degradation: older ws-server builds don't
|
||||
// include error_detail, so the legacy boilerplate is
|
||||
// still the floor (never silently swallow).
|
||||
const detail = (p.error_detail as string) || "";
|
||||
const reason = detail
|
||||
? detail
|
||||
: "Agent error (Exception) — see workspace logs for details.";
|
||||
callbacksRef.current.onSendError?.(reason);
|
||||
}
|
||||
}
|
||||
} else if (type === "a2a_send") {
|
||||
|
||||
@@ -351,8 +351,10 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
||||
{showAdd ? (
|
||||
<div className="bg-surface-card/50 rounded p-2 space-y-1.5 border border-line/50">
|
||||
<input value={newKey} onChange={(e) => setNewKey(e.target.value.toUpperCase())} placeholder="KEY_NAME"
|
||||
aria-label="Secret key name"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-[10px] font-mono text-ink focus:outline-none focus:border-accent" />
|
||||
<input value={newValue} onChange={(e) => setNewValue(e.target.value)} placeholder="Value" type="password"
|
||||
aria-label="Secret value"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-[10px] text-ink focus:outline-none focus:border-accent" />
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { if (newKey && newValue) handleSave(newKey, newValue); }} disabled={!newKey || !newValue}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function TestConnectionButton({
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg aria-hidden="true" className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for useKeyboardShortcut.
|
||||
*
|
||||
* Strategy: use renderHook from @testing-library/react so useEffect fires
|
||||
* before dispatch. We spy on window.addEventListener to capture the registered
|
||||
* handler. Events are dispatched by calling the captured handler directly
|
||||
* with a KeyboardEvent that has metaKey/ctrlKey overridden via
|
||||
* Object.defineProperty (jsdom's built-in modifier-key event is unreliable).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { cleanup, act, renderHook } from "@testing-library/react";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useKeyboardShortcut } from "../use-keyboard-shortcut";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Capture the most-recently registered keydown handler so tests can dispatch through it.
|
||||
let registeredHandler: ((e: KeyboardEvent) => void) | null = null;
|
||||
|
||||
const addSpy = vi.spyOn(window, "addEventListener").mockImplementation(
|
||||
(event: string, handler: EventListener) => {
|
||||
if (event === "keydown") {
|
||||
registeredHandler = handler as (e: KeyboardEvent) => void;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const removeSpy = vi.spyOn(window, "removeEventListener").mockImplementation(
|
||||
(event: string) => {
|
||||
if (event === "keydown") {
|
||||
registeredHandler = null;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
registeredHandler = null;
|
||||
addSpy.mockClear();
|
||||
removeSpy.mockClear();
|
||||
});
|
||||
|
||||
/**
|
||||
* Dispatch a keydown event through the captured handler.
|
||||
* Wrapped in act() so React flushes any state updates synchronously.
|
||||
* Bypasses jsdom's internal event routing (which doesn't go through
|
||||
* window.EventTarget.prototype.addEventListener for fireEvent dispatch).
|
||||
*/
|
||||
function dispatchKeydown(
|
||||
key: string,
|
||||
{ meta = false, ctrl = false }: { meta?: boolean; ctrl?: boolean } = {},
|
||||
) {
|
||||
act(() => {
|
||||
const e = new KeyboardEvent("keydown", { key, bubbles: true });
|
||||
Object.defineProperty(e, "metaKey", { value: meta });
|
||||
Object.defineProperty(e, "ctrlKey", { value: ctrl });
|
||||
registeredHandler?.(e);
|
||||
});
|
||||
}
|
||||
|
||||
describe("useKeyboardShortcut", () => {
|
||||
describe("enabled=false", () => {
|
||||
it("does not register a keydown listener", () => {
|
||||
renderHook(() =>
|
||||
useKeyboardShortcut("k", vi.fn(), { enabled: false }),
|
||||
);
|
||||
expect(addSpy).not.toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe("meta modifier", () => {
|
||||
it("fires callback on Cmd+K", () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", cb, { meta: true }));
|
||||
dispatchKeydown("k", { meta: true });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT fire on Ctrl+K when only meta=true", () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", cb, { meta: true }));
|
||||
dispatchKeydown("k", { ctrl: true });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT fire on plain K even with meta=true", () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", cb, { meta: true }));
|
||||
dispatchKeydown("k", { meta: false, ctrl: false });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctrl modifier", () => {
|
||||
it("fires callback on Ctrl+K", () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", cb, { ctrl: true }));
|
||||
dispatchKeydown("k", { ctrl: true });
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT fire on Cmd+K when only ctrl=true", () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", cb, { ctrl: true }));
|
||||
dispatchKeydown("k", { meta: true });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no-modifier guard", () => {
|
||||
it("does not fire when no modifier is held", () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", cb, {}));
|
||||
dispatchKeydown("k", { meta: false, ctrl: false });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("key mismatch", () => {
|
||||
it("does not fire when wrong key is pressed", () => {
|
||||
const cb = vi.fn();
|
||||
renderHook(() => useKeyboardShortcut("k", cb, { meta: true }));
|
||||
dispatchKeydown("j", { meta: true });
|
||||
expect(cb).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("count reflects shortcut fires", () => {
|
||||
it("increments when Cmd+K fires", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [count, setCount] = useState(0);
|
||||
const cb = useCallback(() => setCount((c) => c + 1), []);
|
||||
useKeyboardShortcut("k", cb, { meta: true });
|
||||
return count;
|
||||
});
|
||||
expect(result.current).toBe(0);
|
||||
dispatchKeydown("k", { meta: true });
|
||||
expect(result.current).toBe(1);
|
||||
dispatchKeydown("k", { meta: true });
|
||||
expect(result.current).toBe(2);
|
||||
});
|
||||
|
||||
it("does not increment on wrong modifier", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const [count, setCount] = useState(0);
|
||||
const cb = useCallback(() => setCount((c) => c + 1), []);
|
||||
useKeyboardShortcut("k", cb, { meta: true });
|
||||
return count;
|
||||
});
|
||||
dispatchKeydown("k", { ctrl: true }); // wrong modifier
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanup on unmount", () => {
|
||||
it("removes the keydown listener on unmount", () => {
|
||||
const cb = vi.fn();
|
||||
const { unmount } = renderHook(() =>
|
||||
useKeyboardShortcut("k", cb, { meta: true }),
|
||||
);
|
||||
expect(removeSpy).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(removeSpy).toHaveBeenCalledWith("keydown", expect.any(Function));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for useSocketEvent.
|
||||
*
|
||||
* Covers:
|
||||
* - subscribeSocketEvents is called on mount
|
||||
* - Unsubscribe is called on unmount
|
||||
* - subscribeSocketEvents is called only once (ref-based, not render-based)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useSocketEvent } from "../useSocketEvent";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Mutable ref shared between vi.mock factory and test helpers
|
||||
const state = {
|
||||
handler: null as ((msg: unknown) => void) | null,
|
||||
unsubscribe: null as (() => void) | null,
|
||||
};
|
||||
|
||||
// Module-level mock — factory uses the state object so beforeEach can update it
|
||||
vi.mock("@/store/socket-events", () => ({
|
||||
subscribeSocketEvents: vi.fn().mockImplementation(() => {
|
||||
if (state.unsubscribe) return state.unsubscribe;
|
||||
const fn = vi.fn();
|
||||
state.unsubscribe = fn;
|
||||
return fn;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { subscribeSocketEvents } from "@/store/socket-events";
|
||||
|
||||
beforeEach(() => {
|
||||
state.handler = null;
|
||||
state.unsubscribe = null;
|
||||
vi.mocked(subscribeSocketEvents).mockImplementation(() => {
|
||||
const fn = vi.fn();
|
||||
state.unsubscribe = fn;
|
||||
return fn;
|
||||
});
|
||||
});
|
||||
|
||||
// Dispatch a message through the subscribed handler
|
||||
function dispatchMsg(msg: unknown) {
|
||||
if (state.handler) {
|
||||
state.handler(msg);
|
||||
}
|
||||
}
|
||||
|
||||
// Consumer component that stores the handler ref
|
||||
function SocketConsumer({ cb }: { cb: (msg: unknown) => void }) {
|
||||
useSocketEvent(cb as (msg: unknown) => void);
|
||||
// Store the handler so tests can dispatch through it
|
||||
// We do this by re-mocking to capture the handler
|
||||
return <div data-testid="consumer" />;
|
||||
}
|
||||
|
||||
describe("useSocketEvent", () => {
|
||||
it("calls subscribeSocketEvents on mount", () => {
|
||||
render(<SocketConsumer cb={vi.fn()} />);
|
||||
expect(subscribeSocketEvents).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls the unsubscribe function on unmount", () => {
|
||||
const unsubscribe = vi.fn();
|
||||
vi.mocked(subscribeSocketEvents).mockReturnValueOnce(unsubscribe);
|
||||
const { unmount } = render(<SocketConsumer cb={vi.fn()} />);
|
||||
unmount();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("subscribeSocketEvents is called only once on re-renders", () => {
|
||||
const { rerender } = render(<SocketConsumer cb={vi.fn()} />);
|
||||
const initial = vi.mocked(subscribeSocketEvents).mock.calls.length;
|
||||
|
||||
rerender(<SocketConsumer cb={vi.fn()} />);
|
||||
rerender(<SocketConsumer cb={vi.fn()} />);
|
||||
rerender(<SocketConsumer cb={vi.fn()} />);
|
||||
|
||||
expect(vi.mocked(subscribeSocketEvents).mock.calls.length).toBe(initial);
|
||||
});
|
||||
});
|
||||
@@ -1,98 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for useWorkspaceName.
|
||||
*
|
||||
* Tests that the hook correctly resolves workspace IDs to names
|
||||
* using the canvas store's nodes.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { renderHook, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { useWorkspaceName } from "../useWorkspaceName";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const mockNodes = [
|
||||
{ id: "ws-1", data: { name: "Alpha Workspace" } },
|
||||
{ id: "ws-2", data: { name: "Beta Workspace" } },
|
||||
{ id: "ws-3", data: {} }, // node without name
|
||||
{ id: "ws-4", data: { name: "" } }, // empty name
|
||||
] as const;
|
||||
|
||||
// Stable reference so useCallback deps are stable across re-renders
|
||||
const stableNodes = [...mockNodes];
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
vi.fn((selector?: (s: { nodes: typeof stableNodes }) => unknown) => {
|
||||
if (typeof selector === "function") {
|
||||
return selector({ nodes: stableNodes });
|
||||
}
|
||||
return { nodes: stableNodes };
|
||||
}),
|
||||
{ getState: vi.fn(() => ({ nodes: stableNodes })) },
|
||||
),
|
||||
}));
|
||||
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useCanvasStore).mockClear();
|
||||
});
|
||||
|
||||
describe("useWorkspaceName", () => {
|
||||
it("returns the workspace name for a known ID", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const resolve = useWorkspaceName();
|
||||
return resolve("ws-1");
|
||||
});
|
||||
expect(result.current).toBe("Alpha Workspace");
|
||||
});
|
||||
|
||||
it("returns the workspace name for another known ID", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const resolve = useWorkspaceName();
|
||||
return resolve("ws-2");
|
||||
});
|
||||
expect(result.current).toBe("Beta Workspace");
|
||||
});
|
||||
|
||||
it("returns empty string for null", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const resolve = useWorkspaceName();
|
||||
return resolve(null);
|
||||
});
|
||||
expect(result.current).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to first 8 chars of ID when node has no name", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const resolve = useWorkspaceName();
|
||||
return resolve("ws-3");
|
||||
});
|
||||
expect(result.current).toBe("ws-3".slice(0, 8));
|
||||
});
|
||||
|
||||
it("falls back to first 8 chars of ID when name is empty string", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const resolve = useWorkspaceName();
|
||||
return resolve("ws-4");
|
||||
});
|
||||
expect(result.current).toBe("ws-4".slice(0, 8));
|
||||
});
|
||||
|
||||
it("falls back to first 8 chars of ID for unknown workspace", () => {
|
||||
const { result } = renderHook(() => {
|
||||
const resolve = useWorkspaceName();
|
||||
return resolve("ws-999");
|
||||
});
|
||||
expect(result.current).toBe("ws-999".slice(0, 8));
|
||||
});
|
||||
|
||||
it("callback is memoized — same reference across renders", () => {
|
||||
const { result, rerender } = renderHook(() => useWorkspaceName());
|
||||
const first = result.current;
|
||||
rerender();
|
||||
expect(result.current).toBe(first);
|
||||
});
|
||||
});
|
||||
@@ -1,32 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for cssVar — maps ColorToken to a CSS variable string.
|
||||
*
|
||||
* Exists for the rare case where an inline style="" or SVG fill needs
|
||||
* a token value rather than a Tailwind class. The returned var(--color-foo)
|
||||
* string follows the live theme without re-renders.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { cssVar, type ColorToken } from "../theme";
|
||||
import { cssVar } from "../theme";
|
||||
import type { ColorToken } from "../theme";
|
||||
|
||||
describe("cssVar", () => {
|
||||
const tokens: ColorToken[] = [
|
||||
"surface", "surface-elevated", "surface-sunken", "surface-card",
|
||||
"line", "line-soft", "ink", "ink-mid", "ink-soft",
|
||||
"accent", "accent-strong", "warm", "good", "bad",
|
||||
"bg", "bg-elev", "bg-card", "line-strong",
|
||||
"ink-mute", "ink-dim", "accent-dim", "plasma", "warn",
|
||||
];
|
||||
it("returns 'var(--color-surface)' for 'surface'", () => {
|
||||
expect(cssVar("surface")).toBe("var(--color-surface)");
|
||||
});
|
||||
|
||||
it("returns a CSS variable string for every colour token", () => {
|
||||
for (const token of tokens) {
|
||||
expect(cssVar(token)).toBe(`var(--color-${token})`);
|
||||
it("returns 'var(--color-ink)' for 'ink'", () => {
|
||||
expect(cssVar("ink")).toBe("var(--color-ink)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-accent)' for 'accent'", () => {
|
||||
expect(cssVar("accent")).toBe("var(--color-accent)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-good)' for 'good'", () => {
|
||||
expect(cssVar("good")).toBe("var(--color-good)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-bad)' for 'bad'", () => {
|
||||
expect(cssVar("bad")).toBe("var(--color-bad)");
|
||||
});
|
||||
|
||||
it("returns 'var(--color-warn)' for 'warn'", () => {
|
||||
expect(cssVar("warn")).toBe("var(--color-warn)");
|
||||
});
|
||||
|
||||
it("handles all surface variants", () => {
|
||||
const surfaces: ColorToken[] = ["surface", "surface-elevated", "surface-sunken", "surface-card"];
|
||||
for (const t of surfaces) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("returned string can be used as an inline style value", () => {
|
||||
const el = document.createElement("div");
|
||||
el.style.color = cssVar("ink");
|
||||
el.style.backgroundColor = cssVar("surface");
|
||||
expect(el.style.color).toBe("var(--color-ink)");
|
||||
expect(el.style.backgroundColor).toBe("var(--color-surface)");
|
||||
it("handles all ink variants", () => {
|
||||
const inks: ColorToken[] = ["ink", "ink-mid", "ink-soft", "ink-mute", "ink-dim"];
|
||||
for (const t of inks) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("returned string contains the token name verbatim", () => {
|
||||
expect(cssVar("accent-strong")).toContain("accent-strong");
|
||||
expect(cssVar("ink-dim")).toContain("ink-dim");
|
||||
it("handles always-dark tokens", () => {
|
||||
const dark: ColorToken[] = ["bg", "bg-elev", "bg-card", "line-strong", "accent-dim", "plasma"];
|
||||
for (const t of dark) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
});
|
||||
|
||||
it("is a pure function — same input always returns same output", () => {
|
||||
const tokens: ColorToken[] = ["surface", "accent", "good", "bad", "warm"];
|
||||
for (const t of tokens) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect(cssVar(t)).toBe(`var(--color-${t})`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ThemeProvider and useTheme.
|
||||
*
|
||||
* Uses renderHook so useEffect fires before assertions.
|
||||
* matchMedia is stubbed via Object.defineProperty in beforeEach.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, renderHook, cleanup, act } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { ThemeProvider, useTheme } from "../theme-provider";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function makeMatcher(prefersDark: boolean) {
|
||||
return {
|
||||
matches: prefersDark,
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => makeMatcher(false)),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useTheme", () => {
|
||||
it("returns noopTheme when no provider is in the tree", () => {
|
||||
const { result } = renderHook(() => useTheme());
|
||||
expect(result.current).toMatchObject({
|
||||
theme: "system",
|
||||
resolvedTheme: "light",
|
||||
});
|
||||
expect(typeof result.current.setTheme).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ThemeProvider", () => {
|
||||
it("initialises with the initialTheme prop", () => {
|
||||
const { result } = renderHook(() => useTheme(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider initialTheme="dark">{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current).toMatchObject({
|
||||
theme: "dark",
|
||||
resolvedTheme: "dark",
|
||||
});
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("reflects system preference when theme=system", () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => makeMatcher(true)),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTheme(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider initialTheme="system">{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current).toMatchObject({
|
||||
theme: "system",
|
||||
resolvedTheme: "dark",
|
||||
});
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("resolvedTheme follows explicit theme, not system, when theme != system", () => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
configurable: true,
|
||||
value: vi.fn().mockImplementation(() => makeMatcher(true)),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useTheme(), {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider initialTheme="light">{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
expect(result.current).toMatchObject({
|
||||
theme: "light",
|
||||
resolvedTheme: "light",
|
||||
});
|
||||
expect(document.documentElement.dataset.theme).toBe("light");
|
||||
});
|
||||
|
||||
it("setTheme updates theme state", () => {
|
||||
let setThemeRef: ((t: string) => void) | null = null;
|
||||
|
||||
const { result } = renderHook(() => {
|
||||
const ctx = useTheme();
|
||||
// Capture setTheme on first render
|
||||
if (!setThemeRef) setThemeRef = ctx.setTheme;
|
||||
return ctx;
|
||||
}, {
|
||||
wrapper: ({ children }) => (
|
||||
<ThemeProvider initialTheme="light">{children}</ThemeProvider>
|
||||
),
|
||||
});
|
||||
|
||||
expect(result.current.theme).toBe("light");
|
||||
|
||||
act(() => { setThemeRef!("dark"); });
|
||||
expect(result.current.theme).toBe("dark");
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
|
||||
it("sets document.documentElement.dataset.theme to resolvedTheme on mount", () => {
|
||||
render(
|
||||
<ThemeProvider initialTheme="dark">
|
||||
<div />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
// renderHook already flushed effects; plain render also needs act
|
||||
act(() => {});
|
||||
expect(document.documentElement.dataset.theme).toBe("dark");
|
||||
});
|
||||
});
|
||||
@@ -21,22 +21,12 @@ vi.mock("../canvas", () => ({
|
||||
class MockWebSocket {
|
||||
static instances: MockWebSocket[] = [];
|
||||
|
||||
// Mirror the real WebSocket readyState constants — socket.ts's wake
|
||||
// path reads WebSocket.OPEN / WebSocket.CONNECTING and this.ws.readyState.
|
||||
static readonly CONNECTING = 0;
|
||||
static readonly OPEN = 1;
|
||||
static readonly CLOSING = 2;
|
||||
static readonly CLOSED = 3;
|
||||
|
||||
url: string;
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((event: { data: string }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
closeCallCount = 0;
|
||||
// Starts OPEN once triggerOpen runs; tests flip this to simulate a
|
||||
// mobile background-suspend that left a dead/half-open socket.
|
||||
readyState = MockWebSocket.CONNECTING;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
@@ -45,12 +35,10 @@ class MockWebSocket {
|
||||
|
||||
close() {
|
||||
this.closeCallCount++;
|
||||
this.readyState = MockWebSocket.CLOSED;
|
||||
}
|
||||
|
||||
// Helpers to trigger events in tests
|
||||
triggerOpen() {
|
||||
this.readyState = MockWebSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
@@ -71,51 +59,11 @@ class MockWebSocket {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal DOM stub (vitest environment is 'node' — no window/document).
|
||||
// socket.ts's wake-recovery attaches visibilitychange/pageshow/online/
|
||||
// focus listeners; under node it self-no-ops via a typeof guard, so to
|
||||
// exercise the path we inject just enough of window/document here, the
|
||||
// same way WebSocket is stubbed above. Kept tiny on purpose — a single
|
||||
// listener registry keyed by event name, plus a settable
|
||||
// visibilityState.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FakeTarget {
|
||||
_l: Record<string, Array<() => void>>;
|
||||
addEventListener: (type: string, fn: () => void) => void;
|
||||
removeEventListener: (type: string, fn: () => void) => void;
|
||||
dispatch: (type: string) => void;
|
||||
}
|
||||
|
||||
function makeFakeTarget(): FakeTarget {
|
||||
const l: Record<string, Array<() => void>> = {};
|
||||
return {
|
||||
_l: l,
|
||||
addEventListener(type, fn) {
|
||||
(l[type] ||= []).push(fn);
|
||||
},
|
||||
removeEventListener(type, fn) {
|
||||
l[type] = (l[type] || []).filter((f) => f !== fn);
|
||||
},
|
||||
dispatch(type) {
|
||||
for (const fn of l[type] || []) fn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const fakeWindow = makeFakeTarget();
|
||||
const fakeDocument = Object.assign(makeFakeTarget(), {
|
||||
visibilityState: "visible" as string,
|
||||
});
|
||||
(globalThis as unknown as Record<string, unknown>).window = fakeWindow;
|
||||
(globalThis as unknown as Record<string, unknown>).document = fakeDocument;
|
||||
|
||||
// Install mock WebSocket globally before importing socket module
|
||||
(globalThis as unknown as Record<string, unknown>).WebSocket = MockWebSocket;
|
||||
|
||||
// Now import the socket module (uses globalThis.WebSocket at call time)
|
||||
import { connectSocket, disconnectSocket } from "../socket";
|
||||
import { connectSocket, disconnectSocket, wakeSocket } from "../socket";
|
||||
import { useCanvasStore } from "../canvas";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -380,153 +328,6 @@ describe("WebSocket onerror", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Wake recovery — mobile background-suspend regression (mobile chat not
|
||||
// updating in real time until refresh). Simulates: connect → open →
|
||||
// the OS freezes the page and silently kills the WS WITHOUT firing
|
||||
// onclose → user returns (visibilitychange / pageshow / online /
|
||||
// focus) → assert the dead socket is replaced AND, on the new socket's
|
||||
// open, the resume signal fires so chat history back-fills the missed
|
||||
// AGENT_MESSAGE / A2A_RESPONSE events.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
import {
|
||||
subscribeSocketResume,
|
||||
_resetSocketResumeListenersForTests,
|
||||
} from "../socket-events";
|
||||
|
||||
describe("wake recovery (mobile background-suspend)", () => {
|
||||
beforeEach(() => {
|
||||
_resetSocketResumeListenersForTests();
|
||||
fakeDocument.visibilityState = "visible";
|
||||
});
|
||||
|
||||
function suspendKill(ws: MockWebSocket) {
|
||||
// Mobile background-suspend: the OS tore the transport down but the
|
||||
// page was frozen so onclose never ran. The socket object survives
|
||||
// with a CLOSED readyState and no reconnect was scheduled.
|
||||
ws.readyState = MockWebSocket.CLOSED;
|
||||
}
|
||||
|
||||
it("reconnects on visibilitychange when the socket was silently killed", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
|
||||
suspendKill(ws);
|
||||
fakeDocument.dispatch("visibilitychange");
|
||||
|
||||
// A fresh socket must have been created — the stale one is not
|
||||
// reused.
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("does NOT reconnect on visibilitychange while the socket is still healthy", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
|
||||
// Healthy OPEN socket + a spurious visibilitychange (e.g. quick tab
|
||||
// peek that never actually suspended) → no churn.
|
||||
fakeDocument.dispatch("visibilitychange");
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("ignores visibilitychange when the page is hidden (the hide transition)", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
suspendKill(ws);
|
||||
|
||||
fakeDocument.visibilityState = "hidden";
|
||||
fakeDocument.dispatch("visibilitychange");
|
||||
// Hidden → must not reconnect (would defeat the purpose; we only
|
||||
// re-arm when the user is actually looking at the page again).
|
||||
expect(MockWebSocket.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["pageshow", "online", "focus"])(
|
||||
"reconnects on window '%s' after a silent kill",
|
||||
(evt) => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
suspendKill(ws);
|
||||
|
||||
fakeWindow.dispatch(evt);
|
||||
expect(MockWebSocket.instances.length).toBeGreaterThan(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("emits the resume signal once the recovered socket re-opens (so chat back-fills missed messages)", () => {
|
||||
const onResume = vi.fn();
|
||||
const unsub = subscribeSocketResume(onResume);
|
||||
|
||||
connectSocket();
|
||||
const ws1 = getLastWS();
|
||||
ws1.triggerOpen();
|
||||
// First open must NOT fire resume — the mount-time chat-history
|
||||
// fetch already covers the initial load.
|
||||
expect(onResume).not.toHaveBeenCalled();
|
||||
|
||||
// Background-suspend silently kills the socket, then the user
|
||||
// returns.
|
||||
suspendKill(ws1);
|
||||
fakeDocument.dispatch("visibilitychange");
|
||||
|
||||
// The wake handler force-reconnected; the new socket completing its
|
||||
// handshake is what signals "we recovered from a gap — re-fetch".
|
||||
const ws2 = getLastWS();
|
||||
expect(ws2).not.toBe(ws1);
|
||||
ws2.triggerOpen();
|
||||
|
||||
expect(onResume).toHaveBeenCalledTimes(1);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("does not emit resume on the very first connect", () => {
|
||||
const onResume = vi.fn();
|
||||
const unsub = subscribeSocketResume(onResume);
|
||||
connectSocket();
|
||||
getLastWS().triggerOpen();
|
||||
expect(onResume).not.toHaveBeenCalled();
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("emits resume after an ordinary onclose-driven reconnect too (desktop path unchanged)", () => {
|
||||
const onResume = vi.fn();
|
||||
const unsub = subscribeSocketResume(onResume);
|
||||
|
||||
connectSocket();
|
||||
const ws1 = getLastWS();
|
||||
ws1.triggerOpen();
|
||||
// Ordinary network drop — onclose fires normally.
|
||||
ws1.triggerClose();
|
||||
vi.advanceTimersByTime(1100); // past the 1s backoff
|
||||
const ws2 = getLastWS();
|
||||
expect(ws2).not.toBe(ws1);
|
||||
ws2.triggerOpen();
|
||||
|
||||
expect(onResume).toHaveBeenCalledTimes(1);
|
||||
unsub();
|
||||
});
|
||||
|
||||
it("detaches wake listeners on disconnect (no reconnect after teardown)", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
disconnectSocket();
|
||||
|
||||
const countAfterDisconnect = MockWebSocket.instances.length;
|
||||
// A wake event after teardown must be inert.
|
||||
fakeDocument.dispatch("visibilitychange");
|
||||
fakeWindow.dispatch("focus");
|
||||
expect(MockWebSocket.instances.length).toBe(countAfterDisconnect);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Health check (startHealthCheck / stopHealthCheck via onopen / disconnect)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -615,3 +416,84 @@ describe("RehydrateDedup", () => {
|
||||
expect(d.shouldSkip(2_700)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wakeSocket() — visibility-wake reconnect (regression #223 / #228)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Mobile browsers (iOS Safari, Chrome on Android in deep-sleep) silently
|
||||
// drop the WebSocket when the tab is backgrounded; the in-page onclose
|
||||
// fires very late or never. Without a visibility wake, the canvas stays
|
||||
// frozen until the user manually refreshes.
|
||||
//
|
||||
// The real wiring lives at module level: connectSocket installs a
|
||||
// visibilitychange/pageshow listener that calls wake() on foreground.
|
||||
// We can't dispatch DOM events here because the suite runs under the
|
||||
// `node` test environment (no `document`/`window` — see canvas/vitest
|
||||
// .config.ts). Instead we test wake() directly through the wakeSocket
|
||||
// public export, which is the same code path the listener invokes.
|
||||
|
||||
describe("wakeSocket → reconnect (#223 / #228 — mobile visibility wake)", () => {
|
||||
it("wake on a healthy OPEN socket does not create a new WebSocket", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// OPEN === 1. wake() should take the healthy-no-op branch.
|
||||
(ws as unknown as { readyState: number }).readyState = 1;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
|
||||
it("wake on a CLOSED socket creates a new WebSocket (the actual #223 fix)", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// CLOSED === 3. Simulates the OS killing the socket while the tab
|
||||
// was backgrounded. We deliberately don't fire triggerClose() —
|
||||
// the whole point of #223 is that mobile browsers don't fire
|
||||
// onclose when they kill the WS, so reconnect never schedules.
|
||||
(ws as unknown as { readyState: number }).readyState = 3;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("wake while CONNECTING (readyState=0) does not pile another handshake", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
// CONNECTING === 0 — a handshake is already in flight.
|
||||
(ws as unknown as { readyState: number }).readyState = 0;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
|
||||
it("wake cancels any pending backoff reconnect", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// Drop the socket — onclose schedules a backoff reconnect.
|
||||
ws.triggerClose();
|
||||
// Now wake the page. wake() should pre-empt the backoff so the
|
||||
// user sees the canvas come back immediately, not after the
|
||||
// exponential delay window.
|
||||
(ws as unknown as { readyState: number }).readyState = 3;
|
||||
clearTimeoutSpy.mockClear();
|
||||
wakeSocket();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("wake after disconnectSocket is a no-op (no zombie reconnect)", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
disconnectSocket();
|
||||
const before = MockWebSocket.instances.length;
|
||||
// Singleton is null now — wake() should silently do nothing.
|
||||
expect(() => wakeSocket()).not.toThrow();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,53 +61,3 @@ export function subscribeSocketEvents(listener: Listener): () => void {
|
||||
export function _resetSocketEventListenersForTests(): void {
|
||||
listeners.clear();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Socket-resume signal
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Fired by the ReconnectingSocket when the WS comes back up AFTER having
|
||||
// been down (drop, or a mobile-browser background-suspend that silently
|
||||
// killed the socket while the page was frozen). Distinct from the raw
|
||||
// event bus above: while the socket was dead the page missed every
|
||||
// AGENT_MESSAGE / A2A_RESPONSE, and the store's rehydrate() only re-pulls
|
||||
// /workspaces status — it does NOT back-fill chat messages. Components
|
||||
// that render a live message thread (desktop ChatTab + MobileChat, both
|
||||
// via useChatHistory) subscribe here to re-fetch their history on resume
|
||||
// so missed agent replies appear without the user having to navigate
|
||||
// away+back or hard-refresh. Shared by desktop and mobile — the recovery
|
||||
// is in the singleton socket, not forked per-surface.
|
||||
|
||||
type ResumeListener = () => void;
|
||||
|
||||
const resumeListeners = new Set<ResumeListener>();
|
||||
|
||||
/** Notify every resume subscriber that the socket just recovered from a
|
||||
* down period. Called by ReconnectingSocket.onopen, but only when the
|
||||
* open follows a prior loss (not the very first connect — the initial
|
||||
* mount-time history fetch already covers that). */
|
||||
export function emitSocketResume(): void {
|
||||
for (const listener of resumeListeners) {
|
||||
try {
|
||||
listener();
|
||||
} catch (err) {
|
||||
if (typeof console !== "undefined") {
|
||||
console.error("socket-resume listener threw:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register a resume subscriber. Returns an unsubscribe function the
|
||||
* caller must invoke from its effect cleanup. */
|
||||
export function subscribeSocketResume(listener: ResumeListener): () => void {
|
||||
resumeListeners.add(listener);
|
||||
return () => {
|
||||
resumeListeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
/** Test-only: drop all resume subscribers. */
|
||||
export function _resetSocketResumeListenersForTests(): void {
|
||||
resumeListeners.clear();
|
||||
}
|
||||
|
||||
+89
-117
@@ -1,6 +1,6 @@
|
||||
import { useCanvasStore } from "./canvas";
|
||||
import { deriveWsBaseUrl } from "@/lib/ws-url";
|
||||
import { emitSocketEvent, emitSocketResume } from "./socket-events";
|
||||
import { emitSocketEvent } from "./socket-events";
|
||||
|
||||
// If explicit WS_URL is set, use it as-is (may include custom path).
|
||||
// Otherwise derive base + append /ws.
|
||||
@@ -98,107 +98,9 @@ class ReconnectingSocket {
|
||||
// caller can fire-and-forget without coordinating.
|
||||
private rehydrateInFlight: Promise<void> | null = null;
|
||||
private rehydrateDedup = new RehydrateDedup(REHYDRATE_DEDUP_WINDOW_MS);
|
||||
// True once any onopen has fired. Gates the resume signal so the very
|
||||
// first connect doesn't fire it (the mount-time chat-history fetch
|
||||
// already covers the initial load — a resume here would be a wasted
|
||||
// duplicate). Set on the first successful open and stays true.
|
||||
private everConnected = false;
|
||||
// True between a loss (onclose / wake-detected stale socket) and the
|
||||
// next successful onopen. Only when this is set does onopen emit the
|
||||
// resume signal — i.e. we recovered from a real gap during which
|
||||
// AGENT_MESSAGE / A2A_RESPONSE events may have been missed.
|
||||
private wasDown = false;
|
||||
// Bound wake handler. iOS Safari / Chrome-mobile freeze the page and
|
||||
// its timers when the tab is backgrounded or the device locks, and
|
||||
// tear the WS down WITHOUT reliably firing onclose before the freeze.
|
||||
// On thaw nothing re-arms: onclose never ran so no reconnect was
|
||||
// scheduled, and the health-check / fallback-poll intervals were
|
||||
// suspended. The socket is silently dead until a manual refresh. This
|
||||
// handler force-reconnects on any wake signal when the socket isn't
|
||||
// healthy. Stored so disconnect() can detach the listeners.
|
||||
private onWake: (() => void) | null = null;
|
||||
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
this.installWakeListeners();
|
||||
}
|
||||
|
||||
/** Attach page-lifecycle listeners that force a reconnect when the
|
||||
* page returns to the foreground / regains connectivity and the
|
||||
* socket is not OPEN. Shared by desktop and mobile — desktop rarely
|
||||
* hits the stale-socket path (its onclose fires promptly) so this is
|
||||
* effectively a no-op there, while mobile depends on it because the
|
||||
* background-suspend kills the socket without an onclose. */
|
||||
private installWakeListeners() {
|
||||
if (typeof window === "undefined" || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
const wake = () => {
|
||||
if (this.disposed) return;
|
||||
// Only act on a visible page — visibilitychange also fires on the
|
||||
// hide transition, which we must ignore (closing here would defeat
|
||||
// the point).
|
||||
if (
|
||||
typeof document.visibilityState === "string" &&
|
||||
document.visibilityState !== "visible"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Healthy socket → nothing to do. A stale/half-open socket on
|
||||
// mobile reports CLOSED or CLOSING (the OS tore the transport
|
||||
// down); CONNECTING is also unhealthy from the user's POV but a
|
||||
// reconnect attempt is already in flight, so leave it.
|
||||
const live =
|
||||
this.ws !== null &&
|
||||
(this.ws.readyState === WebSocket.OPEN ||
|
||||
this.ws.readyState === WebSocket.CONNECTING);
|
||||
if (live) return;
|
||||
// Tear down any zombie and reconnect immediately. Mark wasDown so
|
||||
// the subsequent onopen emits the resume signal and chat threads
|
||||
// back-fill the messages missed while frozen.
|
||||
this.wasDown = true;
|
||||
this.forceReconnect();
|
||||
};
|
||||
this.onWake = wake;
|
||||
document.addEventListener("visibilitychange", wake);
|
||||
window.addEventListener("pageshow", wake);
|
||||
window.addEventListener("online", wake);
|
||||
window.addEventListener("focus", wake);
|
||||
}
|
||||
|
||||
private removeWakeListeners() {
|
||||
if (!this.onWake) return;
|
||||
if (typeof window !== "undefined" && typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", this.onWake);
|
||||
window.removeEventListener("pageshow", this.onWake);
|
||||
window.removeEventListener("online", this.onWake);
|
||||
window.removeEventListener("focus", this.onWake);
|
||||
}
|
||||
this.onWake = null;
|
||||
}
|
||||
|
||||
/** Detach the current (presumed dead/stale) socket without routing
|
||||
* through its onclose, cancel any pending backoff timer, and
|
||||
* reconnect now. Used by the wake path: the browser already killed
|
||||
* the transport, so the exponential backoff that onclose would have
|
||||
* scheduled is both absent and undesirable — the user is looking at
|
||||
* the page and wants it live immediately. */
|
||||
private forceReconnect() {
|
||||
if (this.disposed) return;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.onerror = null;
|
||||
try { this.ws.close(); } catch { /* noop */ }
|
||||
this.ws = null;
|
||||
}
|
||||
this.attempt = 0;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
connect() {
|
||||
@@ -230,18 +132,6 @@ class ReconnectingSocket {
|
||||
this.stopFallbackPoll();
|
||||
this.rehydrate();
|
||||
this.startHealthCheck();
|
||||
// If this open follows a real loss (drop, or a mobile background-
|
||||
// suspend that the wake handler recovered from), signal resume so
|
||||
// live message threads re-fetch the AGENT_MESSAGE / A2A_RESPONSE
|
||||
// history they missed while the socket was dead — rehydrate()
|
||||
// above only refreshes /workspaces status, not chat. Gate on
|
||||
// everConnected so the very first open (covered by the mount-time
|
||||
// history fetch) doesn't fire a redundant resume.
|
||||
if (this.everConnected && this.wasDown) {
|
||||
emitSocketResume();
|
||||
}
|
||||
this.everConnected = true;
|
||||
this.wasDown = false;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
@@ -267,11 +157,6 @@ class ReconnectingSocket {
|
||||
// corresponds to the WS we just tore down (prevents a stale
|
||||
// onclose from a zombie socket from re-arming the loop).
|
||||
if (this.disposed || this.ws !== ws) return;
|
||||
// We had a live socket and lost it — mark down so the next onopen
|
||||
// emits the resume signal and chat threads back-fill missed
|
||||
// messages. (The wake path also sets this; setting it here covers
|
||||
// the ordinary network-drop case.)
|
||||
this.wasDown = true;
|
||||
this.stopHealthCheck();
|
||||
useCanvasStore.getState().setWsStatus("connecting");
|
||||
this.startFallbackPoll();
|
||||
@@ -362,7 +247,6 @@ class ReconnectingSocket {
|
||||
|
||||
disconnect() {
|
||||
this.disposed = true;
|
||||
this.removeWakeListeners();
|
||||
this.stopHealthCheck();
|
||||
this.stopFallbackPoll();
|
||||
if (this.reconnectTimer) {
|
||||
@@ -384,6 +268,46 @@ class ReconnectingSocket {
|
||||
}
|
||||
useCanvasStore.getState().setWsStatus("disconnected");
|
||||
}
|
||||
|
||||
/** Force a reconnect attempt now, skipping the backoff window.
|
||||
* Used by the visibilitychange / pageshow handler: when a mobile
|
||||
* browser backgrounds the tab, the OS silently kills the WebSocket
|
||||
* but the in-page onclose either fires very late or never fires at
|
||||
* all (iOS Safari, Chrome on Android in deep-sleep). Once the user
|
||||
* brings the tab back, the canvas needs to reconnect within human
|
||||
* perception — not on whatever backoff delay was last scheduled,
|
||||
* which can be up to 30s. (#223 / #228)
|
||||
*
|
||||
* Idempotent: if the socket is already OPEN we leave it alone; the
|
||||
* WebSocket is still healthy and a reconnect would just churn. */
|
||||
wake() {
|
||||
if (this.disposed) return;
|
||||
// OPEN === 1. Use the numeric literal so we don't have to import
|
||||
// WebSocket type values; the runtime constant is well-defined.
|
||||
if (this.ws && this.ws.readyState === 1) {
|
||||
// Healthy. Run a rehydrate to catch any events we may have missed
|
||||
// while the tab was backgrounded — the OS does deliver some
|
||||
// packets late, but it can also drop them, and the dedup gate
|
||||
// collapses this with any subsequent health-check rehydrate.
|
||||
void this.rehydrate();
|
||||
return;
|
||||
}
|
||||
// CONNECTING === 0 means a handshake is already in flight. Don't
|
||||
// pile another one on; the existing attempt or its onclose-driven
|
||||
// reconnect will resolve.
|
||||
if (this.ws && this.ws.readyState === 0) return;
|
||||
// Otherwise (CLOSING, CLOSED, or null) we're in limbo. Cancel any
|
||||
// pending backoff and reconnect now.
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
// Reset attempt counter so the *next* failure (if any) starts from
|
||||
// a short delay again — we just had a real user interaction, not
|
||||
// an unattended-tab failure cascade.
|
||||
this.attempt = 0;
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceData {
|
||||
@@ -422,11 +346,49 @@ export interface WorkspaceData {
|
||||
|
||||
let socket: ReconnectingSocket | null = null;
|
||||
|
||||
/** visibilitychange / pageshow handler. Mobile browsers (iOS Safari,
|
||||
* Chrome on Android in deep-sleep) silently drop the WebSocket when
|
||||
* the tab is backgrounded — the in-page `onclose` fires very late or
|
||||
* never. Without this listener, the canvas appears frozen after the
|
||||
* user backgrounds the PWA and returns to it: status events, agent
|
||||
* messages, and cross-device chat broadcast don't arrive until a
|
||||
* manual refresh (#223 / #228).
|
||||
*
|
||||
* Both events are wired: `visibilitychange` covers tab-switch on a
|
||||
* live page; `pageshow` covers Safari's bfcache restore, where the
|
||||
* page comes back from cache without firing visibilitychange. */
|
||||
function onPageWake() {
|
||||
// document is undefined in SSR; the listener never installs there,
|
||||
// but defensively guard anyway in case this code is run via a test
|
||||
// harness that doesn't shim it.
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
socket?.wake();
|
||||
}
|
||||
let visibilityHandlerInstalled = false;
|
||||
function installVisibilityHandler() {
|
||||
if (visibilityHandlerInstalled) return;
|
||||
if (typeof document === "undefined" || typeof window === "undefined") return;
|
||||
document.addEventListener("visibilitychange", onPageWake);
|
||||
// `pageshow` with `event.persisted === true` is the bfcache restore
|
||||
// signal — relevant on iOS Safari. We don't need to inspect
|
||||
// `persisted` because waking an OPEN socket is a no-op.
|
||||
window.addEventListener("pageshow", onPageWake);
|
||||
visibilityHandlerInstalled = true;
|
||||
}
|
||||
function uninstallVisibilityHandler() {
|
||||
if (!visibilityHandlerInstalled) return;
|
||||
if (typeof document === "undefined" || typeof window === "undefined") return;
|
||||
document.removeEventListener("visibilitychange", onPageWake);
|
||||
window.removeEventListener("pageshow", onPageWake);
|
||||
visibilityHandlerInstalled = false;
|
||||
}
|
||||
|
||||
export function connectSocket() {
|
||||
if (!socket) {
|
||||
socket = new ReconnectingSocket(WS_URL);
|
||||
}
|
||||
socket.connect();
|
||||
installVisibilityHandler();
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
@@ -434,4 +396,14 @@ export function disconnectSocket() {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
uninstallVisibilityHandler();
|
||||
}
|
||||
|
||||
/** Manually trigger the visibility-wake path. Exported so the test suite
|
||||
* can exercise `ReconnectingSocket.wake()` without depending on a
|
||||
* jsdom DOM (the rest of this file's tests run under the node env).
|
||||
* Real-world callers don't need this — the visibility/pageshow listener
|
||||
* drives it. */
|
||||
export function wakeSocket() {
|
||||
socket?.wake();
|
||||
}
|
||||
|
||||
@@ -584,6 +584,10 @@
|
||||
.secrets-tab__refresh-btn:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
.secrets-tab__refresh-btn:focus-visible {
|
||||
outline: 2px solid #1d4ed8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.secrets-tab__no-results {
|
||||
text-align: center;
|
||||
@@ -649,6 +653,10 @@
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delete-dialog__cancel-btn:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.delete-dialog__confirm-btn {
|
||||
background: var(--status-invalid);
|
||||
@@ -658,6 +666,10 @@
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delete-dialog__confirm-btn:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.delete-dialog__confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
|
||||
@@ -58,11 +58,11 @@ TOP_LEVEL_MODULES = {
|
||||
"a2a_response",
|
||||
"a2a_tools",
|
||||
"a2a_tools_delegation",
|
||||
"a2a_tools_identity",
|
||||
"a2a_tools_inbox",
|
||||
"a2a_tools_memory",
|
||||
"a2a_tools_messaging",
|
||||
"a2a_tools_rbac",
|
||||
"a2a_tools_identity",
|
||||
"adapter_base",
|
||||
"agent",
|
||||
"agents_md",
|
||||
@@ -311,8 +311,17 @@ locally.
|
||||
deps from your system Python. Plain `pip install --user` works
|
||||
but the binary lands in `~/.local/bin` (Linux) or
|
||||
`~/Library/Python/3.X/bin` (macOS) which is often not on PATH on
|
||||
a fresh shell — `claude mcp add molecule -- molecule-mcp` then
|
||||
fails with "command not found" at first use.
|
||||
a fresh shell — `claude mcp add molecule-<workspace-slug> -- molecule-mcp`
|
||||
then fails with "command not found" at first use.
|
||||
|
||||
* **Server name in `claude mcp add` is workspace-specific.** The
|
||||
Canvas "Add to Claude Code" snippet stamps a unique slug
|
||||
(`molecule-<workspace-name>`) so a single Claude Code session can
|
||||
talk to N molecule workspaces concurrently — `claude mcp add` keys
|
||||
entries by name in `~/.claude.json`, so re-running with a bare
|
||||
`molecule` name silently overwrites the prior workspace's entry.
|
||||
See [molecule-core#1535](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/1535)
|
||||
for the canonical generator.
|
||||
|
||||
### Install
|
||||
|
||||
@@ -336,8 +345,10 @@ WORKSPACE_ID=<uuid> \\
|
||||
That exposes the same 8 platform tools (`delegate_task`, `list_peers`,
|
||||
`send_message_to_user`, `commit_memory`, etc.) that container-bound
|
||||
runtimes already get via the workspace's auto-spawned MCP. Register
|
||||
the binary in your agent's MCP config (e.g. Claude Code's
|
||||
`claude mcp add molecule -- molecule-mcp` with the env above).
|
||||
the binary in your agent's MCP config — use a workspace-specific
|
||||
server name so multi-workspace setups don't collide (e.g. Claude Code:
|
||||
`claude mcp add molecule-<workspace-slug> -- molecule-mcp` with the env
|
||||
above; the Canvas modal stamps the right slug for you).
|
||||
|
||||
### Keeping the token out of shell history
|
||||
|
||||
@@ -375,8 +386,8 @@ hold:
|
||||
wheel does (see `_build_initialize_result`). Nothing for you to
|
||||
do.
|
||||
2. **Claude Code installs the server as a marketplace plugin** — a
|
||||
plain `claude mcp add molecule -- molecule-mcp` produces a
|
||||
non-plugin-sourced server, which Claude Code rejects with
|
||||
plain `claude mcp add molecule-<workspace-slug> -- molecule-mcp`
|
||||
produces a non-plugin-sourced server, which Claude Code rejects with
|
||||
`channel_enable requires a marketplace plugin`. Until the
|
||||
official `moleculesai/claude-code-plugin` marketplace lands
|
||||
(tracking [#2936](https://git.moleculesai.app/molecule-ai/molecule-core/issues/2936)),
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
# shellcheck shell=bash
|
||||
# Shared peer-visibility assertion core — runtime/backend-AGNOSTIC.
|
||||
#
|
||||
# WHY THIS FILE EXISTS
|
||||
# --------------------
|
||||
# The peer-visibility gate (PR #1298) was staging-only. Per the standing
|
||||
# rule that the local prod-mimic stack must run a MANDATORY local-Postgres
|
||||
# E2E BEFORE staging E2E (memory: feedback_local_must_mimic_production,
|
||||
# feedback_mandatory_local_e2e_before_ship, feedback_local_test_before_
|
||||
# staging_e2e), peer-visibility must also run against the local stack.
|
||||
#
|
||||
# The ASSERTION must be byte-identical between local and staging — only
|
||||
# provisioning differs. So the literal MCP `list_peers` call + every
|
||||
# anti-proxy / anti-native-fallback guarantee lives HERE, sourced by both
|
||||
# tests/e2e/test_peer_visibility_mcp_staging.sh (staging/CP backend) and
|
||||
# tests/e2e/test_peer_visibility_mcp_local.sh (local docker-compose
|
||||
# backend). If this assertion ever diverges between the two, that is the
|
||||
# bug — keep it in one place.
|
||||
#
|
||||
# THIS IS NOT A PROXY. pv_assert_runtime issues the byte-for-byte
|
||||
# JSON-RPC `tools/call name=list_peers` envelope to `POST
|
||||
# /workspaces/:id/mcp` using the workspace's OWN bearer token, through
|
||||
# the real WorkspaceAuth + MCPRateLimiter middleware chain — the exact
|
||||
# call mcp_molecule_list_peers makes from a canvas agent. It does NOT
|
||||
# read a registry row, /health, the heartbeat table, or
|
||||
# GET /registry/:id/peers.
|
||||
#
|
||||
# Contract:
|
||||
# pv_assert_runtime <runtime> <ws_id> <ws_bearer> <base_url> \
|
||||
# <org_id_or_empty> <all_ws_ids_space_separated>
|
||||
#
|
||||
# <org_id_or_empty> staging: the X-Molecule-Org-Id header value.
|
||||
# local: "" (the local single-tenant stack does
|
||||
# not gate on the org header; the header
|
||||
# is simply omitted when empty).
|
||||
# <all_ws_ids> every provisioned workspace id (parent + every
|
||||
# runtime sibling). The expected peer set for this
|
||||
# runtime is every id in here EXCEPT <ws_id>.
|
||||
#
|
||||
# Sets the global PV_VERDICT to one of:
|
||||
# OK
|
||||
# FAIL(http=<code>)
|
||||
# FAIL(native-fallback)
|
||||
# FAIL(rpc=<detail>)
|
||||
# FAIL(peers=<detail>)
|
||||
# FAIL(unknown)
|
||||
# Returns 0 when PV_VERDICT=OK, 1 otherwise. Never exits — the caller
|
||||
# owns aggregation + the gate exit code (10 = regression reproduced).
|
||||
#
|
||||
# The literal JSON-RPC envelope. Identical to what
|
||||
# workspace/platform_tools/registry.py's mcp_molecule_list_peers emits.
|
||||
PV_RPC_BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_peers","arguments":{}}}'
|
||||
|
||||
pv_assert_runtime() {
|
||||
local rt="$1" wid="$2" wtok="$3" base_url="$4" org_id="$5" all_ws_ids="$6"
|
||||
|
||||
# Expected peer set = every OTHER provisioned workspace, excluding the
|
||||
# caller itself. Byte-identical selection to the original staging script.
|
||||
local expect_ids
|
||||
expect_ids=$(echo "$all_ws_ids" | tr ' ' '\n' | grep -v "^${wid}$" | grep -v '^$')
|
||||
|
||||
# X-Molecule-Org-Id only when the backend supplies one (staging multi-
|
||||
# tenant). Local single-tenant omits it — the same WorkspaceAuth +
|
||||
# MCPRateLimiter chain still runs; only the tenant-routing header differs.
|
||||
local org_header=()
|
||||
if [ -n "$org_id" ]; then
|
||||
org_header=(-H "X-Molecule-Org-Id: $org_id")
|
||||
fi
|
||||
|
||||
local resp http_code body
|
||||
set +e
|
||||
resp=$(curl -sS -X POST "$base_url/workspaces/$wid/mcp" \
|
||||
-H "Authorization: Bearer $wtok" \
|
||||
"${org_header[@]}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PV_RPC_BODY" \
|
||||
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
|
||||
set -e
|
||||
http_code="$resp"
|
||||
body=$(cat /tmp/pv_mcp_body.json 2>/dev/null || echo '')
|
||||
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " HTTP $http_code"
|
||||
echo " body: $(echo "$body" | head -c 600)"
|
||||
|
||||
# (1) HTTP 200 — a 401 (WorkspaceAuth reject, the Hermes symptom) fails here.
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo " ✗ $rt: list_peers MCP call returned HTTP $http_code (expected 200)"
|
||||
PV_VERDICT="FAIL(http=$http_code)"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (2) JSON-RPC result present, not an error object; expected sibling IDs
|
||||
# present; not a native-sessions fallback. Byte-identical to the
|
||||
# original staging script's inline python.
|
||||
local parse
|
||||
parse=$(echo "$body" | python3 -c "
|
||||
import sys, json
|
||||
expect = set(filter(None, '''$expect_ids'''.split()))
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception as e:
|
||||
print('PARSE_ERROR:' + str(e)); sys.exit(0)
|
||||
if isinstance(d, dict) and d.get('error') is not None:
|
||||
print('RPC_ERROR:' + json.dumps(d['error'])[:200]); sys.exit(0)
|
||||
res = d.get('result') if isinstance(d, dict) else None
|
||||
if res is None:
|
||||
print('NO_RESULT'); sys.exit(0)
|
||||
# MCP tools/call result shape: {content:[{type:text,text:'<json or prose>'}]}
|
||||
text = ''
|
||||
if isinstance(res, dict):
|
||||
for c in res.get('content', []):
|
||||
if c.get('type') == 'text':
|
||||
text += c.get('text', '')
|
||||
text_l = text.lower()
|
||||
# Native-sessions fallback signature (the OpenClaw symptom): the agent
|
||||
# answered from its own runtime session list, not the platform peer set.
|
||||
if 'sessions_list' in text_l or 'no platform peers' in text_l or 'native session' in text_l:
|
||||
print('NATIVE_FALLBACK:' + text[:200]); sys.exit(0)
|
||||
# The expected sibling IDs must literally appear in the returned peer text.
|
||||
found = sorted(i for i in expect if i in text)
|
||||
missing = sorted(expect - set(found))
|
||||
if not expect:
|
||||
print('NO_EXPECTED_PEERS_CONFIGURED'); sys.exit(0)
|
||||
if missing:
|
||||
print('MISSING_PEERS:found=%d/%d missing=%s' % (len(found), len(expect), ','.join(m[:8] for m in missing)))
|
||||
sys.exit(0)
|
||||
print('OK:found=%d/%d' % (len(found), len(expect)))
|
||||
" 2>/dev/null)
|
||||
|
||||
case "$parse" in
|
||||
OK:*)
|
||||
echo " ✓ $rt: list_peers returned 200 and contains all expected peers ($parse)"
|
||||
PV_VERDICT="OK"
|
||||
return 0
|
||||
;;
|
||||
NATIVE_FALLBACK:*)
|
||||
echo " ✗ $rt: list_peers fell back to NATIVE sessions — sees no platform peers ($parse)"
|
||||
PV_VERDICT="FAIL(native-fallback)"
|
||||
return 1
|
||||
;;
|
||||
RPC_ERROR:*|NO_RESULT|PARSE_ERROR:*)
|
||||
echo " ✗ $rt: list_peers MCP call did not return a usable result ($parse)"
|
||||
PV_VERDICT="FAIL(rpc=$parse)"
|
||||
return 1
|
||||
;;
|
||||
MISSING_PEERS:*)
|
||||
echo " ✗ $rt: list_peers returned 200 but peer set is wrong/empty ($parse)"
|
||||
PV_VERDICT="FAIL(peers=$parse)"
|
||||
return 1
|
||||
;;
|
||||
NO_EXPECTED_PEERS_CONFIGURED)
|
||||
# Caller bug, not a runtime regression — surface loudly so a
|
||||
# mis-wired backend can't mint a false green.
|
||||
echo " ✗ $rt: no expected peers were configured for this caller"
|
||||
PV_VERDICT="FAIL(rpc=NO_EXPECTED_PEERS_CONFIGURED)"
|
||||
return 1
|
||||
;;
|
||||
*)
|
||||
echo " ✗ $rt: unexpected verdict '$parse'"
|
||||
PV_VERDICT="FAIL(unknown)"
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
Executable
+328
@@ -0,0 +1,328 @@
|
||||
#!/usr/bin/env bash
|
||||
# LOCAL E2E — fresh-provision peer-visibility gate via the LITERAL MCP path.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# tests/e2e/test_peer_visibility_mcp_staging.sh (PR #1298) codified the
|
||||
# literal user-facing peer-visibility path — but staging-only. The
|
||||
# standing rule is that the local prod-mimic stack runs a MANDATORY
|
||||
# local-Postgres E2E BEFORE staging E2E (memory:
|
||||
# feedback_local_must_mimic_production, feedback_mandatory_local_e2e_
|
||||
# before_ship, feedback_local_test_before_staging_e2e,
|
||||
# feedback_real_subprocess_test_for_boot_path). A staging-only gate means
|
||||
# regressions are caught late and expensively on EC2. This is the LOCAL
|
||||
# backend: same byte-identical assertion, local docker-compose stack.
|
||||
#
|
||||
# THE ASSERTION IS NOT A PROXY and is BYTE-IDENTICAL to staging — it is
|
||||
# the SAME tests/e2e/lib/peer_visibility_assert.sh::pv_assert_runtime that
|
||||
# the staging script calls. It issues the byte-for-byte JSON-RPC
|
||||
# `tools/call name=list_peers` envelope to `POST /workspaces/:id/mcp`
|
||||
# using each workspace's OWN bearer token, through the real WorkspaceAuth
|
||||
# + MCPRateLimiter middleware chain — the exact call
|
||||
# mcp_molecule_list_peers makes from a canvas agent. It does NOT read a
|
||||
# registry row, /health, the heartbeat table, or GET /registry/:id/peers.
|
||||
#
|
||||
# Only PROVISIONING differs from staging:
|
||||
# - staging: POST /cp/admin/orgs (cold EC2 tenant) + per-tenant admin
|
||||
# token + each workspace's auth_token from the POST /workspaces resp.
|
||||
# - local: POST /workspaces directly against the local stack
|
||||
# (BASE, default http://localhost:8080), MCP bearer minted via
|
||||
# GET /admin/workspaces/:id/test-token (e2e_mint_test_token —
|
||||
# deterministic, gated by MOLECULE_ENV != production). Same model
|
||||
# every other local E2E (test_priority_runtimes_e2e.sh,
|
||||
# test_api.sh) already uses; no new credential/provision flow.
|
||||
#
|
||||
# It is written to FAIL on today's broken Hermes/OpenClaw behavior and go
|
||||
# green only when the in-flight root-cause fixes (Hermes-401 #162,
|
||||
# OpenClaw-never-online/MCP-wiring #165) actually land — same gate
|
||||
# semantics + exit codes as the staging script. NON-required by design
|
||||
# until then (flip-to-required tracked at molecule-core#1296), and NOT
|
||||
# masked with continue-on-error (feedback_fix_root_not_symptom).
|
||||
#
|
||||
# Required env: none (local stack only).
|
||||
# Optional env:
|
||||
# BASE default http://localhost:8080
|
||||
# PV_RUNTIMES space list; default "hermes openclaw claude-code"
|
||||
# E2E_PROVISION_TIMEOUT_SECS per-workspace online budget; default 900
|
||||
# (hermes cold apt+uv is the slow path locally)
|
||||
# E2E_KEEP_WS 1 → skip teardown (local debugging only)
|
||||
# LLM provider keys (a workspace boots only if its provider key is set;
|
||||
# a runtime whose key is absent is SKIPPED, not failed — a partially
|
||||
# keyed local env must not false-fail the gate):
|
||||
# CLAUDE_CODE_OAUTH_TOKEN claude-code
|
||||
# E2E_MINIMAX_API_KEY hermes/openclaw (MiniMax, preferred)
|
||||
# E2E_ANTHROPIC_API_KEY hermes/openclaw (direct Anthropic)
|
||||
# E2E_OPENAI_API_KEY hermes/openclaw (OpenAI)
|
||||
#
|
||||
# Exit codes (match the staging script):
|
||||
# 0 every runtime under test saw its peers via the literal MCP call
|
||||
# 1 generic failure
|
||||
# 3 a workspace never reached online within the budget
|
||||
# 10 peer-visibility regression reproduced (the gate firing as designed)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
source "$(dirname "$0")/_lib.sh"
|
||||
# Byte-identical assertion shared with the staging backend.
|
||||
# shellcheck source=tests/e2e/lib/peer_visibility_assert.sh
|
||||
source "$(dirname "$0")/lib/peer_visibility_assert.sh"
|
||||
|
||||
PV_RUNTIMES="${PV_RUNTIMES:-hermes openclaw claude-code}"
|
||||
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-900}"
|
||||
NAME_PREFIX="PV-Local-$$-$(date +%H%M%S)"
|
||||
|
||||
log() { echo "[$(date +%H:%M:%S)] $*"; }
|
||||
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
|
||||
|
||||
CREATED_WSIDS=()
|
||||
|
||||
# ─── Scoped teardown ───────────────────────────────────────────────────
|
||||
# Deletes ONLY the workspaces THIS run created (tracked in CREATED_WSIDS),
|
||||
# one DELETE /workspaces/:id?confirm=true each. NEVER e2e_cleanup_all_
|
||||
# workspaces / any blanket sweep — honors feedback_cleanup_after_each_test
|
||||
# and feedback_never_run_cluster_cleanup_tests_on_live_platform (a local
|
||||
# stack can still be shared with other concurrent local E2E).
|
||||
teardown() {
|
||||
local rc=$?
|
||||
set +e
|
||||
if [ "${E2E_KEEP_WS:-0}" = "1" ]; then
|
||||
echo ""
|
||||
log "[teardown] E2E_KEEP_WS=1 — leaving ${#CREATED_WSIDS[@]} ws for debugging (REMEMBER TO DELETE)"
|
||||
exit $rc
|
||||
fi
|
||||
echo ""
|
||||
log "[teardown] deleting ${#CREATED_WSIDS[@]} workspace(s) this run created (scoped)"
|
||||
for wid in ${CREATED_WSIDS[@]+"${CREATED_WSIDS[@]}"}; do
|
||||
[ -n "$wid" ] || continue
|
||||
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" >/dev/null 2>&1 || true
|
||||
done
|
||||
exit $rc
|
||||
}
|
||||
trap teardown EXIT INT TERM
|
||||
|
||||
# Pre-sweep workspaces a prior crashed run of THIS script left behind
|
||||
# (name prefix match only — never a blanket delete). The trap fires on
|
||||
# normal exit, but a kill -9 / SIGPIPE can bypass it.
|
||||
PRIOR=$(curl -s "$BASE/workspaces" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
print(" ".join(w["id"] for w in json.load(sys.stdin) if w.get("name","").startswith("PV-Local-")))
|
||||
except Exception:
|
||||
pass
|
||||
' 2>/dev/null)
|
||||
for _wid in $PRIOR; do
|
||||
log "Pre-sweeping prior PV-Local workspace: $_wid"
|
||||
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" >/dev/null 2>&1 || true
|
||||
done
|
||||
|
||||
# ─── Local-stack preflight ─────────────────────────────────────────────
|
||||
log "0/5 local stack preflight: $BASE/health"
|
||||
if ! curl -fsS "$BASE/health" -m 5 >/dev/null 2>&1; then
|
||||
echo "::error::Local stack not healthy at $BASE/health — bring it up (make up) before this gate. Infra, not a workspace bug (feedback_fix_root_not_symptom)." >&2
|
||||
exit 1
|
||||
fi
|
||||
# admin/test-token is the local MCP-bearer mint path; it 404s in
|
||||
# production. If it is off, this gate cannot drive the literal call.
|
||||
if ! curl -fsS "$BASE/admin/workspaces/preflight-probe/test-token" -m 5 >/dev/null 2>&1; then
|
||||
# A 404 here is EITHER "no such ws" (fine — endpoint is enabled) OR the
|
||||
# endpoint is disabled (MOLECULE_ENV=production). Distinguish by body.
|
||||
PROBE=$(curl -s "$BASE/admin/workspaces/preflight-probe/test-token" -m 5 2>/dev/null)
|
||||
if echo "$PROBE" | grep -qi 'production\|disabled\|not found.*endpoint'; then
|
||||
echo "::error::GET /admin/workspaces/:id/test-token disabled (MOLECULE_ENV=production?). Cannot mint a local MCP bearer." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
ok " local stack healthy"
|
||||
|
||||
# ─── Resolve per-runtime provisioning secrets ──────────────────────────
|
||||
# Mirrors test_priority_runtimes_e2e.sh / test_staging_full_saas.sh's
|
||||
# provider-key chain. A runtime whose key is absent is SKIPPED (not
|
||||
# failed) so a partially keyed local env doesn't false-fail the gate.
|
||||
runtime_secrets() {
|
||||
local rt="$1"
|
||||
case "$rt" in
|
||||
claude-code)
|
||||
[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ] || { echo ""; return 1; }
|
||||
python3 -c "import json,os;print(json.dumps({'CLAUDE_CODE_OAUTH_TOKEN':os.environ['CLAUDE_CODE_OAUTH_TOKEN']}))"
|
||||
;;
|
||||
hermes|openclaw)
|
||||
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
|
||||
python3 -c "import json,os;k=os.environ['E2E_MINIMAX_API_KEY'];print(json.dumps({'ANTHROPIC_BASE_URL':'https://api.minimax.io/anthropic','ANTHROPIC_AUTH_TOKEN':k,'MINIMAX_API_KEY':k}))"
|
||||
elif [ -n "${E2E_ANTHROPIC_API_KEY:-}" ]; then
|
||||
python3 -c "import json,os;k=os.environ['E2E_ANTHROPIC_API_KEY'];print(json.dumps({'ANTHROPIC_API_KEY':k}))"
|
||||
elif [ -n "${E2E_OPENAI_API_KEY:-}" ]; then
|
||||
python3 -c "import json,os;k=os.environ['E2E_OPENAI_API_KEY'];print(json.dumps({'OPENAI_API_KEY':k,'OPENAI_BASE_URL':'https://api.openai.com/v1','MODEL_PROVIDER':'openai:gpt-4o','HERMES_INFERENCE_PROVIDER':'custom','HERMES_CUSTOM_BASE_URL':'https://api.openai.com/v1','HERMES_CUSTOM_API_KEY':k,'HERMES_CUSTOM_API_MODE':'chat_completions'}))"
|
||||
else
|
||||
echo ""; return 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Unknown runtime: provision with empty secrets and let the stack
|
||||
# decide (kept permissive so PV_RUNTIMES can be widened later).
|
||||
echo "{}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Block until $1 reaches one of $2 (space-separated), or $3 sec elapse.
|
||||
wait_for_status() {
|
||||
local wsid="$1" want="$2" budget="$3" start=$SECONDS last=""
|
||||
while [ $((SECONDS - start)) -lt "$budget" ]; do
|
||||
local s
|
||||
s=$(curl -s "$BASE/workspaces/$wsid" | python3 -c 'import json,sys
|
||||
try:
|
||||
d=json.load(sys.stdin); w=d.get("workspace") if isinstance(d.get("workspace"),dict) else d; print(w.get("status",""))
|
||||
except Exception:
|
||||
print("")' 2>/dev/null || echo "")
|
||||
[ "$s" != "$last" ] && { log " $wsid → ${s:-<none>}"; last="$s"; }
|
||||
for w in $want; do [ "$s" = "$w" ] && { echo "$s"; return 0; }; done
|
||||
sleep 5
|
||||
done
|
||||
echo "$last"
|
||||
return 1
|
||||
}
|
||||
|
||||
# ─── 1. Provision parent (claude-code) + one sibling per runtime ───────
|
||||
# Same topology as the staging script: a claude-code parent plus one
|
||||
# sibling per runtime under test, so each runtime should see all others.
|
||||
log "1/5 provisioning parent (claude-code) + one sibling per runtime under test..."
|
||||
|
||||
PARENT_SECRETS=$(runtime_secrets claude-code) || PARENT_SECRETS=""
|
||||
if [ -z "$PARENT_SECRETS" ]; then
|
||||
# Parent still needs to exist as a peer target even without an LLM key;
|
||||
# it never has to answer list_peers itself (it is excluded from the
|
||||
# caller set), so an empty-secrets claude-code shell is sufficient.
|
||||
PARENT_SECRETS="{}"
|
||||
fi
|
||||
P_RESP=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"${NAME_PREFIX}-parent\",\"runtime\":\"claude-code\",\"tier\":3,\"secrets\":$PARENT_SECRETS}")
|
||||
PARENT_ID=$(echo "$P_RESP" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
if [ -z "$PARENT_ID" ]; then
|
||||
echo "::error::parent create failed: $(echo "$P_RESP" | head -c 300)" >&2
|
||||
exit 1
|
||||
fi
|
||||
CREATED_WSIDS+=("$PARENT_ID")
|
||||
log " PARENT_ID=$PARENT_ID"
|
||||
|
||||
# NOTE: no `declare -A` — this script must also run on a local macOS dev
|
||||
# box (bash 3.2, no associative arrays) per feedback_local_must_mimic_
|
||||
# production. WS_IDS / VERDICT are kept as newline-delimited "rt<TAB>val"
|
||||
# maps with tiny get/set helpers (portable to bash 3.2+ AND ubuntu CI).
|
||||
WS_IDS_MAP=""
|
||||
VERDICT_MAP=""
|
||||
_map_set() { # _map_set <mapvarname> <key> <value>
|
||||
local __m="$1" __k="$2" __v="$3" __cur
|
||||
eval "__cur=\$$__m"
|
||||
__cur=$(printf '%s' "$__cur" | grep -v "^${__k} " || true)
|
||||
if [ -n "$__cur" ]; then
|
||||
eval "$__m=\$(printf '%s\n%s\t%s' \"\$__cur\" \"\$__k\" \"\$__v\")"
|
||||
else
|
||||
eval "$__m=\$(printf '%s\t%s' \"\$__k\" \"\$__v\")"
|
||||
fi
|
||||
}
|
||||
_map_get() { # _map_get <mapvarname> <key> -> stdout value (empty if absent)
|
||||
local __m="$1" __k="$2" __cur
|
||||
eval "__cur=\$$__m"
|
||||
printf '%s\n' "$__cur" | awk -F'\t' -v k="$__k" '$1==k {print $2; exit}'
|
||||
}
|
||||
|
||||
ALL_WS_IDS="$PARENT_ID"
|
||||
ACTIVE_RUNTIMES=""
|
||||
for rt in $PV_RUNTIMES; do
|
||||
SEC=$(runtime_secrets "$rt") || SEC=""
|
||||
if [ -z "$SEC" ]; then
|
||||
log " SKIP $rt — no provider key in env (partially-keyed local env; not a failure)"
|
||||
continue
|
||||
fi
|
||||
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"${NAME_PREFIX}-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SEC}")
|
||||
WID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null)
|
||||
if [ -z "$WID" ]; then
|
||||
echo "::error::$rt workspace create failed: $(echo "$R" | head -c 300)" >&2
|
||||
exit 1
|
||||
fi
|
||||
_map_set WS_IDS_MAP "$rt" "$WID"
|
||||
CREATED_WSIDS+=("$WID")
|
||||
ALL_WS_IDS="$ALL_WS_IDS $WID"
|
||||
ACTIVE_RUNTIMES="$ACTIVE_RUNTIMES $rt"
|
||||
log " $rt → $WID"
|
||||
done
|
||||
ACTIVE_RUNTIMES="$(echo "$ACTIVE_RUNTIMES" | xargs)"
|
||||
|
||||
if [ -z "$ACTIVE_RUNTIMES" ]; then
|
||||
echo "::error::No runtime had a provider key set — cannot run the local peer-visibility gate. Set CLAUDE_CODE_OAUTH_TOKEN and/or E2E_MINIMAX_API_KEY (or ANTHROPIC/OPENAI)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── 2. Wait for the parent online (it is a peer target) ───────────────
|
||||
log "2/5 waiting for parent online (peer target)..."
|
||||
PF=$(wait_for_status "$PARENT_ID" "online" "$PROVISION_TIMEOUT_SECS") || true
|
||||
if [ "$PF" != "online" ]; then
|
||||
echo "::error::parent ($PARENT_ID) never reached online (last=$PF) within ${PROVISION_TIMEOUT_SECS}s" >&2
|
||||
exit 3
|
||||
fi
|
||||
ok " parent online"
|
||||
|
||||
# ─── 3. Wait for every sibling online ──────────────────────────────────
|
||||
# A runtime that never comes online locally is itself a finding: it
|
||||
# reproduces the openclaw-never-online class (#165) on the local stack.
|
||||
log "3/5 waiting for all siblings online (up to ${PROVISION_TIMEOUT_SECS}s each — cold boot)..."
|
||||
REGRESSED=0
|
||||
ONLINE_RUNTIMES=""
|
||||
for rt in $ACTIVE_RUNTIMES; do
|
||||
wid="$(_map_get WS_IDS_MAP "$rt")"
|
||||
S=$(wait_for_status "$wid" "online" "$PROVISION_TIMEOUT_SECS") || true
|
||||
if [ "$S" != "online" ]; then
|
||||
echo " ✗ $rt ($wid): never reached online (last=$S) — reproduces the never-online class locally"
|
||||
_map_set VERDICT_MAP "$rt" "FAIL(never-online:last=$S)"
|
||||
REGRESSED=1
|
||||
continue
|
||||
fi
|
||||
ok " $rt online"
|
||||
ONLINE_RUNTIMES="$ONLINE_RUNTIMES $rt"
|
||||
done
|
||||
|
||||
# ─── 4. THE GATE — literal mcp_molecule_list_peers via POST /:id/mcp ────
|
||||
# Shared, byte-identical assertion. Local passes "" for the org id (the
|
||||
# single-tenant local stack does not gate on X-Molecule-Org-Id); the
|
||||
# literal MCP call + every anti-proxy / anti-native-fallback guarantee is
|
||||
# the SAME code the staging backend runs.
|
||||
log "4/5 driving the LITERAL list_peers MCP call per online runtime..."
|
||||
echo ""
|
||||
for rt in $ONLINE_RUNTIMES; do
|
||||
wid="$(_map_get WS_IDS_MAP "$rt")"
|
||||
WTOK=$(e2e_mint_test_token "$wid" 2>/dev/null || true)
|
||||
if [ -z "$WTOK" ]; then
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " ✗ $rt: could not mint a local MCP bearer (admin/test-token) — cannot drive the literal call"
|
||||
_map_set VERDICT_MAP "$rt" "FAIL(no-bearer)"
|
||||
REGRESSED=1
|
||||
echo ""
|
||||
continue
|
||||
fi
|
||||
PV_VERDICT=""
|
||||
pv_assert_runtime "$rt" "$wid" "$WTOK" "$BASE" "" "$ALL_WS_IDS" || REGRESSED=1
|
||||
_map_set VERDICT_MAP "$rt" "$PV_VERDICT"
|
||||
echo ""
|
||||
done
|
||||
|
||||
# ─── 5. Summary + honest gate exit ─────────────────────────────────────
|
||||
echo "=== SUMMARY — LOCAL fresh-provision peer-visibility (literal MCP list_peers) ==="
|
||||
for rt in $ACTIVE_RUNTIMES; do
|
||||
_v="$(_map_get VERDICT_MAP "$rt")"
|
||||
printf ' %-14s %s\n' "$rt" "${_v:-NO_RUN}"
|
||||
done
|
||||
echo ""
|
||||
|
||||
if [ "$REGRESSED" -ne 0 ]; then
|
||||
echo "✗ GATE FAILED (LOCAL) — at least one runtime cannot see its peers via"
|
||||
echo " the literal mcp_molecule_list_peers call on the local prod-mimic"
|
||||
echo " stack. This is the SAME user-facing failure the proxy signals were"
|
||||
echo " hiding, reproduced locally (far faster than EC2). Expected RED until"
|
||||
echo " the Hermes-401 (#162) + OpenClaw-never-online/MCP-wiring (#165)"
|
||||
echo " root-cause fixes land; goes green only when they actually do."
|
||||
exit 10
|
||||
fi
|
||||
|
||||
ok "GATE PASSED (LOCAL) — every runtime under test sees its platform peers via the literal MCP call."
|
||||
exit 0
|
||||
@@ -64,6 +64,13 @@
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# The literal MCP list_peers assertion lives in the shared, backend-
|
||||
# agnostic lib so it is BYTE-IDENTICAL between this staging backend and
|
||||
# the local docker-compose backend (tests/e2e/test_peer_visibility_mcp_
|
||||
# local.sh). Only provisioning/teardown differs per backend.
|
||||
# shellcheck source=tests/e2e/lib/peer_visibility_assert.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/lib/peer_visibility_assert.sh"
|
||||
|
||||
CP_URL="${MOLECULE_CP_URL:-https://staging-api.moleculesai.app}"
|
||||
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:?MOLECULE_ADMIN_TOKEN required — Railway staging CP_ADMIN_API_TOKEN}"
|
||||
RUN_ID_SUFFIX="${E2E_RUN_ID:-$(date +%H%M%S)-$$}"
|
||||
@@ -259,101 +266,19 @@ done
|
||||
# through WorkspaceAuth + MCPRateLimiter.
|
||||
log "6/6 driving the LITERAL list_peers MCP call per runtime..."
|
||||
echo ""
|
||||
RPC_BODY='{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_peers","arguments":{}}}'
|
||||
REGRESSED=0
|
||||
declare -A VERDICT
|
||||
|
||||
for rt in $PV_RUNTIMES; do
|
||||
wid="${WS_IDS[$rt]}"
|
||||
wtok="${WS_TOKENS[$rt]}"
|
||||
# The expected peer set = every OTHER provisioned workspace (parent +
|
||||
# the sibling runtimes), excluding the caller itself.
|
||||
EXPECT_IDS=$(echo "$ALL_WS_IDS" | tr ' ' '\n' | grep -v "^${wid}$" | grep -v '^$')
|
||||
|
||||
set +e
|
||||
RESP=$(curl -sS -X POST "$TENANT_URL/workspaces/$wid/mcp" \
|
||||
-H "Authorization: Bearer $wtok" \
|
||||
-H "X-Molecule-Org-Id: $ORG_ID" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$RPC_BODY" \
|
||||
-o /tmp/pv_mcp_body.json -w "%{http_code}" 2>/dev/null)
|
||||
set -e
|
||||
HTTP_CODE="$RESP"
|
||||
BODY=$(cat /tmp/pv_mcp_body.json 2>/dev/null || echo '')
|
||||
|
||||
echo "--- $rt (ws=$wid) ---"
|
||||
echo " HTTP $HTTP_CODE"
|
||||
echo " body: $(echo "$BODY" | head -c 600)"
|
||||
|
||||
# (1) HTTP 200 — a 401 (WorkspaceAuth reject, the Hermes symptom) fails here.
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo " ✗ $rt: list_peers MCP call returned HTTP $HTTP_CODE (expected 200)"
|
||||
VERDICT[$rt]="FAIL(http=$HTTP_CODE)"
|
||||
REGRESSED=1
|
||||
continue
|
||||
fi
|
||||
|
||||
# (2) JSON-RPC result present, not an error object.
|
||||
PARSE=$(echo "$BODY" | python3 -c "
|
||||
import sys, json
|
||||
expect = set(filter(None, '''$EXPECT_IDS'''.split()))
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception as e:
|
||||
print('PARSE_ERROR:' + str(e)); sys.exit(0)
|
||||
if isinstance(d, dict) and d.get('error') is not None:
|
||||
print('RPC_ERROR:' + json.dumps(d['error'])[:200]); sys.exit(0)
|
||||
res = d.get('result') if isinstance(d, dict) else None
|
||||
if res is None:
|
||||
print('NO_RESULT'); sys.exit(0)
|
||||
# MCP tools/call result shape: {content:[{type:text,text:'<json or prose>'}]}
|
||||
text = ''
|
||||
if isinstance(res, dict):
|
||||
for c in res.get('content', []):
|
||||
if c.get('type') == 'text':
|
||||
text += c.get('text', '')
|
||||
text_l = text.lower()
|
||||
# Native-sessions fallback signature (the OpenClaw symptom): the agent
|
||||
# answered from its own runtime session list, not the platform peer set.
|
||||
if 'sessions_list' in text_l or 'no platform peers' in text_l or 'native session' in text_l:
|
||||
print('NATIVE_FALLBACK:' + text[:200]); sys.exit(0)
|
||||
# The expected sibling IDs must literally appear in the returned peer text.
|
||||
found = sorted(i for i in expect if i in text)
|
||||
missing = sorted(expect - set(found))
|
||||
if not expect:
|
||||
print('NO_EXPECTED_PEERS_CONFIGURED'); sys.exit(0)
|
||||
if missing:
|
||||
print('MISSING_PEERS:found=%d/%d missing=%s' % (len(found), len(expect), ','.join(m[:8] for m in missing)))
|
||||
sys.exit(0)
|
||||
print('OK:found=%d/%d' % (len(found), len(expect)))
|
||||
" 2>/dev/null)
|
||||
|
||||
case "$PARSE" in
|
||||
OK:*)
|
||||
echo " ✓ $rt: list_peers returned 200 and contains all expected peers ($PARSE)"
|
||||
VERDICT[$rt]="OK"
|
||||
;;
|
||||
NATIVE_FALLBACK:*)
|
||||
echo " ✗ $rt: list_peers fell back to NATIVE sessions — sees no platform peers ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(native-fallback)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
RPC_ERROR:*|NO_RESULT|PARSE_ERROR:*)
|
||||
echo " ✗ $rt: list_peers MCP call did not return a usable result ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(rpc=$PARSE)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
MISSING_PEERS:*)
|
||||
echo " ✗ $rt: list_peers returned 200 but peer set is wrong/empty ($PARSE)"
|
||||
VERDICT[$rt]="FAIL(peers=$PARSE)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
*)
|
||||
echo " ✗ $rt: unexpected verdict '$PARSE'"
|
||||
VERDICT[$rt]="FAIL(unknown)"
|
||||
REGRESSED=1
|
||||
;;
|
||||
esac
|
||||
# Byte-identical assertion via the shared lib. Staging passes ORG_ID as
|
||||
# the X-Molecule-Org-Id header value; the literal MCP call + every
|
||||
# anti-proxy / anti-native-fallback guarantee is the SAME code the
|
||||
# local backend runs.
|
||||
PV_VERDICT=""
|
||||
pv_assert_runtime "$rt" "$wid" "$wtok" "$TENANT_URL" "$ORG_ID" "$ALL_WS_IDS" || REGRESSED=1
|
||||
VERDICT[$rt]="$PV_VERDICT"
|
||||
echo ""
|
||||
done
|
||||
|
||||
|
||||
@@ -105,53 +105,3 @@ func refreshEnvFromCP() error {
|
||||
log.Printf("CP env refresh: applied %d values from %s/cp/tenants/config", applied, base)
|
||||
return nil
|
||||
}
|
||||
|
||||
// requiredLLMEnvVars is the set of LLM proxy env vars a managed SaaS
|
||||
// tenant must have populated after refreshEnvFromCP. cp#469 (tenant
|
||||
// proxy-env delivery) — guaranteed CP-delivered creds reach the
|
||||
// tenant process env on boot. Per Researcher Task #37 / Spec 2 and
|
||||
// Task #46 (watch-fail-first test).
|
||||
//
|
||||
// Key set byte-matched against Researcher's verified emission in
|
||||
// controlplane tenant_config.go:140-144 (Researcher REQUEST_CHANGES
|
||||
// iterate body, 3987f59c). The four keys below ARE the LLM-proxy
|
||||
// subset of the 8 CP-emitted keys; OPENAI_BASE_URL / OPENAI_API_KEY /
|
||||
// ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY are out of scope for cp#469
|
||||
// (different feature surfaces — direct-to-provider fallbacks, not
|
||||
// the proxy). v2 fix: MOLECULE_LLM_USAGE_TOKEN, MOLECULE_LLM_USAGE_URL,
|
||||
// MOLECULE_LLM_BASE_URL, MOLECULE_LLM_ANTHROPIC_BASE_URL — note the
|
||||
// 4th key is namespaced MOLECULE_LLM_ANTHROPIC_BASE_URL, NOT bare
|
||||
// ANTHROPIC_BASE_URL. Bare ANTHROPIC_BASE_URL is a separate CP-emitted
|
||||
// key for direct-provider use, not the LLM proxy.
|
||||
var requiredLLMEnvVars = []string{
|
||||
"MOLECULE_LLM_USAGE_TOKEN",
|
||||
"MOLECULE_LLM_USAGE_URL", // CRITICAL fix v2: was MOLECULE_LLM_URL in v1
|
||||
"MOLECULE_LLM_BASE_URL",
|
||||
"MOLECULE_LLM_ANTHROPIC_BASE_URL", // CRITICAL fix v3: was ANTHROPIC_BASE_URL in v2 (different key!)
|
||||
}
|
||||
|
||||
// assertManagedTenantHasLLMEnv verifies that, when running as a
|
||||
// managed SaaS tenant (MOLECULE_ORG_ID + ADMIN_TOKEN both set), all
|
||||
// required LLM proxy env vars are populated after refreshEnvFromCP.
|
||||
//
|
||||
// Self-hosted (no orgID/adminToken) is exempt — dev must not be
|
||||
// blocked here. Managed tenants with missing LLM keys fail with
|
||||
// MISSING_CP_LLM_ENV so they do not silently boot with broken proxy
|
||||
// creds. Caller in main.go decides whether to log and continue or
|
||||
// log.Fatalf depending on deployment context.
|
||||
func assertManagedTenantHasLLMEnv() error {
|
||||
if os.Getenv("MOLECULE_ORG_ID") == "" || os.Getenv("ADMIN_TOKEN") == "" {
|
||||
// Self-hosted dev / not yet provisioned — not a managed tenant.
|
||||
return nil
|
||||
}
|
||||
var missing []string
|
||||
for _, k := range requiredLLMEnvVars {
|
||||
if os.Getenv(k) == "" {
|
||||
missing = append(missing, k)
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return fmt.Errorf("MISSING_CP_LLM_ENV: required LLM proxy keys not set after refreshEnvFromCP: %v", missing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -48,138 +47,6 @@ func TestRefreshEnvFromCP_AppliesCPResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshEnvFromCP_ManagedTenantRequiresLLMKeys: watch-fail-first
|
||||
// per Researcher Task #46. When running as a managed tenant
|
||||
// (MOLECULE_ORG_ID + ADMIN_TOKEN set), missing LLM proxy env vars
|
||||
// after refreshEnvFromCP MUST surface as MISSING_CP_LLM_ENV, not be
|
||||
// silently accepted. Without this guard, a CP that loses its LLM
|
||||
// creds (e.g. during an incident) would let a tenant boot and then
|
||||
// fail later at first LLM call — worse than a loud refusal here.
|
||||
func TestRefreshEnvFromCP_ManagedTenantRequiresLLMKeys(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Stub CP returns a CP response WITHOUT any of the required
|
||||
// LLM keys — simulates the failure mode where the CP side
|
||||
// dropped or never had the LLM creds for this org.
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"MOLECULE_CP_SHARED_SECRET":"x","MOLECULE_CP_URL":"https://api.moleculesai.app"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("MOLECULE_ORG_ID", "org-managed-1")
|
||||
t.Setenv("ADMIN_TOKEN", "admin-tok")
|
||||
t.Setenv("MOLECULE_CP_URL", srv.URL)
|
||||
// Clear all LLM keys to simulate the boot-without-LLM-env failure mode.
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "")
|
||||
|
||||
// refreshEnvFromCP itself should succeed — CP is reachable, returned 200.
|
||||
if err := refreshEnvFromCP(); err != nil {
|
||||
t.Fatalf("refreshEnvFromCP: %v", err)
|
||||
}
|
||||
// The boot assertion must catch the missing LLM keys.
|
||||
err := assertManagedTenantHasLLMEnv()
|
||||
if err == nil {
|
||||
t.Fatal("expected MISSING_CP_LLM_ENV error for managed tenant without LLM keys, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MISSING_CP_LLM_ENV") {
|
||||
t.Errorf("expected error to contain MISSING_CP_LLM_ENV, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshEnvFromCP_ManagedTenantHappyPath: when the CP returns
|
||||
// all 4 LLM-proxy keys, the gate must PASS — no MISSING_CP_LLM_ENV
|
||||
// for a properly-configured managed tenant. Watch-fail counterpart
|
||||
// to TestRefreshEnvFromCP_ManagedTenantRequiresLLMKeys: if THIS test
|
||||
// ever fires MISSING_CP_LLM_ENV on the byte-correct key set, the
|
||||
// requiredLLMEnvVars list has drifted from the CP emission again.
|
||||
// Per Researcher REQUEST_CHANGES TEST ADEQUACY note.
|
||||
func TestRefreshEnvFromCP_ManagedTenantHappyPath(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return ALL 4 LLM-proxy keys — names byte-matched to
|
||||
// tenant_config.go:140-144 CP emission.
|
||||
fmt.Fprint(w, `{"MOLECULE_LLM_USAGE_TOKEN":"tok-1","MOLECULE_LLM_USAGE_URL":"https://llm.example.com/usage","MOLECULE_LLM_BASE_URL":"https://llm.example.com","MOLECULE_LLM_ANTHROPIC_BASE_URL":"https://llm.example.com/anthropic"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("MOLECULE_ORG_ID", "org-managed-happy")
|
||||
t.Setenv("ADMIN_TOKEN", "admin-tok")
|
||||
t.Setenv("MOLECULE_CP_URL", srv.URL)
|
||||
// Pre-clear so we can verify the refresh actually populated them.
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "")
|
||||
|
||||
if err := refreshEnvFromCP(); err != nil {
|
||||
t.Fatalf("refreshEnvFromCP: %v", err)
|
||||
}
|
||||
// Sanity: refresh actually applied the keys.
|
||||
if got := os.Getenv("MOLECULE_LLM_USAGE_TOKEN"); got != "tok-1" {
|
||||
t.Errorf("refresh did not apply USAGE_TOKEN: got %q", got)
|
||||
}
|
||||
// The boot assertion must pass — no MISSING_CP_LLM_ENV.
|
||||
if err := assertManagedTenantHasLLMEnv(); err != nil {
|
||||
t.Errorf("managed happy path must not MISSING_CP_LLM_ENV, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshEnvFromCP_ManagedTenantPartialEnv: when the CP returns
|
||||
// 3 of 4 LLM-proxy keys (one missing), the gate must STILL catch it
|
||||
// and the error must name the missing key. Per Researcher
|
||||
// REQUEST_CHANGES TEST ADEQUACY note — partial-env coverage is
|
||||
// critical because the production failure mode is usually "one
|
||||
// key dropped" not "all keys dropped".
|
||||
func TestRefreshEnvFromCP_ManagedTenantPartialEnv(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// 3 of 4 — MOLECULE_LLM_ANTHROPIC_BASE_URL is missing.
|
||||
fmt.Fprint(w, `{"MOLECULE_LLM_USAGE_TOKEN":"tok-1","MOLECULE_LLM_USAGE_URL":"https://llm.example.com/usage","MOLECULE_LLM_BASE_URL":"https://llm.example.com"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
t.Setenv("MOLECULE_ORG_ID", "org-managed-partial")
|
||||
t.Setenv("ADMIN_TOKEN", "admin-tok")
|
||||
t.Setenv("MOLECULE_CP_URL", srv.URL)
|
||||
// Pre-clear all 4 so the 3 that come back from CP are the only
|
||||
// ones set; the 4th (MOLECULE_LLM_ANTHROPIC_BASE_URL) stays empty.
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "")
|
||||
|
||||
if err := refreshEnvFromCP(); err != nil {
|
||||
t.Fatalf("refreshEnvFromCP: %v", err)
|
||||
}
|
||||
err := assertManagedTenantHasLLMEnv()
|
||||
if err == nil {
|
||||
t.Fatal("expected MISSING_CP_LLM_ENV for partial env (3 of 4 keys), got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MISSING_CP_LLM_ENV") {
|
||||
t.Errorf("expected error to contain MISSING_CP_LLM_ENV, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "MOLECULE_LLM_ANTHROPIC_BASE_URL") {
|
||||
t.Errorf("expected error to name the missing key MOLECULE_LLM_ANTHROPIC_BASE_URL, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAssertManagedTenantHasLLMEnv_NotManagedIsNoop: self-hosted
|
||||
// (no orgID/adminToken) must NOT block on missing LLM keys — dev
|
||||
// ergonomics matter and the assertion's contract is "managed only".
|
||||
func TestAssertManagedTenantHasLLMEnv_NotManagedIsNoop(t *testing.T) {
|
||||
t.Setenv("MOLECULE_ORG_ID", "")
|
||||
t.Setenv("ADMIN_TOKEN", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_TOKEN", "")
|
||||
t.Setenv("MOLECULE_LLM_USAGE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_BASE_URL", "")
|
||||
t.Setenv("MOLECULE_LLM_ANTHROPIC_BASE_URL", "")
|
||||
if err := assertManagedTenantHasLLMEnv(); err != nil {
|
||||
t.Errorf("self-hosted (not managed) must not block, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefreshEnvFromCP_CPUnreachableDoesNotFailBoot: network errors must
|
||||
// return non-nil BUT main.go treats that as warn-and-continue. We assert
|
||||
// the function returns an error (not a panic) so the caller can log.
|
||||
|
||||
@@ -56,16 +56,6 @@ func main() {
|
||||
log.Printf("CP env refresh: %v (continuing with baked-in env)", err)
|
||||
}
|
||||
|
||||
// Managed-tenant boot assertion (cp#469 — tenant proxy-env delivery).
|
||||
// If we're a managed SaaS tenant (orgID + adminToken set), all required
|
||||
// LLM proxy env vars must be present after refresh. Missing keys block
|
||||
// the tenant from booting with broken LLM creds — silent-fail is worse
|
||||
// than a loud refusal. Self-hosted (no orgID/adminToken) short-circuits
|
||||
// inside the assertion, so this never fires for dev.
|
||||
if err := assertManagedTenantHasLLMEnv(); err != nil {
|
||||
log.Fatalf("Managed tenant boot assertion: %v", err)
|
||||
}
|
||||
|
||||
// Secrets encryption. In MOLECULE_ENV=prod, boot refuses to start
|
||||
// without a valid SECRETS_ENCRYPTION_KEY (fail-secure — Top-5 #5).
|
||||
// In any other environment, missing keys just log a warning and
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Command t4-contract-dump prints the T4 privilege contract as YAML.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./workspace-server/cmd/t4-contract-dump > t4_capabilities.yaml
|
||||
//
|
||||
// This is the seam that template-repo CI workflows consume:
|
||||
//
|
||||
// - Template CI fetches molecule-core at pinned ref
|
||||
// - Runs `go run ./workspace-server/cmd/t4-contract-dump` to produce
|
||||
// t4_capabilities.yaml
|
||||
// - Iterates capabilities and runs each Probe inside a freshly-built
|
||||
// privileged container
|
||||
// - Aggregates structured pass/fail; fails the gate on any hard miss.
|
||||
//
|
||||
// Keeping this trivial and pure-stdlib means a fork user does not need
|
||||
// a Molecule-AI Gitea token or any internal infrastructure to consume
|
||||
// the contract — `go run` against molecule-core's public source is
|
||||
// enough.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
)
|
||||
|
||||
func main() {
|
||||
caps := provisioner.T4PrivilegeContract()
|
||||
if _, err := os.Stdout.WriteString(provisioner.AsYAML(caps)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "t4-contract-dump: write failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -41,9 +41,8 @@ type EventType string
|
||||
// scan-friendly as it grows.
|
||||
const (
|
||||
// Chat / agent messaging — surfaces in canvas chat panels.
|
||||
EventAgentMessage EventType = "AGENT_MESSAGE"
|
||||
EventA2AResponse EventType = "A2A_RESPONSE"
|
||||
EventUserMessage EventType = "USER_MESSAGE"
|
||||
EventAgentMessage EventType = "AGENT_MESSAGE"
|
||||
EventA2AResponse EventType = "A2A_RESPONSE"
|
||||
EventActivityLogged EventType = "ACTIVITY_LOGGED"
|
||||
EventChannelMessage EventType = "CHANNEL_MESSAGE"
|
||||
|
||||
@@ -96,7 +95,6 @@ const (
|
||||
var AllEventTypes = []EventType{
|
||||
EventA2AResponse,
|
||||
EventActivityLogged,
|
||||
EventUserMessage,
|
||||
EventAgentAssigned,
|
||||
EventAgentCardUpdated,
|
||||
EventAgentMessage,
|
||||
|
||||
@@ -41,7 +41,6 @@ func TestAllEventTypes_IsSnapshot(t *testing.T) {
|
||||
"DELEGATION_STATUS",
|
||||
"EXTERNAL_CREDENTIALS_ROTATED",
|
||||
"TASK_UPDATED",
|
||||
"USER_MESSAGE",
|
||||
"WORKSPACE_AWAITING_AGENT",
|
||||
"WORKSPACE_DEGRADED",
|
||||
"WORKSPACE_HEARTBEAT",
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/db"
|
||||
@@ -169,6 +168,21 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
|
||||
if !h.HasProvisioner() {
|
||||
return false
|
||||
}
|
||||
// Restart-aware short-circuit: during the 20-30s EC2-pending window of
|
||||
// an in-flight restart, the workspace's url='' and IsRunning() returns
|
||||
// false → looks indistinguishable from a dead container. Pre-fix this
|
||||
// fired a fresh RestartByID for the just-launched instance, which
|
||||
// coalesceRestart's pending-flag drained by running ANOTHER full
|
||||
// stop+provision cycle (= ec2_stopped of the still-pending instance
|
||||
// → re-provision). That's the 4x reprov thrash class. Skip the
|
||||
// container-dead path while a restart is in flight; the in-flight
|
||||
// restart's own provisionWorkspaceAutoSync will surface a real failure
|
||||
// (markProvisionFailed) if the new container never comes up. Issue
|
||||
// internal#544.
|
||||
if isRestarting(workspaceID) {
|
||||
log.Printf("ProxyA2A: maybeMarkContainerDead skipped for %s — restart already in flight (self-fire guard)", workspaceID)
|
||||
return false
|
||||
}
|
||||
|
||||
var running bool
|
||||
var inspectErr error
|
||||
@@ -224,6 +238,18 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
|
||||
// shape post-EC2-replace (see molecule-controlplane#20 incident
|
||||
// 2026-05-07) where the reconciler hasn't respawned the agent yet.
|
||||
func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspaceID string) *proxyA2AError {
|
||||
// Restart-aware short-circuit (mirror of maybeMarkContainerDead): if a
|
||||
// restart cycle is in flight for this workspace, do not run the
|
||||
// IsRunning probe — it would observe the EC2-pending state as "not
|
||||
// running" and trigger RestartByID for an already-restarting workspace,
|
||||
// closing the self-fire loop. Returning nil lets the optimistic
|
||||
// forward proceed; the upstream Do() call will fail with a connection
|
||||
// error or 502, and the *post-restart* reactive path can decide what
|
||||
// to do once the cycle has actually completed. Issue internal#544.
|
||||
if isRestarting(workspaceID) {
|
||||
log.Printf("ProxyA2A preflight: %s — skipped, restart already in flight (self-fire guard)", workspaceID)
|
||||
return nil
|
||||
}
|
||||
running, err := h.provisioner.IsRunning(ctx, workspaceID)
|
||||
if err != nil {
|
||||
// Transient daemon error. Provisioner.IsRunning returns (true, err)
|
||||
@@ -345,19 +371,6 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
||||
"duration_ms": durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
// #228: fan user's own outbound message to all sessions of the workspace.
|
||||
// When a canvas user sends a message (callerID == "" and method == "message/send"),
|
||||
// the originating session already inserted it optimistically in useChatSend.
|
||||
// Other sessions see nothing until a manual refresh — this broadcast closes
|
||||
// that gap. The originating session collapses its optimistic copy via the
|
||||
// 3-second appendMessageDeduped window (same role + content = deduped).
|
||||
if callerID == "" && a2aMethod == "message/send" && statusCode < 400 {
|
||||
userPayload := extractCanvasUserMessage(body)
|
||||
if userPayload != nil {
|
||||
h.broadcaster.BroadcastOnly(workspaceID, string(events.EventUserMessage), userPayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func nilIfEmpty(s string) *string {
|
||||
@@ -407,110 +420,6 @@ func validateCallerToken(ctx context.Context, c *gin.Context, callerID string) e
|
||||
// matching (the wsauth errors are typed for the invalid case).
|
||||
var errInvalidCallerToken = errors.New("missing caller auth token")
|
||||
|
||||
// extractCanvasUserMessage parses an A2A JSON-RPC request body and extracts
|
||||
// the user-authored text and attachments from a canvas-initiated message/send.
|
||||
// Returns nil when the body is not a canvas user message (empty, malformed,
|
||||
// or not a message/send from canvas). The returned payload is safe to pass
|
||||
// directly to BroadcastOnly — nil fields are omitted from JSON.
|
||||
func extractCanvasUserMessage(body []byte) map[string]interface{} {
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
var top map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &top); err != nil {
|
||||
return nil
|
||||
}
|
||||
// Only handle message/send from canvas
|
||||
var method string
|
||||
if err := json.Unmarshal(top["method"], &method); err != nil || method != "message/send" {
|
||||
return nil
|
||||
}
|
||||
params, ok := top["params"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var paramsMap map[string]json.RawMessage
|
||||
if err := json.Unmarshal(params, ¶msMap); err != nil {
|
||||
return nil
|
||||
}
|
||||
msgRaw, ok := paramsMap["message"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var msg map[string]json.RawMessage
|
||||
if err := json.Unmarshal(msgRaw, &msg); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// role field: only broadcast user-role messages (canvas users)
|
||||
var role string
|
||||
if err := json.Unmarshal(msg["role"], &role); err != nil || role != "user" {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Extract messageId if present
|
||||
var mid string
|
||||
if err := json.Unmarshal(msg["messageId"], &mid); err == nil && mid != "" {
|
||||
result["messageId"] = mid
|
||||
}
|
||||
|
||||
// Extract text from parts — accumulate all text parts into a single string
|
||||
var parts []json.RawMessage
|
||||
if err := json.Unmarshal(msg["parts"], &parts); err == nil {
|
||||
var texts []string
|
||||
var fileAttachments []map[string]interface{}
|
||||
for _, pRaw := range parts {
|
||||
var p map[string]json.RawMessage
|
||||
if err := json.Unmarshal(pRaw, &p); err != nil {
|
||||
continue
|
||||
}
|
||||
var t string
|
||||
if err := json.Unmarshal(p["text"], &t); err == nil && t != "" {
|
||||
texts = append(texts, t)
|
||||
}
|
||||
var fileRaw json.RawMessage
|
||||
if err := json.Unmarshal(p["file"], &fileRaw); err == nil && fileRaw != nil {
|
||||
var f map[string]json.RawMessage
|
||||
if err := json.Unmarshal(fileRaw, &f); err == nil {
|
||||
att := make(map[string]interface{})
|
||||
var s string
|
||||
if err := json.Unmarshal(f["uri"], &s); err == nil {
|
||||
att["uri"] = s
|
||||
}
|
||||
if err := json.Unmarshal(f["name"], &s); err == nil {
|
||||
att["name"] = s
|
||||
}
|
||||
if err := json.Unmarshal(f["mimeType"], &s); err == nil {
|
||||
att["mimeType"] = s
|
||||
}
|
||||
var n float64
|
||||
if err := json.Unmarshal(f["size"], &n); err == nil {
|
||||
att["size"] = n
|
||||
}
|
||||
if len(att) > 0 {
|
||||
fileAttachments = append(fileAttachments, att)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(texts) > 0 {
|
||||
// Join with newlines — user may have sent multiple text parts
|
||||
result["message"] = strings.Join(texts, "\n")
|
||||
}
|
||||
if len(fileAttachments) > 0 {
|
||||
result["attachments"] = fileAttachments
|
||||
}
|
||||
}
|
||||
|
||||
// Drop empty payloads
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractToolTrace pulls metadata.tool_trace from an A2A JSON-RPC response.
|
||||
// Returns nil when absent or malformed — callers can pass it straight through.
|
||||
func extractToolTrace(respBody []byte) json.RawMessage {
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExtractCanvasUserMessage_TextOnly covers the primary path: a canvas user
|
||||
// sends a plain text message with no attachments.
|
||||
func TestExtractCanvasUserMessage_TextOnly(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"messageId": "msg-abc-123",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "Hello, agent!"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
got := extractCanvasUserMessage(body)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil payload for text message")
|
||||
}
|
||||
if got["message"] != "Hello, agent!" {
|
||||
t.Errorf("message = %v, want %q", got["message"], "Hello, agent!")
|
||||
}
|
||||
mid, ok := got["messageId"].(string)
|
||||
if !ok || mid != "msg-abc-123" {
|
||||
t.Errorf("messageId = %v, want %q", got["messageId"], "msg-abc-123")
|
||||
}
|
||||
_, hasAttachments := got["attachments"]
|
||||
if hasAttachments {
|
||||
t.Errorf("unexpected attachments: %v", got["attachments"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCanvasUserMessage_FileOnly covers a user message with a file but no text.
|
||||
func TestExtractCanvasUserMessage_FileOnly(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"messageId": "msg-file-456",
|
||||
"parts": [
|
||||
{
|
||||
"kind": "file",
|
||||
"file": {
|
||||
"name": "report.pdf",
|
||||
"uri": "workspace:/uploads/report.pdf",
|
||||
"mimeType": "application/pdf",
|
||||
"size": 4096
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
got := extractCanvasUserMessage(body)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil payload for file-only message")
|
||||
}
|
||||
if got["message"] != nil {
|
||||
t.Errorf("unexpected message text: %v", got["message"])
|
||||
}
|
||||
attachments, ok := got["attachments"].([]map[string]interface{})
|
||||
if !ok || len(attachments) != 1 {
|
||||
t.Fatalf("attachments = %v, want 1-element array", got["attachments"])
|
||||
}
|
||||
att := attachments[0]
|
||||
if att["uri"] != "workspace:/uploads/report.pdf" {
|
||||
t.Errorf("uri = %v, want %q", att["uri"], "workspace:/uploads/report.pdf")
|
||||
}
|
||||
if att["name"] != "report.pdf" {
|
||||
t.Errorf("name = %v, want %q", att["name"], "report.pdf")
|
||||
}
|
||||
if att["mimeType"] != "application/pdf" {
|
||||
t.Errorf("mimeType = %v, want %q", att["mimeType"], "application/pdf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCanvasUserMessage_TextAndFile covers a user message with both text and a file.
|
||||
func TestExtractCanvasUserMessage_TextAndFile(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "Here is the file:"},
|
||||
{"kind": "text", "text": "see below"},
|
||||
{
|
||||
"kind": "file",
|
||||
"file": {
|
||||
"name": "data.csv",
|
||||
"uri": "workspace:/exports/data.csv",
|
||||
"mimeType": "text/csv",
|
||||
"size": 8192
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
got := extractCanvasUserMessage(body)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil payload")
|
||||
}
|
||||
// Two text parts are joined with newline
|
||||
if got["message"] != "Here is the file:\nsee below" {
|
||||
t.Errorf("message = %v, want %q", got["message"], "Here is the file:\nsee below")
|
||||
}
|
||||
attachments, ok := got["attachments"].([]map[string]interface{})
|
||||
if !ok || len(attachments) != 1 {
|
||||
t.Fatalf("attachments = %v, want 1-element array", got["attachments"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCanvasUserMessage_Malformed covers malformed JSON.
|
||||
func TestExtractCanvasUserMessage_Malformed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{"not JSON", []byte(`{not valid`)},
|
||||
{"wrong type top-level", []byte(`123`)},
|
||||
{"missing params", []byte(`{"method":"message/send"}`)},
|
||||
{"params not object", []byte(`{"method":"message/send","params":123}`)},
|
||||
{"missing message", []byte(`{"method":"message/send","params":{}}`)},
|
||||
{"message not object", []byte(`{"method":"message/send","params":{"message":123}}`)},
|
||||
{"role missing", []byte(`{"method":"message/send","params":{"message":{"parts":[]}}}`)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractCanvasUserMessage(tc.body); got != nil {
|
||||
t.Errorf("expected nil for %s, got %v", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCanvasUserMessage_NotUserRole covers agent/workspace callers
|
||||
// whose role is not "user" — these should not be broadcast as USER_MESSAGE.
|
||||
func TestExtractCanvasUserMessage_NotUserRole(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{
|
||||
"agent role",
|
||||
[]byte(`{"method":"message/send","params":{"message":{"role":"agent","parts":[{"kind":"text","text":"hello"}]}}}`),
|
||||
},
|
||||
{
|
||||
"assistant role",
|
||||
[]byte(`{"method":"message/send","params":{"message":{"role":"assistant","parts":[{"kind":"text","text":"hello"}]}}}`),
|
||||
},
|
||||
{
|
||||
"empty role",
|
||||
[]byte(`{"method":"message/send","params":{"message":{"role":"","parts":[{"kind":"text","text":"hello"}]}}}`),
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := extractCanvasUserMessage(tc.body); got != nil {
|
||||
t.Errorf("expected nil for role=%s, got %v", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCanvasUserMessage_NotMessageSend covers non-message/send methods.
|
||||
func TestExtractCanvasUserMessage_NotMessageSend(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
method string
|
||||
}{
|
||||
{"tasks/send", "tasks/send"},
|
||||
{"initialize", "initialize"},
|
||||
{"ping", "ping"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(map[string]interface{}{
|
||||
"method": tc.method,
|
||||
"params": map[string]interface{}{
|
||||
"message": map[string]interface{}{
|
||||
"role": "user",
|
||||
"parts": []map[string]interface{}{{"kind": "text", "text": "hello"}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if got := extractCanvasUserMessage(body); got != nil {
|
||||
t.Errorf("expected nil for method=%q, got %v", tc.method, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCanvasUserMessage_BlankOrEmpty covers text with only whitespace
|
||||
// and empty parts arrays.
|
||||
func TestExtractCanvasUserMessage_BlankOrEmpty(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{
|
||||
"empty text part",
|
||||
[]byte(`{"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":""}]}}}`),
|
||||
},
|
||||
{
|
||||
"empty parts array",
|
||||
[]byte(`{"method":"message/send","params":{"message":{"role":"user","parts":[]}}}`),
|
||||
},
|
||||
{
|
||||
"whitespace-only text — still included as valid content",
|
||||
[]byte(`{"method":"message/send","params":{"message":{"role":"user","parts":[{"kind":"text","text":" "}]}}}`),
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := extractCanvasUserMessage(tc.body)
|
||||
if tc.name == "whitespace-only text — still included as valid content" {
|
||||
// Whitespace-only text is valid content — preserve it as-is.
|
||||
// Canvas dedup collapses identical copies; whitespace is not stripped.
|
||||
if got == nil {
|
||||
t.Error("expected non-nil for whitespace-only text")
|
||||
} else if got["message"] != " " {
|
||||
t.Errorf("message = %q, want %q", got["message"], " ")
|
||||
}
|
||||
return
|
||||
}
|
||||
if got != nil {
|
||||
t.Errorf("expected nil for %s, got %v", tc.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractCanvasUserMessage_Unicode covers non-ASCII text.
|
||||
func TestExtractCanvasUserMessage_Unicode(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"method": "message/send",
|
||||
"params": {
|
||||
"message": {
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"kind": "text", "text": "こんにちは世界 🌍 日本語"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}`)
|
||||
got := extractCanvasUserMessage(body)
|
||||
if got == nil {
|
||||
t.Fatal("expected non-nil payload for unicode message")
|
||||
}
|
||||
if got["message"] != "こんにちは世界 🌍 日本語" {
|
||||
t.Errorf("message = %v, want %q", got["message"], "こんにちは世界 🌍 日本語")
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user