Compare commits

..

2 Commits

Author SHA1 Message Date
core-fe ac7c395855 fix(canvas/ContextMenu): prevent React error #185 by moving hasChildren derivation out of Zustand selector
ContextMenu used `.some()` inside its Zustand selector to compute hasChildren.
Zustand's useSyncExternalStore calls the selector on every snapshot; `.some()`
returns a new boolean each time, which React 19's stricter comparison
and the re-render side-effects from the store subscription created a
feedback loop on mobile Chat tab mount → React error #185
("Maximum update depth exceeded").

Fix: select the stable `nodes` array once, derive children via useMemo
outside the store subscription. Also removes the inline `getState().nodes.filter()`
call in handleDelete in favour of the memoized children.

Regression tests (2 cases):
- setPendingDelete receives correct children array when workspace has children
- setPendingDelete hasChildren=false and empty children when no children

Refs: #651

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:08:28 +00:00
core-fe 3edb68ab77 test(canvas/lib): add isExternalLikeRuntime coverage (16 cases)
Mirrors the backend isExternalLikeRuntime() contract so both sides agree
on which runtimes are external-like (no platform container, no Files/Terminal tabs).

Cases: "external", "kimi", "kimi-cli" → true; all other runtimes,
undefined, null, empty string → false. Case-sensitivity verified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 00:08:28 +00:00
207 changed files with 2227 additions and 16394 deletions
-1
View File
@@ -1 +0,0 @@
refire:1778784369
+4 -11
View File
@@ -203,17 +203,12 @@ def ci_jobs_all(ci_doc: dict) -> set[str]:
def ci_job_names(ci_doc: dict) -> set[str]:
"""Set of job keys in ci.yml MINUS the sentinel itself MINUS jobs
whose `if:` gates on `github.event_name` or `github.ref` (those are
event-scoped and can legitimately be `skipped` for a given trigger;
if we required them under the sentinel `needs:`, every PR-only job
whose `if:` gates on `github.event_name` (those are event-scoped
and can legitimately be `skipped` for a given trigger; if we
required them under the sentinel `needs:`, every PR-only job
would be `skipped` on push and the sentinel would interpret
`skipped != success` as failure). RFC §4 spec.
`github.ref` is the companion gate for jobs that run only on direct
pushes to specific branches (e.g. `github.ref == 'refs/heads/main'`).
These never execute in a PR context, so flagging them as missing
from `all-required.needs:` is a false positive (mc#958 / mc#959).
Used for F1 (jobs missing from sentinel needs). NOT used for F1b
(typos in needs) — see `ci_jobs_all` for that."""
jobs = ci_doc.get("jobs")
@@ -226,9 +221,7 @@ def ci_job_names(ci_doc: dict) -> set[str]:
continue
if isinstance(v, dict):
gate = v.get("if")
if isinstance(gate, str) and (
"github.event_name" in gate or "github.ref" in gate
):
if isinstance(gate, str) and "github.event_name" in gate:
continue
names.add(k)
return names
+18 -119
View File
@@ -47,15 +47,6 @@ REQUIRED_CONTEXTS_RAW = _env(
"sop-checklist / all-items-acked (pull_request)"
),
)
# Required contexts for push (main/staging) runs. The push CI uses the same
# aggregator names with " (push)" suffix. Checking these explicitly instead of
# the combined state avoids false-pause when non-blocking jobs (e.g. Platform
# Go with continue-on-error: true due to mc#774) have failed — their failures
# pollute the combined state but do not block merges.
PUSH_REQUIRED_CONTEXTS_RAW = _env(
"PUSH_REQUIRED_CONTEXTS",
default="CI / all-required (push)",
)
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
@@ -65,11 +56,6 @@ class ApiError(RuntimeError):
pass
class MergePermissionError(ApiError):
"""Merge failed with a permanent permission error (403/404/405).
The queue should skip this PR and move to the next one."""
@dataclasses.dataclass(frozen=True)
class MergeDecision:
ready: bool
@@ -132,24 +118,16 @@ def required_contexts(raw: str) -> list[str]:
return [part.strip() for part in raw.split(",") if part.strip()]
def push_required_contexts() -> list[str]:
"""Required contexts for push (branch) CI runs. See PUSH_REQUIRED_CONTEXTS_RAW."""
return required_contexts(PUSH_REQUIRED_CONTEXTS_RAW)
def status_state(status: dict) -> str:
return str(status.get("status") or status.get("state") or "").lower()
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
# Gitea /statuses endpoint returns entries in ascending id order (oldest
# first). We need the LAST occurrence of each context, so iterate in
# reverse to prefer newer entries.
latest: dict[str, dict] = {}
for status in reversed(statuses):
for status in statuses:
context = status.get("context")
if isinstance(context, str):
latest[context] = status # overwrite: reverse order → newest wins
if isinstance(context, str) and context not in latest:
latest[context] = status
return latest
@@ -215,23 +193,16 @@ def evaluate_merge_readiness(
required_contexts: list[str],
pr_has_current_base: bool,
) -> MergeDecision:
# Check push-required contexts explicitly instead of combined state.
# Combined state can be "failure" due to non-blocking jobs
# (continue-on-error: true) that don't actually gate merges.
# CI / all-required (push) is the authoritative gate — it respects
# continue-on-error and correctly aggregates all blocking failures.
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
if not main_ok:
return MergeDecision(False, "pause", "main required contexts not green: " + ", ".join(main_bad))
main_state = str(main_status.get("state") or "").lower()
if main_state != "success":
return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}")
if not pr_has_current_base:
return MergeDecision(False, "update", "PR head does not contain current main")
# Check explicit required contexts instead of combined state. Combined state
# can be "failure" due to non-blocking jobs with continue-on-error: true
# (e.g. publish-runtime-autobump/pr-validate, qa-review on stale tokens).
# The required_contexts list is the authoritative gate — it includes only
# the checks that actually block merges.
pr_state = str(pr_status.get("state") or "").lower()
if pr_state != "success":
return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}")
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
ok, missing_or_bad = required_contexts_green(latest, required_contexts)
if not ok:
@@ -249,37 +220,10 @@ def get_branch_head(branch: str) -> str:
def get_combined_status(sha: str) -> dict:
"""Combined status + all individual statuses for `sha`.
The /status endpoint caps the `statuses` array at 30 entries (Gitea
default page size), so we fetch the full list via /statuses with a
higher limit. The combined `state` still comes from /status.
"""
_, combined = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
if not isinstance(combined, dict):
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
if not isinstance(body, dict):
raise ApiError(f"status for {sha} response not object")
# Fetch full statuses list; 200 covers >99% of real-world runs.
# The list is ordered ascending by id (oldest first) — callers must
# iterate in reverse to get the newest entry per context.
# Best-effort: large repos (main with 550+ statuses) may time out.
# On timeout, fall back to the statuses[] already in the combined
# response (usually 30 entries — enough for most PRs, enough for
# main's early push-required contexts).
try:
_, all_statuses = api(
"GET",
f"/repos/{OWNER}/{NAME}/commits/{sha}/statuses",
query={"limit": "50"},
)
if isinstance(all_statuses, list):
combined["statuses"] = all_statuses
except (ApiError, urllib.error.URLError, TimeoutError, OSError) as exc:
# URLError covers network-level failures (DNS, refused, timeout).
# TimeoutError and OSError cover socket-level timeouts.
sys.stderr.write(f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n")
# Fall back to the statuses[] already in the combined response.
pass
return combined
return body
def list_queued_issues() -> list[dict]:
@@ -343,28 +287,15 @@ def merge_pull(pr_number: int, *, dry_run: bool) -> None:
print(f"::notice::merging PR #{pr_number}")
if dry_run:
return
try:
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
except ApiError as exc:
# Re-raise permission-like errors so process_once can skip this PR.
# 403 = no push access, 404 = repo/pr not found, 405 = not allowed.
msg = str(exc)
for code in ("403", "404", "405"):
if code in msg:
raise MergePermissionError(msg) from exc
raise # re-raise other ApiErrors unchanged
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
def process_once(*, dry_run: bool = False) -> int:
contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
main_sha = get_branch_head(WATCH_BRANCH)
main_status = get_combined_status(main_sha)
# Check push-required contexts explicitly instead of combined state.
# See evaluate_merge_readiness for rationale.
main_latest = latest_statuses_by_context(main_status.get("statuses") or [])
main_ok, main_bad = required_contexts_green(main_latest, push_required_contexts())
if not main_ok:
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} required contexts not green: {', '.join(main_bad)}")
if str(main_status.get("state") or "").lower() != "success":
print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green")
return 0
issue = choose_next_queued_issue(
@@ -421,25 +352,7 @@ def process_once(*, dry_run: bool = False) -> int:
"deferring to next tick"
)
return 0
try:
merge_pull(pr_number, dry_run=dry_run)
except MergePermissionError as exc:
# Permanent merge failure (HTTP 403/404/405). Post a comment so
# maintainers know why, then return 0 so this tick is done.
# The PR stays in the queue; future ticks can retry after the
# permission issue is resolved.
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
post_comment(
pr_number,
(
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
"No available token has Can-merge permission on this repo. "
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
"Skipping to next queued PR on next tick."
),
dry_run=dry_run,
)
return 0
merge_pull(pr_number, dry_run=dry_run)
return 0
return 0
@@ -449,21 +362,7 @@ def main() -> int:
parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
_require_runtime_env()
try:
return process_once(dry_run=args.dry_run)
except ApiError as exc:
# API errors (401/403/404/500) are transient for a queue tick —
# log and exit 0 so the workflow is not marked failed and the next
# tick can retry. Returning non-zero would permanently fail the
# workflow run, blocking future ticks.
sys.stderr.write(f"::error::queue API error: {exc}\n")
return 0
except urllib.error.URLError as exc:
sys.stderr.write(f"::error::queue network error: {exc}\n")
return 0
except TimeoutError as exc:
sys.stderr.write(f"::error::queue timeout: {exc}\n")
return 0
return process_once(dry_run=args.dry_run)
if __name__ == "__main__":
-50
View File
@@ -36,9 +36,6 @@ Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn):
raw `.error` fields into CI logs/summaries.
9. Production deploy/redeploy workflows must expose an operational control:
kill switch for auto deploys or rollback tag for manual deploys.
10. Docker health checks must not run `docker info | head` under pipefail.
`head` closes the pipe early, `docker info` can exit nonzero from
SIGPIPE, and the step can falsely report Docker daemon failure.
Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to
validate this lint must mirror real Gitea 1.22.6 YAML semantics, not
@@ -228,24 +225,6 @@ def _iter_uses(doc: Any) -> Iterable[str]:
yield step["uses"]
def _iter_run_blocks(doc: Any) -> Iterable[str]:
"""Yield every shell `run:` block from job steps in a workflow document."""
if not isinstance(doc, dict):
return
jobs = doc.get("jobs")
if not isinstance(jobs, dict):
return
for job in jobs.values():
if not isinstance(job, dict):
continue
steps = job.get("steps")
if not isinstance(steps, list):
continue
for step in steps:
if isinstance(step, dict) and isinstance(step.get("run"), str):
yield step["run"]
def check_cross_repo_uses(filename: str, doc: Any) -> list[str]:
"""Return per-violation error lines for cross-repo `uses:` references."""
errors: list[str] = []
@@ -285,10 +264,6 @@ GITHUB_API_REF_RE = re.compile(
PROD_CP_URL_RE = re.compile(r"https://api\.moleculesai\.app\b")
REDEPLOY_FLEET_RE = re.compile(r"\b/cp/admin/tenants/redeploy-fleet\b")
RUN_SETS_PIPEFAIL_RE = re.compile(r"(?m)^\s*set\s+-[^\n]*o\s+pipefail\b")
DOCKER_INFO_HEAD_PIPE_RE = re.compile(
r"(?m)^\s*docker\s+info\b[^\n|]*\|\s*head\b"
)
RAW_CP_RESPONSE_RE = re.compile(
r"""(?x)
(?:\bjq\s+\.\s+["']?\$HTTP_RESPONSE["']?)
@@ -408,30 +383,6 @@ def check_production_operational_control(filename: str, raw: str) -> list[str]:
return errors
# ---------------------------------------------------------------------------
# Rule 10 — docker info piped to head under pipefail
# ---------------------------------------------------------------------------
def check_docker_info_head_pipefail(filename: str, doc: Any) -> list[str]:
errors: list[str] = []
for run_block in _iter_run_blocks(doc):
if not (
RUN_SETS_PIPEFAIL_RE.search(run_block)
and DOCKER_INFO_HEAD_PIPE_RE.search(run_block)
):
continue
errors.append(
f"::error file={filename}::Rule 10 (FATAL): workflow runs "
f"`docker info | head` after enabling `pipefail`. `head` can "
f"close the pipe early, making `docker info` exit nonzero and "
f"falsely fail the Docker daemon health check. Capture "
f"`docker_info=\"$(docker info 2>&1)\"` first, then print a "
f"bounded preview with `printf ... | sed -n '1,5p'`."
)
break
return errors
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
@@ -485,7 +436,6 @@ def main(argv: list[str] | None = None) -> int:
fatal_errors.extend(check_production_concurrency(rel, doc, raw))
fatal_errors.extend(check_production_raw_response_logging(rel, raw))
fatal_errors.extend(check_production_operational_control(rel, raw))
fatal_errors.extend(check_docker_info_head_pipefail(rel, doc))
warnings.extend(check_github_server_url_missing(rel, doc, raw))
# Cross-file checks
+1 -38
View File
@@ -101,10 +101,9 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
PR_JSON=$(mktemp)
REVIEWS_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" "$TEAM_PROBE_TMP"
}
trap cleanup EXIT
@@ -144,42 +143,6 @@ if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
exit 1
fi
# --- RFC#324 §N/A follow-up: check N/A declarations status ---
# sop-checklist.py posts `sop-checklist / na-declarations (pull_request)`
# status when a peer posts /sop-n/a <gate>. If our gate is declared N/A,
# the requirement for a Gitea APPROVE review is waived.
NA_STATUSES_TMP=$(mktemp)
HTTP_CODE=$(curl -sS -o "$NA_STATUSES_TMP" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/statuses/${PR_HEAD_SHA}")
debug "statuses/${PR_HEAD_SHA} → HTTP ${HTTP_CODE}"
if [ "$HTTP_CODE" = "200" ]; then
# Gitea returns statuses as array; look for the na-declarations context.
# jq: find all statuses where context == "sop-checklist / na-declarations (pull_request)"
# and state == "success". Extract the description field.
NA_DESC=$(jq -r '
.[] |
select(.context == "sop-checklist / na-declarations (pull_request)") |
select(.state == "success") |
.description
' "$NA_STATUSES_TMP" 2>/dev/null | head -1)
if [ -n "$NA_DESC" ] && [ "$NA_DESC" != "null" ]; then
debug "na-declarations status found: ${NA_DESC}"
# Check if our gate appears in the N/A description.
# The description format is "N/A: qa-review, security-review" or similar.
if echo "$NA_DESC" | grep -iq "\\b${TEAM}-review\\b"; then
echo "::notice::${TEAM}-review N/A — gate declared not-applicable via /sop-n/a: ${NA_DESC}"
echo "::notice::PR ${PR_NUMBER} passes ${TEAM}-review via N/A declaration"
rm -f "$NA_STATUSES_TMP"
exit 0
fi
fi
else
debug "could not fetch statuses (HTTP ${HTTP_CODE}) — proceeding with normal eval"
fi
rm -f "$NA_STATUSES_TMP"
# --- Fetch all reviews on the PR ---
HTTP_CODE=$(curl -sS -o "$REVIEWS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
-81
View File
@@ -1,81 +0,0 @@
#!/usr/bin/env bash
# Re-run review-check.sh for a slash-command refire and post the protected
# pull_request status context to the PR head SHA.
set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${GITEA_HOST:?GITEA_HOST required}"
: "${REPO:?REPO required}"
: "${PR_NUMBER:?PR_NUMBER required}"
: "${TEAM:?TEAM required}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
CONTEXT="${TEAM}-review / approved (pull_request)"
TARGET_URL="https://${GITEA_HOST}/${OWNER}/${NAME}/pulls/${PR_NUMBER}"
authfile=$(mktemp)
prfile=$(mktemp)
postfile=$(mktemp)
# shellcheck disable=SC2329 # invoked by EXIT trap
cleanup() {
rm -f "$authfile" "$prfile" "$postfile"
}
trap cleanup EXIT
chmod 600 "$authfile"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
code=$(curl -sS -o "$prfile" -w '%{http_code}' -K "$authfile" \
"${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
if [ "$code" != "200" ]; then
echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${code}"
head -c 200 "$prfile" >&2 || true
exit 1
fi
head_sha=$(jq -r '.head.sha // ""' "$prfile")
state=$(jq -r '.state // ""' "$prfile")
if [ -z "$head_sha" ] || [ "$head_sha" = "null" ]; then
echo "::error::Could not resolve PR head SHA for PR ${PR_NUMBER}"
exit 1
fi
if [ "$state" != "open" ]; then
echo "::notice::PR ${PR_NUMBER} is ${state}; ${TEAM}-review refire is a no-op"
exit 0
fi
set +e
bash .gitea/scripts/review-check.sh
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
status_state="success"
description="Refired via /${TEAM}-recheck by ${COMMENT_AUTHOR:-unknown}"
else
status_state="failure"
description="Refired via /${TEAM}-recheck; ${TEAM}-review failed"
fi
body=$(jq -nc \
--arg state "$status_state" \
--arg context "$CONTEXT" \
--arg description "$description" \
--arg target_url "$TARGET_URL" \
'{state:$state, context:$context, description:$description, target_url:$target_url}')
code=$(curl -sS -o "$postfile" -w '%{http_code}' -X POST \
-K "$authfile" -H "Content-Type: application/json" \
-d "$body" \
"${API}/repos/${OWNER}/${NAME}/statuses/${head_sha}")
if [ "$code" != "200" ] && [ "$code" != "201" ]; then
echo "::error::POST /statuses/${head_sha} returned HTTP ${code}"
head -c 200 "$postfile" >&2 || true
exit 1
fi
echo "::notice::posted ${status_state} for context=\"${CONTEXT}\" on sha=${head_sha}"
exit "$rc"
+22 -168
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env python3
# sop-checklist — evaluate whether a PR has peer-acked each
# sop-checklist-gate — evaluate whether a PR has peer-acked each
# SOP-checklist item. Posts a commit-status that branch protection
# can require.
#
# RFC#351 Step 2 of 6 (implementation MVP).
#
# Invoked by .gitea/workflows/sop-checklist.yml on:
# Invoked by .gitea/workflows/sop-checklist-gate.yml on:
# - pull_request_target: [opened, edited, synchronize, reopened]
# - issue_comment: [created, edited, deleted]
#
@@ -68,7 +68,7 @@ import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any, Callable
from typing import Any
# ---------------------------------------------------------------------------
@@ -110,7 +110,7 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
# yet validated; future iteration may require a min-length).
_DIRECTIVE_RE = re.compile(
r"^[ \t]*/(sop-ack|sop-revoke|sop-n/a)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
re.MULTILINE,
)
@@ -118,21 +118,17 @@ _DIRECTIVE_RE = re.compile(
def parse_directives(
comment_body: str,
numeric_aliases: dict[int, str],
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
) -> list[tuple[str, str, str]]:
"""Extract /sop-ack and /sop-revoke directives from a comment body.
Returns (directives, na_directives) where each is a list of
(kind, canonical_slug, note) tuples:
kind is "sop-ack", "sop-revoke", or "sop-n/a"
Returns a list of (kind, canonical_slug, note) tuples where:
kind is "sop-ack" or "sop-revoke"
canonical_slug is the normalized form (or "" if unparseable)
note is the trailing free-text (may be "")
The two lists are kept separate so call sites can unpack them
directly (e.g. directives, na_directives = parse_directives(...)).
"""
directives: list[tuple[str, str, str]] = []
na_directives: list[tuple[str, str, str]] = []
out: list[tuple[str, str, str]] = []
if not comment_body:
return directives, na_directives
return out
for m in _DIRECTIVE_RE.finditer(comment_body):
kind = m.group(1)
raw_slug = (m.group(2) or "").strip()
@@ -162,12 +158,8 @@ def parse_directives(
note_from_group = (m.group(3) or "").strip()
# If we collapsed multi-word slug into kebab and there's a
# trailing-text group too, append it.
entry = (kind, canonical, note_from_group)
if kind == "sop-n/a":
na_directives.append(entry)
else:
directives.append(entry)
return directives, na_directives
out.append((kind, canonical, note_from_group))
return out
# ---------------------------------------------------------------------------
@@ -180,8 +172,8 @@ def section_marker_present(body: str, marker: str) -> bool:
on a non-empty line (i.e. the author actually filled it in).
We require the marker substring AND non-whitespace content on the
same line OR within the next non-blank line this prevents
trivially-empty checklists like:
same line OR within the next line this prevents trivially-empty
checklists like:
## SOP-Checklist
- [ ] **Comprehensive testing performed**:
@@ -190,18 +182,9 @@ def section_marker_present(body: str, marker: str) -> bool:
from auto-passing the section-present check. The peer-ack is still
required, but answering with empty content is captured as a soft
finding via the section-present test alone.
NOTE: we scan forward through blank lines (the markdown-header pattern
is ## Header\\n\\ncontent) so that a header + blank-line + content
structure still satisfies the check. The backward checkbox fallback
catches inline markers without a preceding checkbox (mc#1099).
"""
if not body or not marker:
return False
# Strip trailing whitespace so the blank-line scan below can find
# content that appears on the very last line of the body (without
# being misled by a trailing \n or spaces).
body = body.rstrip()
body_lower = body.lower()
marker_lower = marker.lower()
idx = body_lower.find(marker_lower)
@@ -217,44 +200,13 @@ def section_marker_present(body: str, marker: str) -> bool:
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
if stripped:
return True
# Fall through: scan forward, skipping blank-only lines, until we find
# non-empty content or run out of body. Handles:
# ## Header ← marker line (empty after marker)
# ← blank line (skipped)
# - actual content ← found
pos = line_end
while True:
# Skip the current newline and any additional newlines (blank lines).
while pos < len(body) and body[pos] == "\n":
pos += 1
if pos >= len(body):
break
line_end = body.find("\n", pos)
if line_end < 0:
line_end = len(body)
line = body[pos:line_end]
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
if stripped:
return True
pos = line_end
# Last resort: the marker may appear mid-sentence (e.g.
# **Memory/saved-feedback consulted**: No applicable...).
# Search backward within the CURRENT LINE only (not preceding lines)
# to find a checkbox on the same line before the marker text.
# mc#1099 follow-up: memory-consulted detection was failing because
# the checkbox was on the same line before the inline marker.
_CHECKBOX_RE = re.compile(r"- \[[ x\]]|<input", re.IGNORECASE)
line_start = body.rfind("\n", 0, idx) + 1 # 0 if no newline before idx
before = body[line_start:idx]
m = _CHECKBOX_RE.search(before)
if not m:
return False
# Require meaningful content between the checkbox and the marker text
# (markdown formatting like ** or * must also be stripped).
# If only whitespace/markdown chars remain, the checkbox line is empty.
between = before[m.end() :]
stripped_between = re.sub(r"[\s\*:#\[\]_\-]+", "", between)
return bool(stripped_between)
# Fall through: check the NEXT line (multi-line answers).
next_line_end = body.find("\n", line_end + 1)
if next_line_end < 0:
next_line_end = len(body)
next_line = body[line_end + 1:next_line_end]
stripped_next = re.sub(r"[\s\*:\-\[\]]+", "", next_line)
return bool(stripped_next)
# ---------------------------------------------------------------------------
@@ -297,7 +249,7 @@ def compute_ack_state(
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, slug, _note in parse_directives(body, numeric_aliases)[0]:
for kind, slug, _note in parse_directives(body, numeric_aliases):
if not slug:
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
continue
@@ -349,63 +301,6 @@ def compute_ack_state(
}
# ---------------------------------------------------------------------------
# N/A-gate evaluation
# ---------------------------------------------------------------------------
def compute_na_state(
comments: list[dict[str, Any]],
author: str,
na_gates: dict[str, Any],
probe: Callable[[str, list[str]], list[str]],
) -> dict[str, dict[str, Any]]:
"""Evaluate which N/A gates have a valid declaration from a team member.
Returns dict[gate_name, dict] where each dict has:
declared: bool at least one valid non-author team-member declared N/A
decl_ackers: list[str] usernames who declared this gate N/A
rejected: dict with keys:
not_in_team: list[str] users who tried but aren't in required teams
"""
# Build per-user latest N/A directive (most-recent wins per RFC#324).
latest_na: dict[str, tuple[str, str]] = {} # user → (gate, note)
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, gate, note in parse_directives(body, {})[1]:
# [1] = na_directives only
if gate in na_gates:
latest_na[user] = (gate, note)
result: dict[str, dict[str, Any]] = {}
for gate, gate_cfg in na_gates.items():
result[gate] = {
"declared": False,
"decl_ackers": [],
"rejected": {"not_in_team": []},
}
decl_ackers: list[str] = []
not_in_team: list[str] = []
for user, (g, _note) in latest_na.items():
if g != gate:
continue
if user == author:
continue # authors cannot self-declare N/A
approved = probe(gate, [user])
if approved:
decl_ackers.append(user)
else:
not_in_team.append(user)
result[gate]["declared"] = bool(decl_ackers)
result[gate]["decl_ackers"] = decl_ackers
result[gate]["rejected"]["not_in_team"] = not_in_team
return result
# ---------------------------------------------------------------------------
# Gitea API client
# ---------------------------------------------------------------------------
@@ -800,7 +695,6 @@ def main(argv: list[str] | None = None) -> int:
cfg = load_config(args.config)
items: list[dict[str, Any]] = cfg["items"]
items_by_slug = {it["slug"]: it for it in items}
na_gates: dict[str, Any] = cfg.get("n/a_gates", {})
numeric_aliases = {
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
}
@@ -921,46 +815,6 @@ def main(argv: list[str] | None = None) -> int:
description=description, target_url=target_url,
)
print(f"::notice::status posted: {args.status_context}{state}")
# --- N/A gate status (RFC#324 §N/A follow-up) ---
# Post a separate status so review-check.sh can discover N/A declarations
# and waive the Gitea-approve requirement for that gate.
na_state: dict[str, dict[str, Any]] = {}
if na_gates:
na_state = compute_na_state(comments, author, na_gates, probe)
na_descs: list[str] = []
for gate, s in na_state.items():
if s["declared"]:
na_descs.append(gate)
decl = s["decl_ackers"]
rej = s["rejected"]["not_in_team"]
if decl:
print(f"::notice:: [N/A OK] {gate} — declared by {','.join(decl)}")
if rej:
print(
f"::notice:: [N/A REJ] {gate} — not-in-team: {','.join(rej)}",
file=sys.stderr,
)
na_desc = ", ".join(sorted(na_descs)) if na_descs else "(none)"
na_status_state = "success" if na_descs else "pending"
# review-check.sh reads the description to discover which gates are N/A.
# Include the gate names so it can grep for them.
na_description = f"N/A: {na_desc}" if na_descs else "N/A: (none)"
if not args.dry_run:
client.post_status(
args.owner, args.repo, head_sha,
state=na_status_state,
context="sop-checklist / na-declarations (pull_request)",
description=na_description,
target_url=target_url,
)
print(
f"::notice::na-declarations status → {na_status_state}: {na_description}"
)
# By default exit 0 — the POSTed status IS the gate, NOT the job
# conclusion. If the job exits 1 BP will see TWO failure signals
# (one from the job's auto-status, one from our POST), making the
+6 -28
View File
@@ -133,9 +133,6 @@ PUSH_COMPENSATION_DESCRIPTION = (
"Compensated by status-reaper (workflow has no push: trigger; "
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
)
# Backward-compatible alias for older tests/tooling that predate the split
# between push-suffix compensation and pull-request-shadow compensation.
COMPENSATION_DESCRIPTION = PUSH_COMPENSATION_DESCRIPTION
PR_SHADOW_COMPENSATION_DESCRIPTION = (
"Compensated by status-reaper (default-branch pull_request status "
"shadowed by successful push status on same SHA; see "
@@ -614,10 +611,11 @@ def list_recent_commit_shas(branch: str, limit: int) -> list[str]:
(verified via vendor-truth probe 2026-05-11 against
git.moleculesai.app — `feedback_smoke_test_vendor_truth_not_shape_match`).
Raises ApiError on non-2xx OR on unexpected response shape. The
branch-level caller soft-skips this tick because the next scheduled
tick can safely retry the listing. Per-SHA status/write errors remain
separate and must not be mislabeled as commit-list outages.
Raises ApiError on non-2xx OR on unexpected response shape. This is
a HARD halt — without the commit list the sweep can't proceed. (The
per-SHA error isolation downstream is a different concern: tolerating
a transient 5xx on ONE commit's status is best-effort; losing the
commit list itself means we don't even know which commits to try.)
"""
_, body = api(
"GET",
@@ -658,27 +656,7 @@ def reap_branch(
- compensated_per_sha: {<sha_full>: [<context>, ...]} — only
SHAs that actually got at least one compensation are included
"""
try:
shas = list_recent_commit_shas(branch, limit)
except ApiError as e:
print(
"::warning::status-reaper skipped this tick because the "
f"commit list could not be read after retries: {e}"
)
return {
"scanned_shas": 0,
"compensated": 0,
"preserved_real_push": 0,
"preserved_unknown": 0,
"preserved_non_failure": 0,
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"preserved_pr_without_push_success": 0,
"compensated_per_sha": {},
"skipped": True,
"skip_reason": "commit-list-api-error",
}
shas = list_recent_commit_shas(branch, limit)
aggregate: dict[str, Any] = {
"scanned_shas": 0,
+2 -18
View File
@@ -85,10 +85,7 @@ def test_pr_needs_update_when_base_sha_absent_from_commits():
def test_merge_decision_requires_main_green_pr_green_and_current_base():
required = ["CI / all-required (pull_request)"]
main_status = {
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
}
main_status = {"state": "success", "statuses": []}
pr_status = {
"state": "success",
"statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}],
@@ -107,10 +104,7 @@ def test_merge_decision_requires_main_green_pr_green_and_current_base():
def test_merge_decision_updates_stale_pr_before_merge():
decision = mq.evaluate_merge_readiness(
main_status={
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
},
main_status={"state": "success", "statuses": []},
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
required_contexts=["CI / all-required (pull_request)"],
pr_has_current_base=False,
@@ -118,13 +112,3 @@ def test_merge_decision_updates_stale_pr_before_merge():
assert decision.ready is False
assert decision.action == "update"
def test_MergePermissionError_inherits_from_ApiError():
assert issubclass(mq.MergePermissionError, mq.ApiError)
def test_MergePermissionError_message_preserved():
exc = mq.MergePermissionError("POST /merge -> HTTP 405: User not allowed")
assert "405" in str(exc)
assert "User not allowed" in str(exc)
@@ -1,8 +1,8 @@
#!/usr/bin/env python3
# Unit tests for sop-checklist.py
# Unit tests for sop-checklist-gate.py
#
# Run: python3 .gitea/scripts/tests/test_sop_checklist.py
# or: pytest .gitea/scripts/tests/test_sop_checklist.py
# Run: python3 .gitea/scripts/tests/test_sop_checklist_gate.py
# or: pytest .gitea/scripts/tests/test_sop_checklist_gate.py
#
# RFC#351 Step 2 of 6 — implementation MVP. Tests cover:
# - slug normalization (the 4 example variants in the script header)
@@ -33,7 +33,7 @@ sys.path.insert(0, PARENT)
import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location(
"sop_checklist", os.path.join(PARENT, "sop-checklist.py")
"sop_checklist_gate", os.path.join(PARENT, "sop-checklist-gate.py")
)
sop = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sop) # type: ignore[union-attr]
@@ -134,22 +134,18 @@ class TestParseDirectives(unittest.TestCase):
def setUp(self):
self.aliases = _numeric_aliases()
def parse_ack_revoke(self, body):
directives, na_directives = sop.parse_directives(body, self.aliases)
self.assertEqual(na_directives, [])
return directives
def test_simple_ack(self):
d = self.parse_ack_revoke("/sop-ack comprehensive-testing")
d = sop.parse_directives("/sop-ack comprehensive-testing", self.aliases)
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
def test_simple_revoke(self):
d = self.parse_ack_revoke("/sop-revoke staging-smoke")
d = sop.parse_directives("/sop-revoke staging-smoke", self.aliases)
self.assertEqual(d, [("sop-revoke", "staging-smoke", "")])
def test_ack_with_note(self):
d = self.parse_ack_revoke(
"/sop-ack comprehensive-testing LGTM the test covers all edge cases"
d = sop.parse_directives(
"/sop-ack comprehensive-testing LGTM the test covers all edge cases",
self.aliases,
)
self.assertEqual(len(d), 1)
self.assertEqual(d[0][0], "sop-ack")
@@ -157,12 +153,13 @@ class TestParseDirectives(unittest.TestCase):
self.assertIn("LGTM", d[0][2])
def test_numeric_shorthand(self):
d = self.parse_ack_revoke("/sop-ack 1")
d = sop.parse_directives("/sop-ack 1", self.aliases)
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
def test_revoke_with_reason(self):
d = self.parse_ack_revoke(
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB"
d = sop.parse_directives(
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB",
self.aliases,
)
self.assertEqual(d[0][0], "sop-revoke")
self.assertEqual(d[0][1], "comprehensive-testing")
@@ -174,7 +171,7 @@ class TestParseDirectives(unittest.TestCase):
"/sop-ack comprehensive-testing\n"
"Will follow up on the doc nit separately."
)
d = self.parse_ack_revoke(body)
d = sop.parse_directives(body, self.aliases)
self.assertEqual(len(d), 1)
self.assertEqual(d[0][1], "comprehensive-testing")
@@ -183,7 +180,7 @@ class TestParseDirectives(unittest.TestCase):
"/sop-ack comprehensive-testing\n"
"/sop-ack local-postgres-e2e\n"
)
d = self.parse_ack_revoke(body)
d = sop.parse_directives(body, self.aliases)
self.assertEqual(len(d), 2)
slugs = {x[1] for x in d}
self.assertEqual(slugs, {"comprehensive-testing", "local-postgres-e2e"})
@@ -192,21 +189,21 @@ class TestParseDirectives(unittest.TestCase):
# A directive embedded mid-line is not honored (prevents review
# comments like "to /sop-ack you need..." from acting as acks).
body = "If you want to /sop-ack comprehensive-testing reply in this thread"
d = self.parse_ack_revoke(body)
d = sop.parse_directives(body, self.aliases)
self.assertEqual(d, [])
def test_leading_whitespace_allowed(self):
body = " /sop-ack comprehensive-testing"
d = self.parse_ack_revoke(body)
d = sop.parse_directives(body, self.aliases)
self.assertEqual(len(d), 1)
def test_empty_body(self):
self.assertEqual(sop.parse_directives("", self.aliases), ([], []))
self.assertEqual(sop.parse_directives(None, self.aliases), ([], []))
self.assertEqual(sop.parse_directives("", self.aliases), [])
self.assertEqual(sop.parse_directives(None, self.aliases), [])
def test_normalization_applied(self):
# /sop-ack Comprehensive_Testing → canonical comprehensive-testing
d = self.parse_ack_revoke("/sop-ack Comprehensive_Testing")
d = sop.parse_directives("/sop-ack Comprehensive_Testing", self.aliases)
self.assertEqual(d[0][1], "comprehensive-testing")
@@ -551,55 +548,3 @@ class TestEndToEndAckFlow(unittest.TestCase):
if __name__ == "__main__":
unittest.main(verbosity=2)
# ---------------------------------------------------------------------------
# compute_na_state
# ---------------------------------------------------------------------------
class TestComputeNaState(unittest.TestCase):
"""Tests for /sop-n/a directive evaluation."""
def test_no_na_declarations(self):
cfg = sop.load_config(CONFIG_PATH)
na_gates = cfg.get("n/a_gates", {})
comments = []
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda *_: [])
self.assertFalse(na_state["qa-review"]["declared"])
self.assertFalse(na_state["security-review"]["declared"])
def test_na_declared_by_authorized_user(self):
cfg = sop.load_config(CONFIG_PATH)
na_gates = cfg.get("n/a_gates", {})
comments = [_comment("bob", "/sop-n/a qa-review N/A: pure tooling change")]
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: u)
self.assertTrue(na_state["qa-review"]["declared"])
self.assertEqual(na_state["qa-review"]["decl_ackers"], ["bob"])
def test_na_declared_by_unauthorized_user_rejected(self):
cfg = sop.load_config(CONFIG_PATH)
na_gates = cfg.get("n/a_gates", {})
comments = [_comment("mallory", "/sop-n/a qa-review N/A: not real team")]
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: [])
self.assertFalse(na_state["qa-review"]["declared"])
self.assertEqual(na_state["qa-review"]["rejected"]["not_in_team"], ["mallory"])
def test_author_cannot_self_declare_na(self):
cfg = sop.load_config(CONFIG_PATH)
na_gates = cfg.get("n/a_gates", {})
comments = [_comment("alice", "/sop-n/a qa-review N/A: I am the author")]
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: u)
self.assertFalse(na_state["qa-review"]["declared"])
def test_parse_directives_separates_na_from_ack(self):
directives, na_directives = sop.parse_directives(
"/sop-ack comprehensive-testing\n/sop-n/a qa-review N/A: no surface",
{},
)
self.assertEqual(len(directives), 1)
self.assertEqual(directives[0][0], "sop-ack")
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])
+16 -32
View File
@@ -32,7 +32,6 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)"
WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml"
DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml"
SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh"
PASS=0
@@ -88,7 +87,6 @@ assert_file_exists() {
echo
echo "== existence =="
assert_file_exists "workflow file exists" "$WORKFLOW"
assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
assert_file_exists "script file exists" "$SCRIPT"
if [ "$FAIL" -gt 0 ]; then
echo
@@ -106,44 +104,30 @@ echo "== T6/T7 workflow yaml =="
PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$WORKFLOW" 2>&1 || true)
assert_eq "T7 workflow parses as YAML" "ok" "$PARSE_OUT"
# The old per-workflow issue_comment listener caused queue storms because
# Gitea queues jobs before evaluating job-level `if:`. The script remains,
# but comment-triggered refires route through the single dispatcher.
# Three required gates in the `if:` expression
WORKFLOW_CONTENT=$(cat "$WORKFLOW")
if printf '%s' "$WORKFLOW_CONTENT" | grep -q '^ issue_comment:'; then
echo " FAIL T6a manual fallback workflow must not listen on issue_comment"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T6a"
else
echo " PASS T6a manual fallback workflow does not listen on issue_comment"
PASS=$((PASS + 1))
fi
assert_contains "T6b workflow exposes workflow_dispatch" \
"workflow_dispatch" "$WORKFLOW_CONTENT"
assert_contains "T6c workflow documents unsupported manual inputs" \
"workflow_dispatch inputs" "$WORKFLOW_CONTENT"
assert_contains "T6a workflow if: contains author_association gate" \
"github.event.comment.author_association" "$WORKFLOW_CONTENT"
assert_contains "T6b workflow if: gates on MEMBER/OWNER/COLLABORATOR" \
'["MEMBER","OWNER","COLLABORATOR"]' "$WORKFLOW_CONTENT"
assert_contains "T6c workflow if: contains slash-command trigger" \
"/refire-tier-check" "$WORKFLOW_CONTENT"
assert_contains "T6d workflow if: gates on PR-not-issue" \
"github.event.issue.pull_request" "$WORKFLOW_CONTENT"
assert_contains "T6e workflow listens on issue_comment" \
"issue_comment" "$WORKFLOW_CONTENT"
assert_contains "T6f workflow requests statuses:write permission" \
"statuses: write" "$WORKFLOW_CONTENT"
# Does NOT check out PR HEAD (security)
if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then
echo " FAIL T6d workflow MUST NOT check out PR head (security)"
echo " FAIL T6g workflow MUST NOT check out PR head (security)"
FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T6d"
FAILED_TESTS="${FAILED_TESTS} T6g"
else
echo " PASS T6d workflow does not check out PR head"
echo " PASS T6g workflow does not check out PR head"
PASS=$((PASS + 1))
fi
DISPATCH_PARSE_OUT=$(python3 -c 'import sys,yaml;yaml.safe_load(open(sys.argv[1]).read());print("ok")' "$DISPATCH_WORKFLOW" 2>&1 || true)
assert_eq "T6e dispatcher workflow parses as YAML" "ok" "$DISPATCH_PARSE_OUT"
DISPATCH_CONTENT=$(cat "$DISPATCH_WORKFLOW")
assert_contains "T6f dispatcher listens on issue_comment" \
"issue_comment" "$DISPATCH_CONTENT"
assert_contains "T6g dispatcher handles /qa-recheck" \
"/qa-recheck" "$DISPATCH_CONTENT"
assert_contains "T6h dispatcher handles /security-recheck" \
"/security-recheck" "$DISPATCH_CONTENT"
assert_contains "T6i dispatcher handles /refire-tier-check" \
"/refire-tier-check" "$DISPATCH_CONTENT"
# T1-T5 — script behavior against a local Gitea-fixture
echo
echo "== T1-T5 script behavior (vs local fixture) =="
-36
View File
@@ -107,39 +107,3 @@ items:
description: >-
List of feedback memories applicable to this change. Ack from
any engineer who has the same memory access.
# N/A gate declarations (RFC#324 §N/A follow-up).
# PRs where a gate genuinely does not apply (e.g., pure-infra with no
# qa surface, or docs-only) can be declared N/A by a non-author peer
# who is in one of the gate's required_teams. The sop-checklist
# posts a `sop-checklist / na-declarations (pull_request)` status that
# review-check.sh reads to skip the Gitea-APPROVE requirement.
#
# Usage: any PR commenter (peer) posts:
# /sop-n/a qa-review <reason>
# /sop-n/a security-review <reason>
#
# Slash commands:
# /sop-n/a <gate> [reason] — declare gate N/A (most-recent per-user wins)
# /sop-revoke <gate> — revoke prior N/A declaration for that gate
#
# Gate names must match the context strings used by review-check.sh:
# qa-review → qa-review / approved (<event>) [TEAM_ID=20]
# security-review → security-review / approved (<event>) [TEAM_ID=21]
#
# required_teams: OR semantics — any team member can declare N/A.
# Authors cannot self-declare N/A (enforced by gate script).
n/a_gates:
qa-review:
required_teams: [qa, security, engineers]
description: >-
QA review N/A when this change has no qa surface (pure-infra,
tooling-only, revert, dependency-only). A qa/eng/security member
must post /sop-n/a qa-review to activate.
security-review:
required_teams: [security, managers, ceo]
description: >-
Security review N/A when this change has no security surface
(docs-only, pure-frontend, dependency-only). A security/owners
member must post /sop-n/a security-review to activate.
+148 -202
View File
@@ -107,25 +107,16 @@ jobs:
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Workflow-only edits are covered by the workflow lint family
# and by this workflow's always-present required jobs. Do not fan
# those edits out into Go/Canvas/Python/shellcheck work; the
# downstream jobs still emit their required contexts via no-op
# steps when their surface flag is false.
#
# If the diff itself cannot be trusted, fail open by running every
# surface instead of silently under-testing the PR.
if ! DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null); then
echo "platform=true" >> "$GITHUB_OUTPUT"
echo "canvas=true" >> "$GITHUB_OUTPUT"
echo "python=true" >> "$GITHUB_OUTPUT"
echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "python=$(echo "$DIFF" | grep -qE '^workspace/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/' && echo true || echo false)" >> "$GITHUB_OUTPUT"
# Both .github/workflows/ci.yml AND .gitea/workflows/ci.yml count
# as "this workflow changed" — either edit should force-run every
# downstream job. The Gitea port follows the same shape as the
# GitHub original so behavior matches when triggered on either
# platform.
DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo ".gitea/workflows/ci.yml")
echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "scripts=$(echo "$DIFF" | grep -qE '^tests/e2e/|^scripts/|^infra/scripts/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
# + per-step gating shape preserves the GitHub-side required-check name
@@ -133,49 +124,59 @@ jobs:
# the name match works on PRs that don't touch workspace-server/).
platform-build:
name: Platform (Go)
needs: changes
runs-on: ubuntu-latest
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job.
# Phase 4 (#656) originally flipped this to continue-on-error: false based on
# Phase-3-masked "green on main 2026-05-12". Two failure classes then surfaced:
# (1) 4x delegation_test.go sqlmock gaps (PR #669 / #634 fix-forward, closed).
# (2) TestMCPHandler_CommitMemory_GlobalScope_Blocked (mcp_test.go:433):
# OFFSEC-001 hardening collided with test assertion; tracked in mc#762.
# Fix-forward for (1) landed in PR #669. The mc#762 gap (2) is a separate
# issue — it does NOT block this flip because the test is already wrapped in
# 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 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: 15
# mc#774 (interim): re-mask platform-build pending fix-forward. Phase 4
# (#656) flipped this to continue-on-error: false based on a Phase-3-masked
# "green on main 2026-05-12" — the prior continue-on-error: true had
# been hiding failing tests in workspace-server/internal/handlers/.
# Two distinct failure classes surfaced on 0e5152c3:
# (1) 4x delegation_test.go (lines 1110/1176/1228/1271): helpers
# expectExecuteDelegationBase/Success/Failed are missing sqlmock
# expectations for queries production has issued since ~2026-04-21
# (last_outbound_at UPDATE, lookupDeliveryMode/Runtime SELECTs,
# a2a_receive INSERT activity_logs, recordLedgerStatus writes).
# Halt cond #3 applies (regression > 7 days → broader sweep).
# (2) 1x mcp_test.go:433 (TestMCPHandler_CommitMemory_GlobalScope_Blocked):
# commit 7d1a189f (2026-05-10) hardened mcp.go to scrub err.Error()
# from JSON-RPC responses (OFFSEC-001), but the test asserts the
# error message contains "GLOBAL". Production-vs-test contract
# collision — needs design call, not mock update.
# Time-boxed Option A (90 min) did not fit the cross-cutting scope.
# This is a sequenced revert→fix→reflip per
# feedback_strict_root_only_after_class_a emergency clause — NOT
# a permanent re-mask. Re-flip blocked on mc#774 fix-forward landing.
# Other 4 #656 flips (changes, canvas-build, shellcheck, python-lint)
# retain continue-on-error: false; only platform-build regresses.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true # mc#774 fix-forward in flight; re-flip when mc#774 lands (PR #669 → rebase after #709)
defaults:
run:
working-directory: workspace-server
steps:
- if: false
- if: needs.changes.outputs.platform != 'true'
working-directory: .
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: always()
- if: needs.changes.outputs.platform == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: needs.changes.outputs.platform == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
- if: always()
- if: needs.changes.outputs.platform == 'true'
run: go mod download
- if: always()
- if: needs.changes.outputs.platform == 'true'
run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
- if: always()
- if: needs.changes.outputs.platform == 'true'
run: go vet ./...
- if: always()
- if: needs.changes.outputs.platform == 'true'
name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
- if: always()
- if: needs.changes.outputs.platform == 'true'
name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always()
- if: needs.changes.outputs.platform == 'true'
name: Diagnostic — per-package verbose 60s
run: |
set +e
@@ -191,15 +192,11 @@ jobs:
echo "::endgroup::"
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
- if: always()
- if: needs.changes.outputs.platform == 'true'
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 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 ./...
run: go test -race -coverprofile=coverage.out ./...
- if: always()
- if: needs.changes.outputs.platform == 'true'
name: Per-file coverage report
# Advisory — lists every source file with its coverage so reviewers
# can see at-a-glance where gaps are. Sorted ascending so the worst
@@ -213,7 +210,7 @@ jobs:
END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n
- if: always()
- if: needs.changes.outputs.platform == 'true'
name: Check coverage thresholds
# Enforces two gates from #1823 Layer 1:
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
@@ -301,28 +298,28 @@ jobs:
# siblings — verified empirically on PR #2314).
canvas-build:
name: Canvas (Next.js)
needs: changes
runs-on: ubuntu-latest
timeout-minutes: 20
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
defaults:
run:
working-directory: canvas
steps:
- if: false
- if: needs.changes.outputs.canvas != 'true'
working-directory: .
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection."
- if: always()
- if: needs.changes.outputs.canvas == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: needs.changes.outputs.canvas == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
- if: always()
- if: needs.changes.outputs.canvas == 'true'
run: rm -f package-lock.json && npm install
- if: always()
- if: needs.changes.outputs.canvas == 'true'
run: npm run build
- if: always()
- if: needs.changes.outputs.canvas == 'true'
name: Run tests with coverage
# Coverage instrumentation is configured in canvas/vitest.config.ts
# (provider: v8, reporters: text + html + json-summary). Step 2 of
@@ -331,7 +328,7 @@ jobs:
# tracked in #1815) after the team sees what current coverage is.
run: npx vitest run --coverage
- name: Upload coverage summary as artifact
if: always()
if: needs.changes.outputs.canvas == 'true' && always()
# Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses
# the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
# implement, surfacing as `GHESNotSupportedError: @actions/artifact
@@ -348,15 +345,16 @@ jobs:
# Shellcheck (E2E scripts) — required check, always runs.
shellcheck:
name: Shellcheck (E2E scripts)
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
steps:
- if: false
- if: needs.changes.outputs.scripts != 'true'
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
- if: always()
- if: needs.changes.outputs.scripts == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: needs.changes.outputs.scripts == 'true'
name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
# shellcheck is pre-installed on ubuntu-latest runners (via apt).
# infra/scripts/ is included because setup.sh + nuke.sh gate the
@@ -367,66 +365,32 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning
- if: always()
- if: needs.changes.outputs.scripts == 'true'
name: Lint cleanup-trap hygiene (RFC #2873)
run: bash tests/e2e/lint_cleanup_traps.sh
- if: always()
- if: needs.changes.outputs.scripts == 'true'
name: Run E2E bash unit tests (no live infra)
run: |
bash tests/e2e/test_model_slug.sh
- if: always()
name: Test ECR promote-tenant-image script (mock-driven, no live infra)
# Covers scripts/promote-tenant-image.sh — the codified
# :staging-latest → :latest ECR promote + tenant fleet redeploy
# closing molecule-ai/molecule-core#660. 40 mock-driven cases
# exercise every exit path (preflight, snapshot, promote, redeploy
# 403→SSM-refresh, verify, rollback). No live AWS/CP/SSM calls.
run: |
bash scripts/test-promote-tenant-image.sh
- if: always()
name: Shellcheck promote-tenant-image script
# scripts/ is excluded from the bulk shellcheck pass above (legacy
# SC3040/SC3043 cleanup pending). Run shellcheck explicitly on
# the promote script + its test harness so regressions there are
# caught by the required check.
run: |
shellcheck --severity=warning \
scripts/promote-tenant-image.sh \
scripts/test-promote-tenant-image.sh
# mc#959 root-fix (sre)
canvas-deploy-reminder:
name: Canvas Deploy Reminder
runs-on: ubuntu-latest
# mc#774 root-fix: added job-level `if:` so ci-required-drift.py's
# ci_job_names() detects this as github.ref-gated and skips it from F1.
# The step-level exit 0 handles the "not main push" case; the job-level
# `if:` makes the gating explicit so the drift script sees it.
# Runs on both main and staging pushes; step exits 0 when not applicable.
if: ${{ github.ref == 'refs/heads/main' || github.ref == 'refs/heads/staging' }}
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
needs: [changes, canvas-build]
# Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Write deploy reminder to step summary
env:
COMMIT_SHA: ${{ github.sha }}
CANVAS_CHANGED: ${{ needs.changes.outputs.canvas }}
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref }}
# github.server_url resolves via the workflow-level env override
# to the Gitea instance, so the RUN_URL points at the Gitea run
# page (not github.com). See feedback_act_runner_github_server_url.
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
if [ "$CANVAS_CHANGED" != "true" ] || [ "$EVENT_NAME" != "push" ] || [ "$REF_NAME" != "refs/heads/main" ]; then
echo "Canvas deploy reminder not applicable for event=$EVENT_NAME ref=$REF_NAME canvas_changed=$CANVAS_CHANGED."
exit 0
fi
# Write body to a temp file — avoids backtick escaping in shell.
cat > /tmp/deploy-reminder.md << 'BODY'
## Canvas build passed — deploy required
@@ -458,6 +422,7 @@ jobs:
# Python Lint & Test — required check, always runs.
python-lint:
name: Python Lint & Test
needs: changes
runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false
@@ -467,25 +432,25 @@ jobs:
run:
working-directory: workspace
steps:
- if: false
- if: needs.changes.outputs.python != 'true'
working-directory: .
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection."
- if: always()
- if: needs.changes.outputs.python == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always()
- if: needs.changes.outputs.python == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
cache: pip
cache-dependency-path: workspace/requirements.txt
- if: always()
- if: needs.changes.outputs.python == 'true'
run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0
# Coverage flags + fail-under floor moved into workspace/pytest.ini
# (issue #1817) so local `pytest` and CI use identical config.
- if: always()
- if: needs.changes.outputs.python == 'true'
run: python -m pytest --tb=short
- if: always()
- if: needs.changes.outputs.python == 'true'
name: Per-file critical-path coverage (MCP / inbox / auth)
# MCP-critical Python files have a per-file floor on top of the
# 86% total floor in pytest.ini. See issue #2790 for full rationale.
@@ -550,104 +515,85 @@ jobs:
# red silently merged through. See internal#286 for the three concrete
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
#
# This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a
# job-level `if: always()` + `needs:` sentinel as skipped before upstream
# jobs settle, leaving branch protection with a permanent pending
# `CI / all-required` context. Instead, this independent sentinel polls the
# required commit-status contexts for this SHA and fails if any fail, skip,
# or never emit.
# Three properties of this job each close a failure mode:
#
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
# It is an informational main-push reminder, not a PR quality gate. Keeping
# it in this dependency list lets a skipped reminder skip the required
# sentinel before the `always()` guard can emit a branch-protection status.
# 1. `if: always()` — runs even when an upstream fails. Without it the
# sentinel is `skipped` and protection treats that as missing → merge
# ungated.
#
# 2. Assertion is `result == "success"` per dep, NOT `!= "failure"`.
# A `skipped` upstream (job gated by `if:` evaluating false, matrix
# entry that couldn't run) must NOT silently pass through.
# `skipped`-as-green is exactly the failure mode this gate closes.
#
# 3. `needs:` is the canonical list of "what counts as required."
# status_check_contexts will reference only `ci/all-required` (Step 5
# follow-up — branch-protection PATCH is Owners-tier per
# `feedback_never_admin_merge_bypass`, separate PR); a new job is
# added simply by listing it in `needs:` here.
# `.gitea/workflows/ci-required-drift.yml` files a [ci-drift] issue
# hourly if this list diverges from status_check_contexts or from
# audit-force-merge.yml's REQUIRED_CHECKS env (RFC §4 + §6).
#
# Excluded from `needs:`: `canvas-deploy-reminder` — gated by
# `if: ... github.event_name == 'push' && github.ref == 'refs/heads/main'`,
# so on PR events it's legitimately `skipped`. The drift detector
# explicitly excludes `github.event_name`-gated jobs from F1 (see
# `.gitea/scripts/ci-required-drift.py::ci_job_names`).
#
# Phase 3 (RFC #219 §1) safety: underlying build jobs carry
# continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim)
# (Gitea suppresses status reporting for CoE jobs). This sentinel
# runs with continue-on-error: false so it always reports its
# result to the API — without this, the required-status entry
# (CI / all-required (pull_request)) is never created, which
# blocks PR merges. When Phase 3 ends, flip underlying jobs to
# continue-on-error: false; this sentinel can then be flipped to
# continue-on-error: true if a Phase-4 regression requires it.
continue-on-error: false
runs-on: ubuntu-latest
timeout-minutes: 45
timeout-minutes: 1
needs:
- changes
- platform-build
- canvas-build
- shellcheck
- python-lint
if: always()
steps:
- name: Wait for required CI contexts
env:
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
API_ROOT: ${{ github.server_url }}/api/v1
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
EVENT_NAME: ${{ github.event_name }}
- name: Assert every required dependency succeeded
run: |
set -euo pipefail
python3 - <<'PY'
import json
import os
import sys
import time
import urllib.error
import urllib.request
token = os.environ["GITEA_TOKEN"]
api_root = os.environ["API_ROOT"].rstrip("/")
repo = os.environ["REPOSITORY"]
sha = os.environ["COMMIT_SHA"]
event = os.environ["EVENT_NAME"]
required = [
f"CI / Detect changes ({event})",
f"CI / Platform (Go) ({event})",
f"CI / Canvas (Next.js) ({event})",
f"CI / Shellcheck (E2E scripts) ({event})",
f"CI / Python Lint & Test ({event})",
]
terminal_bad = {"failure", "error"}
deadline = time.time() + 40 * 60
last_summary = None
def fetch_statuses():
statuses = []
for page in range(1, 6):
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100"
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
with urllib.request.urlopen(req, timeout=10) as resp:
chunk = json.load(resp)
if not chunk:
break
statuses.extend(chunk)
latest = {}
for item in statuses:
ctx = item.get("context")
if not ctx:
continue
prev = latest.get(ctx)
if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""):
latest[ctx] = item
return latest
while True:
try:
latest = fetch_statuses()
except (TimeoutError, OSError, urllib.error.URLError) as exc:
if time.time() >= deadline:
print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr)
sys.exit(1)
print(f"WARN: status poll failed, retrying: {exc}", flush=True)
time.sleep(15)
continue
states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required}
summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items())
if summary != last_summary:
print(summary, flush=True)
last_summary = summary
bad = {ctx: state for ctx, state in states.items() if state in terminal_bad}
if bad:
print("FAIL: required CI context failed:", file=sys.stderr)
for ctx, state in bad.items():
desc = (latest.get(ctx) or {}).get("description") or ""
print(f" - {ctx}: {state} {desc}", file=sys.stderr)
sys.exit(1)
if all(state == "success" for state in states.values()):
print(f"OK: all {len(required)} required CI contexts succeeded")
sys.exit(0)
if time.time() >= deadline:
print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr)
for ctx, state in states.items():
print(f" - {ctx}: {state}", file=sys.stderr)
sys.exit(1)
time.sleep(15)
PY
# `needs.*.result` is one of: success | failure | cancelled | skipped | null.
# We assert success per dep (not != failure) — see RFC §2 reasoning above.
# Null results are skipped: they come from Phase 3 (continue-on-error: true
# suppresses status) or from jobs still in-flight. The sentinel succeeds
# rather than blocking PRs on Phase 3 noise.
results='${{ toJSON(needs) }}'
echo "$results"
echo "$results" | python3 -c '
import json, sys
ns = json.load(sys.stdin)
# Phase 3 masked: jobs with continue-on-error: true may report "failure"
# Remove when mc#774 handler test failures are resolved.
PHASE3_MASKED = {"platform-build"}
# Exclude null (Phase 3 suppressed / in-flight) from the bad list.
bad = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") not in ("success", None, "cancelled", "skipped") and k not in PHASE3_MASKED]
if bad:
print(f"FAIL: jobs not green:", file=sys.stderr)
for k, r in bad:
print(f" - {k}: {r}", file=sys.stderr)
sys.exit(1)
pending = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") is None]
cancelled = [(k, v.get("result")) for k, v in ns.items()
if v.get("result") == "cancelled"]
if pending:
print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
", ".join(k for k, _ in pending), file=sys.stderr)
if cancelled:
print(f"INFO: {len(cancelled)} job(s) masked by continue-on-error: " +
", ".join(k for k, _ in cancelled), file=sys.stderr)
print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
'
-37
View File
@@ -69,13 +69,6 @@ name: E2E API Smoke Test
# 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when
# they DO come up. Timeouts are not the bottleneck; not bumped.
#
# Item #1046 (fixed 2026-05-14): Stale platform-server from cancelled runs
# lingers on :8080 after "Stop platform" step is skipped (workflow cancelled
# before reaching line 335). Added a pre-start "Kill stale platform-server"
# step (line 286) that scans /proc for zombie platform-server processes
# and kills them before the port probe or bind. Makes the ephemeral port
# probe + start sequence deterministic.
#
# Item explicitly NOT fixed here: failing test `Status back online`
# fails because the platform's langgraph workspace template image
# (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns
@@ -290,35 +283,6 @@ jobs:
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 (issue #1046)
if: needs.detect-changes.outputs.api == 'true'
run: |
# Concurrent runs on the same host-network act_runner can leave a
# zombie platform-server from a cancelled/timeout run. Cancelled
# runs never reach the "Stop platform" step (line 335), so the
# old process lingers. Kill it before the ephemeral port probe
# or start so the port is definitively free.
#
# /proc scan — works on any Linux without pkill/lsof/ss.
# comm field is truncated to 15 chars: "platform-serve" matches
# "platform-server". Verify with cmdline to avoid false positives.
killed=0
for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do
kpid="${pid%/comm}"
kpid="${kpid##*/}"
cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ')
if echo "$cmdline" | grep -q "platform-server"; then
echo "Killing stale platform-server pid ${kpid}: ${cmdline}"
kill "$kpid" 2>/dev/null || true
killed=$((killed + 1))
fi
done
if [ "$killed" -gt 0 ]; then
sleep 2
echo "Killed $killed stale process(es); port(s) released."
else
echo "No stale platform-server found."
fi
- name: Start platform (background)
if: needs.detect-changes.outputs.api == 'true'
working-directory: workspace-server
@@ -382,4 +346,3 @@ jobs:
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
-288
View File
@@ -1,288 +0,0 @@
name: E2E Chat
# Comprehensive Playwright E2E for the unified chat stack (desktop
# ChatTab + mobile MobileChat). Runs on every PR that touches canvas,
# workspace-server, or this workflow file.
#
# Architecture:
# 1. Ephemeral Postgres + Redis (docker, unique container names)
# 2. workspace-server built from source, started with
# MOLECULE_ENV=development (fail-open auth)
# 3. canvas dev server (npm run dev) on :3000
# 4. Playwright tests create workspaces via API, point them at an
# in-process echo runtime, and exercise the full send/receive
# round-trip through the browser.
#
# Parallel-safety: same pattern as e2e-api.yml — per-run container names
# and ephemeral host ports so concurrent jobs on the host-network runner
# don't collide.
on:
push:
branches: [main, staging]
pull_request:
branches: [main, staging]
concurrency:
group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: false
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# bp-exempt: helper job; real gate is E2E Chat / E2E Chat (pull_request)
detect-changes:
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
outputs:
chat: ${{ steps.decide.outputs.chat }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- id: decide
run: |
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "chat=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "chat=true" >> "$GITHUB_OUTPUT"
exit 0
fi
CHANGED=$(git diff --name-only "$BASE" HEAD)
if echo "$CHANGED" | grep -qE '^(canvas/|workspace-server/|\.gitea/workflows/e2e-chat\.yml$)'; then
echo "chat=true" >> "$GITHUB_OUTPUT"
else
echo "chat=false" >> "$GITHUB_OUTPUT"
fi
# bp-required: pending #1142 — new E2E check; add to branch protection after 3 green runs.
e2e-chat:
needs: detect-changes
name: E2E Chat
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
timeout-minutes: 15
env:
PG_CONTAINER: pg-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
REDIS_CONTAINER: redis-e2e-chat-${{ github.run_id }}-${{ github.run_attempt }}
steps:
- name: No-op pass (paths filter excluded this commit)
if: needs.detect-changes.outputs.chat != 'true'
run: |
echo "No canvas / workspace-server / workflow changes — E2E Chat gate satisfied without running tests."
echo "::notice::E2E Chat no-op pass (paths filter excluded this commit)."
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: 'stable'
cache: true
cache-dependency-path: workspace-server/go.sum
- if: needs.detect-changes.outputs.chat == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: canvas/package-lock.json
- name: Start Postgres (docker)
if: needs.detect-changes.outputs.chat == 'true'
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}')
if [ -z "$PG_PORT" ]; then
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$PG_PORT" ]; then
echo "::error::Could not resolve host port for $PG_CONTAINER"
exit 1
fi
echo "PG_PORT=${PG_PORT}" >> "$GITHUB_ENV"
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
echo "E2E_DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
for i in $(seq 1 30); do
if docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1; then
echo "Postgres ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Postgres did not become ready in 30s"
exit 1
- name: Start Redis (docker)
if: needs.detect-changes.outputs.chat == 'true'
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}')
if [ -z "$REDIS_PORT" ]; then
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
fi
if [ -z "$REDIS_PORT" ]; then
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
exit 1
fi
echo "REDIS_PORT=${REDIS_PORT}" >> "$GITHUB_ENV"
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
for i in $(seq 1 15); do
if docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG; then
echo "Redis ready after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Redis did not become ready in 15s"
exit 1
- name: Build platform
if: needs.detect-changes.outputs.chat == 'true'
working-directory: workspace-server
run: go build -o platform-server ./cmd/server
- name: Pick platform port
if: needs.detect-changes.outputs.chat == 'true'
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 "PLATFORM_PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "E2E_PLATFORM_URL=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "Platform host port: ${PLATFORM_PORT}"
- name: Pick canvas port
if: needs.detect-changes.outputs.chat == 'true'
run: |
CANVAS_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 "CANVAS_PORT=${CANVAS_PORT}" >> "$GITHUB_ENV"
echo "Canvas host port: ${CANVAS_PORT}"
- name: Start platform (background)
if: needs.detect-changes.outputs.chat == 'true'
working-directory: workspace-server
run: |
export MOLECULE_ENV=development
export DATABASE_URL="${DATABASE_URL}"
export REDIS_URL="${REDIS_URL}"
export PORT="${PLATFORM_PORT}"
export CORS_ORIGINS="http://localhost:3000,http://localhost:3001,http://localhost:${CANVAS_PORT},http://127.0.0.1:${CANVAS_PORT}"
./platform-server > platform.log 2>&1 &
echo $! > platform.pid
- name: Wait for /health
if: needs.detect-changes.outputs.chat == 'true'
run: |
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:${PLATFORM_PORT}/health" > /dev/null; then
echo "Platform up after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Platform did not become healthy in 30s"
cat workspace-server/platform.log || true
exit 1
- name: Install canvas dependencies
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: npm ci
- name: Install Playwright browsers
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: npx playwright install --with-deps chromium
- name: Start canvas dev server (background)
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: |
export NEXT_PUBLIC_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
export NEXT_PUBLIC_WS_URL="ws://127.0.0.1:${PLATFORM_PORT}/ws"
npx next dev --turbopack -p "${CANVAS_PORT}" > canvas.log 2>&1 &
echo $! > canvas.pid
for i in $(seq 1 30); do
if curl -sf "http://localhost:${CANVAS_PORT}" > /dev/null 2>&1; then
echo "Canvas up after ${i}s"
exit 0
fi
sleep 1
done
echo "::error::Canvas did not start in 30s"
cat canvas.log || true
exit 1
- name: Run Playwright E2E tests
if: needs.detect-changes.outputs.chat == 'true'
working-directory: canvas
run: |
export E2E_PLATFORM_URL="http://127.0.0.1:${PLATFORM_PORT}"
export E2E_DATABASE_URL="${DATABASE_URL}"
export PLAYWRIGHT_BASE_URL="http://localhost:${CANVAS_PORT}"
npx playwright test e2e/chat-desktop.spec.ts e2e/chat-mobile.spec.ts
- name: Dump platform log on failure
if: failure() && needs.detect-changes.outputs.chat == 'true'
run: cat workspace-server/platform.log || true
- name: Dump canvas log on failure
if: failure() && needs.detect-changes.outputs.chat == 'true'
run: cat canvas/canvas.log || true
- name: Upload Playwright report
if: failure() && needs.detect-changes.outputs.chat == 'true'
uses: actions/upload-artifact@v3.2.2
with:
name: playwright-report-chat
path: canvas/playwright-report/
- name: Stop canvas
if: always() && needs.detect-changes.outputs.chat == 'true'
run: |
if [ -f canvas/canvas.pid ]; then
kill "$(cat canvas/canvas.pid)" 2>/dev/null || true
fi
- name: Stop platform
if: always() && needs.detect-changes.outputs.chat == 'true'
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() && needs.detect-changes.outputs.chat == 'true'
run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
-225
View File
@@ -1,225 +0,0 @@
name: E2E Peer Visibility (literal MCP list_peers)
# WHY A DEDICATED WORKFLOW (not folded into e2e-staging-saas.yml)
# --------------------------------------------------------------
# This is the systemic fix for a real trust failure. Hermes and OpenClaw
# were reported "fleet-verified / cascade-complete" because the *proxy*
# signals were green (registry registration + heartbeat for Hermes; model
# round-trip 200 for OpenClaw). A freshly-provisioned workspace asked on
# canvas "can you see your peers" actually FAILS:
# - Hermes: 401 on the molecule MCP `list_peers` call
# - OpenClaw: native `sessions_list` fallback, sees no platform peers
# Tasks #142/#159 were even marked "completed" under this proxy flaw.
#
# A dedicated workflow (vs extending e2e-staging-saas.yml) because:
# - It must provision MULTIPLE distinct runtimes (hermes, openclaw,
# claude-code) in ONE org and assert each sees the others. The
# full-saas script is single-runtime-per-run (E2E_RUNTIME) and folding
# a multi-runtime matrix into it would conflate concerns and bloat its
# already-45-min run.
# - It needs its own concurrency group so it doesn't fight full-saas /
# canvas for the staging org-creation quota.
# - It needs an independent, non-required status-context name so it can
# be RED today (the in-flight Hermes-401 / OpenClaw-MCP-wiring fixes
# have not landed) WITHOUT wedging unrelated merges — and flipped to
# REQUIRED in one branch-protection edit once it goes green
# (flip-to-required checklist: molecule-core#1296).
#
# THE ASSERTION IS NOT A PROXY. The driving script
# tests/e2e/test_peer_visibility_mcp_staging.sh 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.
#
# HONEST GATE — NO continue-on-error. Per feedback_fix_root_not_symptom a
# fake-green mask would defeat the entire purpose. This workflow goes red
# on today's broken behavior and green only when the root-cause fixes
# actually land. It is intentionally NOT in branch_protections — see PR
# body for the required-vs-not decision + flip tracking issue.
#
# Gitea 1.22.6 / act_runner notes honored:
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked). The
# actions/checkout SHA is the one e2e-staging-canvas.yml already uses
# successfully (a mirrored SHA — see #1277/PR#1292 root-cause).
# - Per-SHA concurrency, not global (feedback_concurrency_group_per_sha).
# - Workflow-level GITHUB_SERVER_URL pinned
# (feedback_act_runner_github_server_url).
# - pr-validate posts a status under the same check name so a
# workflow-only PR is not silently statusless and the context is
# 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).
on:
push:
branches: [main]
paths:
- 'workspace-server/internal/handlers/mcp.go'
- 'workspace-server/internal/handlers/mcp_tools.go'
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'workspace/a2a_mcp_server.py'
- 'workspace/platform_tools/registry.py'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
pull_request:
branches: [main]
paths:
- 'workspace-server/internal/handlers/mcp.go'
- 'workspace-server/internal/handlers/mcp_tools.go'
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'workspace/a2a_mcp_server.py'
- 'workspace/platform_tools/registry.py'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
workflow_dispatch:
schedule:
# 07:30 UTC daily — catches AMI / template-hermes / template-openclaw
# drift even on quiet days. Offset 30m from e2e-staging-saas (07:00)
# so the two don't collide on the staging org-creation quota.
- cron: '30 7 * * *'
concurrency:
# Per-SHA (feedback_concurrency_group_per_sha). A single global group
# would let a queued staging/main push behind a PR run get cancelled,
# leaving any gate that reads "completed run at SHA" stuck.
group: e2e-peer-visibility-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: false
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# PR path: post a real status under the required-ready check name so a
# workflow-only PR is never silently statusless. The actual EC2 E2E is
# push/dispatch/cron only (30+ min). This is NOT a fake-green mask of
# the real assertion — it validates the driving script's bash syntax
# and inline-python so a broken test script fails at PR time.
pr-validate:
name: E2E Peer Visibility
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate driving script
run: |
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"
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
# 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.
peer-visibility:
name: E2E Peer Visibility
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
timeout-minutes: 60
env:
MOLECULE_CP_URL: https://staging-api.moleculesai.app
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
# LLM provider key so each runtime can authenticate at boot.
# Priority MiniMax → direct-Anthropic → OpenAI matches
# test_staging_full_saas.sh's secrets-injection chain.
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 }}
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
PV_RUNTIMES: "hermes openclaw claude-code"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify admin token present
run: |
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
exit 2
fi
echo "Admin token present"
- name: Verify an LLM key present
run: |
if [ -z "${E2E_MINIMAX_API_KEY:-}" ] && [ -z "${E2E_ANTHROPIC_API_KEY:-}" ] && [ -z "${E2E_OPENAI_API_KEY:-}" ]; then
echo "::error::No LLM provider key set — workspaces fail at boot with 'No provider API key found'. Set MOLECULE_STAGING_MINIMAX_API_KEY (or ANTHROPIC / OPENAI)."
exit 2
fi
echo "LLM key present"
- name: CP staging health preflight
run: |
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
if [ "$code" != "200" ]; then
echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a workspace bug. Failing loud per feedback_fix_root_not_symptom."
exit 1
fi
echo "Staging CP healthy"
- name: Run fresh-provision peer-visibility E2E (literal MCP list_peers)
run: bash tests/e2e/test_peer_visibility_mcp_staging.sh
# Belt-and-braces scoped teardown: the script installs an EXIT/INT/
# TERM trap, but if the runner itself is cancelled the trap may not
# fire. This always() step deletes ONLY the e2e-pv-<run_id> org this
# run created — never a cluster-wide sweep
# (feedback_never_run_cluster_cleanup_tests_on_live_platform). The
# admin DELETE is idempotent so double-invoking is safe;
# sweep-stale-e2e-orgs is the final net (slug starts with 'e2e-').
- name: Teardown safety net (runs on cancel/failure)
if: always()
env:
ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
run: |
set +e
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs?limit=500" \
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
| python3 -c "
import json, sys, os, datetime
run_id = os.environ.get('GITHUB_RUN_ID', '')
try:
d = json.load(sys.stdin)
except Exception:
print(''); sys.exit(0)
# ONLY sweep slugs from THIS run. e2e-pv-<YYYYMMDD>-<run_id>-...
# Sweep today AND yesterday's UTC date so a midnight-crossing run
# still matches its own slug (same bug class as the saas/canvas
# safety nets).
today = datetime.date.today()
yest = today - datetime.timedelta(days=1)
dates = (today.strftime('%Y%m%d'), yest.strftime('%Y%m%d'))
if run_id:
prefixes = tuple(f'e2e-pv-{dt}-{run_id}-' for dt in dates)
else:
prefixes = tuple(f'e2e-pv-{dt}-' for dt in dates)
orgs = d if isinstance(d, list) else d.get('orgs', [])
cands = [o['slug'] for o in orgs
if any(o.get('slug','').startswith(p) for p in prefixes)
and o.get('instance_status') not in ('purged',)]
print('\n'.join(cands))
" 2>/dev/null)
for slug in $orgs; do
echo "Safety-net teardown: $slug"
set +e
curl -sS -o /tmp/pv-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/tmp/pv-cleanup.code
set -e
code=$(cat /tmp/pv-cleanup.code 2>/dev/null || echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::pv teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES. Body: $(head -c 300 /tmp/pv-cleanup.out 2>/dev/null)"
fi
done
exit 0
+11 -27
View File
@@ -83,41 +83,25 @@ jobs:
REPO: ${{ github.repository }}
run: |
set -euo pipefail
# Fetch all open PRs and run gate-check on each. This scheduled
# refresher is advisory; a transient Gitea list timeout must not turn
# main red. PR-specific gate-check runs still use normal failure
# semantics.
# Fetch all open PRs and run gate-check on each
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
# gate_check.py uses timeout=15 on every urlopen call; this catches the
# inline Python polling loop too (issue #603).
pr_numbers=$(python3 <<'PY'
import json
import os
import socket
import sys
import time
import urllib.error
import urllib.request
socket.setdefaulttimeout(30)
socket.setdefaulttimeout(15)
token = os.environ["GITEA_TOKEN"]
repo = os.environ["REPO"]
url = f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100"
last_error = None
for attempt in range(1, 4):
req = urllib.request.Request(
url,
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
prs = json.loads(r.read())
break
except (TimeoutError, OSError, urllib.error.URLError, urllib.error.HTTPError) as exc:
last_error = exc
print(f"warning: PR list fetch attempt {attempt}/3 failed: {exc}", file=sys.stderr)
if attempt < 3:
time.sleep(2 * attempt)
else:
print(f"warning: skipped scheduled gate-check refresh; failed to list open PRs after 3 attempts: {last_error}", file=sys.stderr)
raise SystemExit(0)
req = urllib.request.Request(
f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100",
headers={"Authorization": f"token {token}", "Accept": "application/json"},
)
with urllib.request.urlopen(req) as r:
prs = json.loads(r.read())
for pr in prs:
print(pr["number"])
PY
-5
View File
@@ -48,9 +48,4 @@ jobs:
REQUIRED_CONTEXTS: >-
CI / all-required (pull_request),
sop-checklist / all-items-acked (pull_request)
# Push-side required contexts. Checking CI / all-required (push)
# 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.
PUSH_REQUIRED_CONTEXTS: CI / all-required (push)
run: python3 .gitea/scripts/gitea-merge-queue.py
@@ -86,33 +86,22 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# A full-history checkout can exceed the runner's quiet/startup
# window before the path filter emits logs. Fetch the common push
# case cheaply; the script below fetches the exact BASE SHA if it is
# not present in the shallow checkout.
fetch-depth: 2
fetch-depth: 0
- id: filter
# Inline replacement for dorny/paths-filter — see e2e-api.yml.
run: |
# Gitea Actions evaluates github.event.before to empty string in shell
# scripts. Use GITHUB_EVENT_BEFORE shell env var instead (Gitea
# correctly populates it for push events). PR case uses template var.
BASE=""
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
BASE="$GITHUB_EVENT_BEFORE"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# timeout 30 guards against the case where BASE points to a ref that
# git can resolve but cat-file hangs (rare on corrupted objects).
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0
fi
@@ -93,7 +93,7 @@ jobs:
lint:
name: lint-continue-on-error-tracking
runs-on: ubuntu-latest
timeout-minutes: 20
timeout-minutes: 10
# Phase 3 (RFC #219 §1): surface masked defects without blocking
# PRs. Pre-existing continue-on-error: true directives on main
# all violate this lint at first — intentional. Flip to false
+7 -11
View File
@@ -49,17 +49,13 @@ jobs:
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
build-and-push:
name: Build & push canvas image
# Dedicated publish/release lane (internal#462 / #394 / #399). Ship
# path (on: push:main, canvas/**) — reserved capacity so a merged
# canvas fix's image build never FIFO-queues behind PR required-CI.
# The `publish` label resolves ONLY to the molecule-runner-publish-*
# sub-pool (config.publish.yaml). HARD DEPENDENCY: this MUST land
# AFTER the publish-lane runners are registered/advertising `publish`
# — the earlier #599 `docker` label attempt queued indefinitely with
# zero eligible runners precisely because the label was targeted
# before any runner advertised it (see #576). The lane is registered
# in this rollout (internal#462) so the precondition holds.
runs-on: publish
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
# See issue #576 + infra-lead pulse ~00:30Z.
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
+2 -8
View File
@@ -66,10 +66,7 @@ concurrency:
jobs:
publish:
# Dedicated publish/release lane (internal#462 / #394 / #399). Ship
# path (on: push tag runtime-v*) — reserved capacity, never FIFO
# behind PR-CI. `publish` resolves only to molecule-runner-publish-*.
runs-on: publish
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
@@ -162,7 +159,6 @@ jobs:
exit 1
fi
python -m twine upload \
--verbose \
--repository pypi \
--username __token__ \
--password "$PYPI_TOKEN" \
@@ -170,9 +166,7 @@ jobs:
cascade:
needs: publish
# Publish/release lane (internal#462) — downstream of the runtime
# publish ship job; keep it on the reserved lane too.
runs-on: publish
runs-on: ubuntu-latest
steps:
- name: Wait for PyPI to propagate the new version
env:
@@ -37,6 +37,12 @@ name: publish-workspace-server-image
on:
push:
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
- 'manifest.json'
- 'scripts/**'
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite
@@ -54,14 +60,7 @@ env:
jobs:
build-and-push:
# Dedicated publish/release lane (internal#462 / #394 / #399). This
# is a post-merge ship job (on: push:main) — it must NOT FIFO-compete
# with PR required-CI on the shared pool (PR#1350's prod image build
# was delayed ~25min this way). The `publish` label resolves ONLY to
# the reserved molecule-runner-publish-* sub-pool (config.publish.yaml,
# OUTSIDE the managed 1..20 range) so a merged fix's image build
# starts immediately while PR-CI keeps the general pool.
runs-on: publish
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -75,14 +74,12 @@ jobs:
set -euo pipefail
echo "::group::Docker daemon health check"
echo "Runner: ${HOSTNAME:-unknown}"
docker_info="$(docker info 2>&1)" || {
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Runner: ${HOSTNAME:-unknown}"
printf '%s\n' "${docker_info}"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
printf '%s\n' "${docker_info}" | sed -n '1,5p'
echo "Docker daemon OK"
echo "::endgroup::"
@@ -188,9 +185,7 @@ jobs:
name: Production auto-deploy
needs: build-and-push
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
# Publish/release lane (internal#462) — production deploy of a merged
# fix; reserved capacity, never queued behind PR-CI.
runs-on: publish
runs-on: ubuntu-latest
timeout-minutes: 75
env:
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
+17 -9
View File
@@ -9,10 +9,10 @@
# Triggers on:
# - `pull_request_target`: opened, synchronize, reopened
# → initial status posts when PR opens / re-pushes
# - comment refires are handled by `review-refire-comments.yml`
# → a single issue_comment dispatcher prevents every SOP/review
# comment from enqueueing separate qa/security/tier jobs on
# Gitea 1.22.6 before job-level `if:` can skip them.
# - `issue_comment`: /qa-recheck slash-command on the PR
# → manual re-fire after a QA reviewer clicks APPROVE
# (Gitea 1.22.6 doesn't re-fire on pull_request_review, per
# go-gitea/gitea#33700 + feedback_pull_request_review_no_refire)
# Workflow name = `qa-review` ; job name = `approved`.
# The job's own pass/fail conclusion publishes the status context
# `qa-review / approved (<event>)` — NO `POST /statuses` call → NO
@@ -85,6 +85,8 @@ name: qa-review
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
@@ -95,10 +97,16 @@ jobs:
approved:
# Gate the job:
# - On pull_request_target events: always run.
# Comment-triggered refires live in review-refire-comments.yml. Keeping
# this workflow PR-only avoids comment-triggered queue storms.
# - On issue_comment events: only when it's a PR comment and the body
# contains the slash-command. NO privilege gate at the step level
# (RFC#324 v1.3 §A1.1): a non-collaborator's /qa-recheck is fine
# because the eval is read-only and idempotent — re-running it
# just re-confirms whether a real team-member APPROVE exists.
if: |
github.event_name == 'pull_request_target'
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/qa-recheck'))
runs-on: ubuntu-latest
steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
@@ -112,7 +120,7 @@ jobs:
# no comment.user.login so the step is a no-op skip there.
if: github.event_name == 'issue_comment'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
login="${{ github.event.comment.user.login }}"
@@ -143,7 +151,7 @@ jobs:
- name: Evaluate qa-review
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
# PR number lives in different places per event:
+66 -58
View File
@@ -9,17 +9,19 @@ name: redeploy-tenants-on-main
# - Workflow-level env.GITHUB_SERVER_URL pinned per
# feedback_act_runner_github_server_url.
# - `continue-on-error: true` on each job (RFC §1 contract).
# - Dropped unsupported `workflow_run` (task #81).
# - Later changed to manual-only after publish-workspace-server-image.yml
# gained an integrated ordered production deploy job.
# - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
# push+paths filter per this PR. Gitea 1.22.6 does not support
# `workflow_run` (task #81). The push trigger fires on every
# commit to publish-workspace-server-image.yml which is the
# same signal (only successful runs commit to main).
#
# Manual production tenant redeploy/rollback helper.
# Auto-refresh prod tenant EC2s after every main merge.
#
# Why this workflow is manual-only: publish-workspace-server-image now owns
# the ordered build -> push -> production auto-deploy sequence in one workflow.
# A separate push-triggered redeploy workflow races before the new ECR image
# exists and can paint main red with a false deployment failure.
# 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
@@ -32,26 +34,36 @@ name: redeploy-tenants-on-main
# Gitea suspension migration. The staging-verify.yml promote step now
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
#
# Runtime ordering for automatic deploys now lives in
# publish-workspace-server-image.yml:
# 1. build-and-push creates new :staging-<sha> images in ECR.
# 2. deploy-production waits for required push contexts on that SHA.
# 3. deploy-production calls redeploy-fleet canary-first.
# 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: set PROD_MANUAL_REDEPLOY_TARGET_TAG as a repo/org
# variable or secret, run workflow_dispatch, then unset it after the
# rollback. That calls redeploy-fleet with target_tag=<value>,
# re-pulling the pinned image on every tenant.
# Rollback path: re-run this workflow with a specific SHA pinned via
# the workflow_dispatch input. That calls redeploy-fleet with
# target_tag=<sha>, re-pulling the older image on every tenant.
on:
push:
branches: [main]
paths:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
permissions:
contents: read
# No write scopes needed — the workflow hits an external CP endpoint,
# not the GitHub API.
# Serialize manual redeploys so two operator-triggered rollbacks do not
# overlap and cause confusing per-tenant SSM state.
# 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.
#
# NOTE: cancel-in-progress: false removed (Rule 7 fix). Gitea 1.22.6
# cancels queued runs regardless of this setting, so it provides no
@@ -65,20 +77,22 @@ env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
redeploy:
if: ${{ github.event_name == 'workflow_dispatch' }}
# Dedicated publish/release lane (internal#462 / #394 / #399).
# Production tenant redeploy — a deploy action, reserved capacity so
# it never queues behind PR-CI. `publish` -> molecule-runner-publish-*.
runs-on: publish
# Skip the auto-trigger if publish-workspace-server-image didn't
# actually succeed. workflow_run fires on any completion state; we
# don't want to redeploy against a half-built image.
# NOTE (Gitea port): workflow_dispatch trigger dropped; only the
# workflow_run path remains.
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
timeout-minutes: 25
env:
# Rule 9 fix: keep the same operational kill switch surface as the
# integrated auto-deploy workflow.
# Rule 9 fix: operational kill switch for auto-triggered deployments.
# Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true to prevent
# this workflow from redeploying. Manual workflow_dispatch bypasses this.
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
steps:
- name: Kill-switch guard
@@ -100,16 +114,21 @@ jobs:
# 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>` for manual reruns from
# the current default-branch SHA.
# 2. Default → `staging-<short_head_sha>`. The just-published
# digest. Bypasses the `:latest` retag path that's currently
# dead (staging-verify soft-skips without canary fleet, so
# the only thing retagging `:latest` today is the manual
# promote-latest.yml — last run 2026-04-28). Auto-trigger
# from workflow_run uses workflow_run.head_sha; manual
# dispatch with no input falls through to github.sha.
env:
PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || secrets.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }}
HEAD_SHA: ${{ github.sha }}
INPUT_TAG: ${{ inputs.target_tag }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: |
set -euo pipefail
if [ -n "${PROD_MANUAL_REDEPLOY_TARGET_TAG:-}" ]; then
echo "target_tag=$PROD_MANUAL_REDEPLOY_TARGET_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag from PROD_MANUAL_REDEPLOY_TARGET_TAG."
if [ -n "${INPUT_TAG:-}" ]; then
echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag: $INPUT_TAG"
else
SHORT="${HEAD_SHA:0:7}"
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
@@ -125,26 +144,13 @@ jobs:
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
CANARY_SLUG: ${{ vars.PROD_REDEPLOY_CANARY_SLUG || secrets.PROD_REDEPLOY_CANARY_SLUG || '' }}
SOAK_SECONDS: ${{ vars.PROD_REDEPLOY_SOAK_SECONDS || secrets.PROD_REDEPLOY_SOAK_SECONDS || '' }}
BATCH_SIZE: ${{ vars.PROD_REDEPLOY_BATCH_SIZE || secrets.PROD_REDEPLOY_BATCH_SIZE || '' }}
DRY_RUN: ${{ vars.PROD_REDEPLOY_DRY_RUN || secrets.PROD_REDEPLOY_DRY_RUN || '' }}
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
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
case "${PROD_AUTO_DEPLOY_DISABLED,,}" in
1|true|yes|on)
echo "::notice::PROD_AUTO_DEPLOY_DISABLED is set; skipping production redeploy."
exit 0
;;
esac
CANARY_SLUG="${CANARY_SLUG:-hongming}"
SOAK_SECONDS="${SOAK_SECONDS:-60}"
BATCH_SIZE="${BATCH_SIZE:-3}"
DRY_RUN="${DRY_RUN:-false}"
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."
@@ -166,7 +172,7 @@ jobs:
}')
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " target_tag=$TARGET_TAG canary=$CANARY_SLUG soak_seconds=$SOAK_SECONDS batch_size=$BATCH_SIZE dry_run=$DRY_RUN"
echo " body: $BODY"
HTTP_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp)
@@ -255,11 +261,13 @@ jobs:
# fail the workflow, which is what `ok=true` should have
# guaranteed all along.
#
# When the redeploy is triggered manually with a specific tag
# (target_tag != "latest"), the expected SHA may not equal
# ${{ github.sha }}.
# When the redeploy was triggered by workflow_dispatch with a
# specific tag (target_tag != "latest"), the expected SHA may
# not equal ${{ github.sha }} — in that case we resolve via
# GHCR's manifest. For workflow_run (default :latest) the
# workflow_run.head_sha is the SHA that just published.
env:
EXPECTED_SHA: ${{ github.sha }}
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
# Tenant subdomain template — slugs from the response are
# appended. Production CP issues `<slug>.moleculesai.app`;
@@ -273,10 +281,10 @@ jobs:
if [ "$TARGET_TAG" != "latest" ] \
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then
# Manual redeploy with a pinned tag that isn't the head
# 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 inspect the ECR
# 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.
#
@@ -75,10 +75,7 @@ env:
jobs:
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
redeploy:
# Dedicated publish/release lane (internal#462 / #394 / #399).
# Post-merge staging redeploy — a deploy action, reserved capacity.
# `publish` -> molecule-runner-publish-* sub-pool.
runs-on: publish
runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
-113
View File
@@ -1,113 +0,0 @@
# Consolidated comment dispatcher for manual review/tier refires.
#
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
# qa-review, security-review, sop-checklist, and sop-tier-refire all
# listened to comments. This workflow is the single non-SOP comment subscriber:
# ordinary comments no-op quickly; slash commands post the required status
# contexts to the PR head SHA.
name: review-refire-comments
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
statuses: write
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
cancel-in-progress: true
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Classify comment
id: classify
env:
COMMENT_BODY: ${{ github.event.comment.body }}
IS_PR: ${{ github.event.issue.pull_request != null }}
run: |
set -euo pipefail
{
echo "run_qa=false"
echo "run_security=false"
echo "run_tier=false"
} >> "$GITHUB_OUTPUT"
if [ "$IS_PR" != "true" ]; then
echo "::notice::not a PR comment; no-op"
exit 0
fi
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
case "$first_line" in
/qa-recheck*)
echo "run_qa=true" >> "$GITHUB_OUTPUT"
;;
/security-recheck*)
echo "run_security=true" >> "$GITHUB_OUTPUT"
;;
/refire-tier-check*)
echo "run_tier=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "::notice::no supported review refire slash command; no-op"
;;
esac
- name: Check out BASE ref for trusted scripts
if: |
steps.classify.outputs.run_qa == 'true' ||
steps.classify.outputs.run_security == 'true' ||
steps.classify.outputs.run_tier == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Refire qa-review status
if: steps.classify.outputs.run_qa == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: qa
TEAM_ID: '20'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire security-review status
if: steps.classify.outputs.run_security == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: security
TEAM_ID: '21'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire sop-tier-check status
if: steps.classify.outputs.run_tier == 'true'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh
+4 -13
View File
@@ -66,28 +66,19 @@ jobs:
# PR#372's ci.yml port used. Diffs against the PR base or the
# previous push SHA, then matches against the wheel-relevant
# path set.
#
# NOTE: Gitea Actions does not expose github.event.before as a
# shell environment variable. The ${{ github.event.before }} template
# expression works inside YAML run: blocks but is evaluated to an
# empty string for push events, making the ${VAR:-fallback} always
# use the fallback. Use GITHUB_EVENT_BEFORE instead — it IS set in
# the runner's shell environment for push events.
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
BASE="$GITHUB_EVENT_BEFORE"
fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
# New branch or no previous SHA: treat as wheel-relevant.
echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0
fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0
fi
+10 -5
View File
@@ -12,6 +12,8 @@ name: security-review
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions:
contents: read
@@ -20,10 +22,13 @@ permissions:
jobs:
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
approved:
# Comment-triggered refires live in review-refire-comments.yml. Keeping
# this workflow PR-only avoids comment-triggered queue storms.
# See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational
# log only, NOT a gate) / A4 / A5 design rationale.
if: |
github.event_name == 'pull_request_target'
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/security-recheck'))
runs-on: ubuntu-latest
steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
@@ -32,7 +37,7 @@ jobs:
# so re-running on a non-collaborator comment is harmless.
if: github.event_name == 'issue_comment'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
login="${{ github.event.comment.user.login }}"
@@ -57,7 +62,7 @@ jobs:
- name: Evaluate security-review
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
@@ -1,4 +1,4 @@
# sop-checklist — peer-ack merge gate for SOP-checklist items.
# sop-checklist-gate — peer-ack merge gate for SOP-checklist items.
#
# RFC#351 Step 2 of 6 (implementation MVP).
#
@@ -65,15 +65,7 @@
# membership, compute, post status). Re-running on any event is safe —
# the new status overwrites the previous one for the same context.
name: sop-checklist
# Cancel any in-progress runs for the same PR to prevent
# stale runs from overwriting newer status contexts.
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
cancel-in-progress: true
# bp-required: yes ← emits sop-checklist / all-items-acked (pull_request)
name: sop-checklist-gate
on:
pull_request_target:
@@ -91,7 +83,7 @@ permissions:
statuses: write
jobs:
all-items-acked:
gate:
# Run on pull_request_target events always. On issue_comment events,
# only when the comment is on a PR (issue_comment fires for issues
# too) and the body contains one of the slash-commands.
@@ -100,8 +92,7 @@ jobs:
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
(contains(github.event.comment.body, '/sop-ack') ||
contains(github.event.comment.body, '/sop-revoke') ||
contains(github.event.comment.body, '/sop-n/a')))
contains(github.event.comment.body, '/sop-revoke')))
runs-on: ubuntu-latest
steps:
- name: Check out BASE ref (trust boundary — never PR-head)
@@ -114,7 +105,7 @@ jobs:
# qa-review.yml so the script source is always trusted.
ref: ${{ github.event.repository.default_branch }}
- name: Run sop-checklist
- name: Run sop-checklist-gate
env:
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
@@ -122,7 +113,7 @@ jobs:
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -euo pipefail
python3 .gitea/scripts/sop-checklist.py \
python3 .gitea/scripts/sop-checklist-gate.py \
--owner "$OWNER" \
--repo "$REPO_NAME" \
--pr "$PR_NUMBER" \
-4
View File
@@ -61,10 +61,6 @@ on:
pull_request_review:
types: [submitted, dismissed, edited]
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
tier-check:
runs-on: ubuntu-latest
+40 -13
View File
@@ -1,4 +1,4 @@
# sop-tier-refire — manual fallback for sop-tier-check refire.
# sop-tier-refire — issue_comment-triggered refire of sop-tier-check.
#
# Closes internal#292. Gitea 1.22.6 doesn't refire workflows on the
# `pull_request_review` event (go-gitea/gitea#33700); the `sop-tier-check`
@@ -8,12 +8,12 @@
# to merge is the admin force-merge path (audited via `audit-force-merge`
# but the audit trail keeps growing; see `feedback_never_admin_merge_bypass`).
#
# Comment-triggered refires now live in `review-refire-comments.yml`. Gitea
# queues issue_comment workflows before evaluating job-level `if:`, so having
# qa-review, security-review, sop-checklist, and sop-tier-refire all subscribe
# to every comment caused queue storms on SOP-heavy PRs. This workflow is a
# non-automatic breadcrumb only; Gitea 1.22.6 does not support
# workflow_dispatch inputs, so real refires must use `/refire-tier-check`.
# Workaround pattern from `feedback_pull_request_review_no_refire`:
# `issue_comment` events DO fire reliably on 1.22.6. When a repo
# MEMBER/OWNER/COLLABORATOR comments `/refire-tier-check` on a PR, this
# workflow re-runs the sop-tier-check logic and POSTs the resulting
# status to the PR head SHA directly. No empty commit, no git history
# bloat, no cascade re-fire of every other workflow on the PR.
#
# SECURITY MODEL:
#
@@ -37,16 +37,43 @@
# Rate-limit: a 1s pre-sleep + a "skip if status posted in last 30s"
# guard prevents comment-spam from thrashing the status. See the script.
name: sop-tier-check refire (manual)
name: sop-tier-check refire (issue_comment)
on:
workflow_dispatch:
issue_comment:
types: [created]
jobs:
refire:
# Three gates, all required:
# - comment is on a PR (not a plain issue)
# - commenter is MEMBER, OWNER, or COLLABORATOR
# - comment body contains the slash-command trigger
if: |
github.event.issue.pull_request != null &&
contains(fromJson('["MEMBER","OWNER","COLLABORATOR"]'), github.event.comment.author_association) &&
contains(github.event.comment.body, '/refire-tier-check')
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
statuses: write
steps:
- name: Explain supported refire path
run: |
echo "::error::Gitea 1.22.6 does not support workflow_dispatch inputs here; comment /refire-tier-check on the PR instead."
exit 1
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Load the script from the default branch (main), matching the
# sop-tier-check.yml security model.
ref: ${{ github.event.repository.default_branch }}
- name: Re-evaluate sop-tier-check and POST status
env:
# Same org-level secret sop-tier-check.yml + audit-force-merge.yml use.
# Fallback to GITHUB_TOKEN with a clear error if missing.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
# Set to '1' for diagnostic per-API-call output. Off by default.
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh
+1 -1
View File
@@ -1 +1 @@
staging trigger 2026-05-14T17:35:02Z
staging trigger
-1
View File
@@ -1 +0,0 @@
trigger
-173
View File
@@ -1,173 +0,0 @@
import { test, expect } from "@playwright/test";
import { startEchoRuntime } from "./fixtures/echo-runtime";
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
test.describe("Desktop ChatTab", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
let workspaceName = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
workspaceName = ws.name;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
// Dismiss onboarding guide if present.
const skipGuide = page.getByText("Skip guide");
if (await skipGuide.isVisible().catch(() => false)) {
await skipGuide.click();
}
// Click the workspace node by its exact name label.
await page.getByText(workspaceName, { exact: true }).first().click();
// Wait for the side panel chat tab to be clickable, then click it.
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
});
test("chat panel loads without error", async ({ page }) => {
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
expect(hasEmptyState || hasHistory).toBeTruthy();
});
test("send text message and receive echo response", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("What is the weather?");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("What is the weather?")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: What is the weather?")).toBeVisible({ timeout: 15_000 });
});
test("history persists across reload", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Persistence test");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 15_000 });
await page.reload();
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
await page.getByText(workspaceName, { exact: true }).first().click();
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
await expect(page.getByText("Persistence test", { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Persistence test")).toBeVisible({ timeout: 5_000 });
});
test("file attachment round-trip", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Please read this file");
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
await fileInput.setInputFiles({
name: "test.txt",
mimeType: "text/plain",
buffer: Buffer.from("secret content abc123"),
});
await expect(page.getByText("test.txt")).toBeVisible({ timeout: 3_000 });
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Please read this file")).toBeVisible({ timeout: 15_000 });
});
test("activity log appears during send", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Trigger activity");
await page.getByRole("button", { name: /Send/ }).first().click();
// Activity log container should appear during the send flow.
await expect(page.locator("[data-testid='activity-log']").first()).toBeVisible({ timeout: 10_000 }).catch(() => {
// Activity log may not be present in all layouts.
});
});
});
test.describe("Desktop ChatTab — Markdown rendering", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
let workspaceName = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
workspaceName = ws.name;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/");
await page.waitForSelector(".react-flow__node", { timeout: 10_000 });
const skipGuide2 = page.getByText("Skip guide");
if (await skipGuide2.isVisible().catch(() => false)) {
await skipGuide2.click();
}
await page.getByText(workspaceName, { exact: true }).first().click();
await page.locator('#tab-chat').click();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 5_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
});
test("code block renders <pre>", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("```js\nconst x = 1;\n```");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: ```js")).toBeVisible({ timeout: 15_000 });
const pre = page.locator("pre").first();
await expect(pre).toBeVisible({ timeout: 5_000 });
await expect(pre).toContainText("const x = 1;");
});
test("table renders <table>", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("| A | B |\n|---|---|\n| 1 | 2 |");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: | A | B |")).toBeVisible({ timeout: 15_000 });
const table = page.locator("table").first();
await expect(table).toBeVisible({ timeout: 5_000 });
await expect(table).toContainText("A");
await expect(table).toContainText("1");
});
});
-97
View File
@@ -1,97 +0,0 @@
import { test, expect } from "@playwright/test";
import { startEchoRuntime } from "./fixtures/echo-runtime";
import { seedWorkspace, startHeartbeat, cleanupWorkspace } from "./fixtures/chat-seed";
test.describe("MobileChat", () => {
let cleanup: () => Promise<void> = async () => {};
let workspaceId = "";
test.beforeAll(async () => {
const echo = await startEchoRuntime();
const ws = await seedWorkspace(echo.baseURL);
workspaceId = ws.id;
const stopHeartbeat = startHeartbeat(ws.id, ws.authToken);
cleanup = async () => {
stopHeartbeat();
await echo.stop();
};
});
test.afterAll(async () => {
await cleanupWorkspace(workspaceId);
await cleanup();
});
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: 375, height: 812 });
// Navigate directly to the mobile chat view.
await page.goto(`/?m=chat&a=${workspaceId}`);
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
// Wait for the workspace status to flip to online and the textarea to be enabled.
await expect(page.locator("textarea").first()).toBeEnabled({ timeout: 15_000 });
// Dismiss onboarding guide if present.
const skipGuide = page.getByText("Skip guide");
if (await skipGuide.isVisible().catch(() => false)) {
await skipGuide.click();
}
});
test("chat panel loads without error", async ({ page }) => {
const hasEmptyState = await page.getByText("Send a message to start chatting.").isVisible().catch(() => false);
const hasHistory = await page.locator("[data-testid='chat-panel']").locator("div").count() > 3;
expect(hasEmptyState || hasHistory).toBeTruthy();
});
test("send text message and receive echo response", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile test message");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Mobile test message")).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Mobile test message")).toBeVisible({ timeout: 15_000 });
});
test("history persists across reload", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile persistence");
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 15_000 });
await page.reload();
await page.waitForSelector("[data-testid='chat-panel']", { timeout: 10_000 });
await expect(page.getByText("Mobile persistence", { exact: true })).toBeVisible({ timeout: 5_000 });
await expect(page.getByText("Echo: Mobile persistence")).toBeVisible({ timeout: 5_000 });
});
test("composer auto-grows with multi-line text", async ({ page }) => {
const textarea = page.locator("textarea").first();
const initialHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
await textarea.fill("Line 1\nLine 2\nLine 3\nLine 4\nLine 5");
await page.waitForTimeout(300);
const grownHeight = await textarea.evaluate((el: HTMLElement) => el.offsetHeight);
expect(grownHeight).toBeGreaterThan(initialHeight);
});
test("file attachment in mobile chat", async ({ page }) => {
const textarea = page.locator("textarea").first();
await textarea.fill("Mobile file test");
const fileInput = page.locator("[data-testid='chat-panel'] input[type='file']").first();
await fileInput.setInputFiles({
name: "mobile.txt",
mimeType: "text/plain",
buffer: Buffer.from("mobile secret"),
});
await expect(page.getByText("mobile.txt")).toBeVisible({ timeout: 3_000 });
await page.getByRole("button", { name: /Send/ }).first().click();
await expect(page.getByText("Echo: Mobile file test")).toBeVisible({ timeout: 15_000 });
});
});
-187
View File
@@ -1,187 +0,0 @@
/**
* E2E seed fixture for chat tests.
*
* Creates an external workspace via the workspace-server API, extracts the
* auto-minted auth token, then overrides the DB row so it appears "online"
* with an echo-runtime URL. External runtime is used because the health
* sweep skips Docker checks for external workspaces; we keep the workspace
* alive with periodic heartbeats.
*/
import { randomUUID } from "node:crypto";
const PLATFORM_URL = process.env.E2E_PLATFORM_URL ?? "http://localhost:8080";
export interface SeededWorkspace {
id: string;
name: string;
agentURL: string;
authToken: string;
}
/**
* Create an external workspace and wire it to the echo runtime.
*/
export async function seedWorkspace(echoURL: string): Promise<SeededWorkspace> {
// 1. Create external workspace (no URL — platform will mint an auth token).
const runId = Math.random().toString(36).slice(2, 8);
const wsName = `Chat E2E Agent ${runId}`;
const createRes = await fetch(`${PLATFORM_URL}/workspaces`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: wsName, tier: 1, external: true, runtime: "external" }),
});
if (!createRes.ok) {
const text = await createRes.text();
throw new Error(`Failed to create workspace: ${createRes.status} ${text}`);
}
const ws = (await createRes.json()) as {
id: string;
name: string;
connection?: { auth_token?: string };
};
const authToken = ws.connection?.auth_token;
if (!authToken) {
throw new Error("Workspace created but no auth_token returned");
}
// 2. Direct DB update: mark online + point url at echo runtime.
// The platform blocks loopback URLs at the API layer (SSRF guard),
// so we bypass via psql for local E2E.
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) {
throw new Error("E2E_DATABASE_URL must be set for DB seeding");
}
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) {
throw new Error(`Cannot parse E2E_DATABASE_URL: ${dbUrl}`);
}
const [, user, pass, host, port, db] = m;
// Pre-seed a platform_inbound_secret so chat file uploads don't trigger
// the lazy-heal 503 "retry in 30 s" path on first use.
const inboundSecret = Array.from({ length: 43 }, () =>
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"[
Math.floor(Math.random() * 64)
],
).join("");
const psql = [
`PGPASSWORD=${pass} psql`,
`-h ${host} -p ${port} -U ${user} -d ${db}`,
`-c "UPDATE workspaces SET status = 'online', url = '${echoURL}', platform_inbound_secret = '${inboundSecret}' WHERE id = '${ws.id}'"`,
].join(" ");
const { execSync } = await import("node:child_process");
try {
execSync(psql, { stdio: "pipe", timeout: 30_000 });
} catch (err) {
throw new Error(`DB update failed: ${err}`);
}
return { id: ws.id, name: wsName, agentURL: echoURL, authToken };
}
/**
* Start a heartbeat interval that keeps an external workspace alive.
* Returns a stop function.
*/
export function startHeartbeat(
workspaceId: string,
authToken: string,
intervalMs = 30_000,
): () => void {
const send = () => {
fetch(`${PLATFORM_URL}/registry/heartbeat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify({
workspace_id: workspaceId,
error_rate: 0,
sample_error: "",
active_tasks: 0,
current_task: "",
uptime_seconds: 0,
}),
}).catch(() => {});
};
// Send immediately so the first heartbeat lands before the stale sweep.
send();
const timer = setInterval(send, intervalMs);
return () => clearInterval(timer);
}
/**
* Seed chat-history rows for a workspace.
*/
export async function seedChatHistory(
workspaceId: string,
messages: Array<{ role: "user" | "agent"; content: string }>,
): Promise<void> {
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) return;
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) return;
const [, user, pass, host, port, db] = m;
const values = messages
.map(
(msg, i) =>
`('${randomUUID()}', '${workspaceId}', '${msg.role}', '${msg.content.replace(/'/g, "''")}', NOW() - INTERVAL '${messages.length - i} seconds')`,
)
.join(",");
const sql = `INSERT INTO chat_messages (id, workspace_id, role, content, created_at) VALUES ${values};`;
const { execSync } = await import("node:child_process");
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "${sql}"`;
execSync(psql, { stdio: "pipe", timeout: 10_000 });
}
/**
* Delete a seeded workspace row directly from the DB.
* Uses psql (same credentials as seedWorkspace) so we bypass any
* workspace-server side-effects (container stop, cascade cleanup, etc.)
* that can race or 500 on external workspaces.
*/
export async function cleanupWorkspace(workspaceId: string): Promise<void> {
const dbUrl = process.env.E2E_DATABASE_URL;
if (!dbUrl) return;
const pgRegex = /postgres:\/\/([^:]+):([^@]+)@([^:]+):(\d+)\/([^?]+)/;
const m = dbUrl.match(pgRegex);
if (!m) return;
const [, user, pass, host, port, db] = m;
const psql = `PGPASSWORD=${pass} psql -h ${host} -p ${port} -U ${user} -d ${db} -c "DELETE FROM workspaces WHERE id = '${workspaceId}'"`;
const { execSync } = await import("node:child_process");
try {
execSync(psql, { stdio: "pipe", timeout: 30_000 });
} catch {
// Best-effort cleanup; don't fail the test suite if the row is already gone.
}
}
/**
* Mint a workspace auth token so the canvas can make authenticated API
* calls (WorkspaceAuth middleware).
*/
export async function mintTestToken(workspaceId: string): Promise<string> {
const res = await fetch(
`${PLATFORM_URL}/admin/workspaces/${workspaceId}/test-token`,
);
if (!res.ok) {
throw new Error(`Failed to mint test token: ${res.status}`);
}
const data = (await res.json()) as { auth_token: string };
return data.auth_token;
}
-180
View File
@@ -1,180 +0,0 @@
/**
* Minimal A2A echo runtime for E2E tests.
*
* Listens on an ephemeral port, receives A2A JSON-RPC `message/send`
* requests, and returns a response with the original text echoed back.
* Also implements the workspace-side chat upload ingest endpoint so
* file-attachment E2E can exercise the full upload → send → echo
* round-trip.
*
* Usage (inside test fixture):
* const echo = await startEchoRuntime();
* // ... seed workspace with agent_url pointing to echo.baseURL ...
* echo.stop();
*/
import { createServer, type Server } from "node:http";
export interface EchoRuntime {
baseURL: string;
stop: () => Promise<void>;
lastRequest: { method: string; text: string; files: unknown[] } | null;
}
/** Parse a minimal multipart body and extract the first file's name + content. */
function parseMultipart(body: Buffer): { name: string; mimeType: string; content: Buffer } | null {
// Find the boundary line (first line starting with "--").
const str = body.toString("binary");
const firstDash = str.indexOf("--");
if (firstDash === -1) return null;
const eol = str.indexOf("\r\n", firstDash);
if (eol === -1) return null;
const boundary = str.slice(firstDash + 2, eol);
const boundaryMarker = "\r\n--" + boundary;
// Find the first part that has a filename in Content-Disposition.
let pos = eol + 2;
while (pos < str.length) {
const nextBoundary = str.indexOf(boundaryMarker, pos);
if (nextBoundary === -1) break;
const part = str.slice(pos, nextBoundary);
const cdMatch = part.match(/Content-Disposition:[^\r\n]*filename="([^"]+)"/i);
if (cdMatch) {
const name = cdMatch[1];
const ctMatch = part.match(/Content-Type:\s*([^\r\n]+)/i);
const mimeType = ctMatch ? ctMatch[1].trim() : "application/octet-stream";
// Body starts after the first double-CRLF in the part.
const bodyStart = part.indexOf("\r\n\r\n");
if (bodyStart !== -1) {
// Extract the raw bytes (not the string) so binary is safe.
const headerBytes = Buffer.byteLength(part.slice(0, bodyStart + 4), "binary");
const partStartInBody = Buffer.byteLength(str.slice(0, pos + bodyStart + 4), "binary");
const partEndInBody = Buffer.byteLength(str.slice(0, nextBoundary), "binary");
const content = body.subarray(partStartInBody, partEndInBody);
return { name, mimeType, content };
}
}
pos = nextBoundary + boundaryMarker.length;
// Skip trailing "--" (end marker) or CRLF.
if (str.slice(pos, pos + 2) === "--") break;
if (str.slice(pos, pos + 2) === "\r\n") pos += 2;
}
return null;
}
export async function startEchoRuntime(): Promise<EchoRuntime> {
let lastRequest: EchoRuntime["lastRequest"] = null;
const server = createServer((req, res) => {
// CORS: allow the canvas origin (localhost:3000) to call us.
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
const url = req.url ?? "/";
// Workspace-side chat upload ingest (RFC #2312).
if (url === "/internal/chat/uploads/ingest" && req.method === "POST") {
const chunks: Buffer[] = [];
req.on("data", (chunk: Buffer) => chunks.push(chunk));
req.on("end", () => {
const body = Buffer.concat(chunks);
const file = parseMultipart(body);
if (!file) {
res.writeHead(400);
res.end(JSON.stringify({ error: "no files field" }));
return;
}
const sanitized = file.name.replace(/[^a-zA-Z0-9._\-]/g, "_").replace(/ /g, "_");
const prefix = Array.from({ length: 32 }, () =>
Math.floor(Math.random() * 16).toString(16),
).join("");
const response = {
files: [
{
uri: `workspace:/workspace/.molecule/chat-uploads/${prefix}-${sanitized}`,
name: sanitized,
mimeType: file.mimeType,
size: file.content.length,
},
],
};
res.setHeader("Content-Type", "application/json");
res.writeHead(200);
res.end(JSON.stringify(response));
});
return;
}
// Default: A2A JSON-RPC handler.
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk: string) => {
body += chunk;
});
req.on("end", () => {
res.setHeader("Content-Type", "application/json");
try {
const rpc = JSON.parse(body);
const msg = rpc.params?.message;
const textParts =
msg?.parts
?.filter((p: { kind?: string; text?: string }) => p.kind === "text")
.map((p: { text?: string }) => p.text)
.filter(Boolean) ?? [];
const fileParts =
msg?.parts?.filter((p: { kind?: string }) => p.kind === "file") ?? [];
const text = textParts.join("\n");
lastRequest = {
method: rpc.method ?? "unknown",
text,
files: fileParts,
};
const replyText = text
? `Echo: ${text}`
: fileParts.length > 0
? "Echo: received your file(s)."
: "Echo: hello";
const response = {
jsonrpc: "2.0",
id: rpc.id ?? null,
result: {
parts: [{ kind: "text", text: replyText }],
},
};
res.writeHead(200);
res.end(JSON.stringify(response));
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "invalid json" }));
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
const baseURL = `http://127.0.0.1:${port}`;
return {
baseURL,
stop: () =>
new Promise((resolve) => {
server.close(() => resolve(undefined));
}),
get lastRequest() {
return lastRequest;
},
};
}
+1 -2
View File
@@ -5,10 +5,9 @@ export default defineConfig({
timeout: 30_000,
expect: { timeout: 10_000 },
fullyParallel: false,
workers: 1,
retries: 0,
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://localhost:3000",
baseURL: "http://localhost:3000",
headless: true,
screenshot: "only-on-failure",
},
+2 -2
View File
@@ -327,7 +327,7 @@ function OrgCTA({ org }: { org: Org }) {
return (
<a
href={href}
className="rounded bg-emerald-700 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-600"
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500"
>
Open
</a>
@@ -337,7 +337,7 @@ function OrgCTA({ org }: { org: Org }) {
return (
<a
href={`/pricing?org=${encodeURIComponent(org.slug)}`}
className="rounded bg-amber-800 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700"
className="rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-500"
>
Complete payment
</a>
+6 -14
View File
@@ -8,17 +8,11 @@ import type { AuditEntry, AuditResponse } from "@/types/audit";
type EventFilter = "all" | AuditEntry["event_type"];
// Contrast note: text is rendered on near-black bg (bg-*-950/40). Every text
// color below is chosen to pass WCAG 2.1 AA 4.5:1 on that background:
// blue-300 ( delegation ) ≈ 8.8:1
// violet-300 ( decision ) ≈ 9.5:1
// yellow-200 ( gate ) ≈ 11.5:1
// orange-300 ( hitl ) ≈ 9.1:1
const BADGE_COLORS: Record<AuditEntry["event_type"], { text: string; bg: string; border: string }> = {
delegation: { text: "text-blue-300", bg: "bg-blue-950/40", border: "border-blue-800/40" },
decision: { text: "text-violet-300", bg: "bg-violet-950/40", border: "border-violet-800/40" },
gate: { text: "text-yellow-200", bg: "bg-yellow-950/40", border: "border-yellow-800/40" },
hitl: { text: "text-orange-300", bg: "bg-orange-950/40", border: "border-orange-800/40" },
delegation: { text: "text-accent", bg: "bg-blue-950/40", border: "border-blue-800/40" },
decision: { text: "text-violet-400", bg: "bg-violet-950/40", border: "border-violet-800/40" },
gate: { text: "text-yellow-400", bg: "bg-yellow-950/40", border: "border-yellow-800/40" },
hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" },
};
const FILTERS: { id: EventFilter; label: string }[] = [
@@ -170,10 +164,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{/* Error banner */}
{error && (
<div
role="alert"
className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0"
>
<div className="mx-4 mt-3 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded text-xs text-bad shrink-0">
{error}
</div>
)}
@@ -251,6 +242,7 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
{/* Event-type badge */}
<span
className={`shrink-0 text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${badge.text} ${badge.bg} ${badge.border}`}
aria-label={`Event type: ${entry.event_type}`}
>
{entry.event_type}
</span>
+5 -5
View File
@@ -100,8 +100,8 @@ export function BatchActionBar() {
aria-label="Batch workspace actions"
className="fixed bottom-6 left-1/2 -translate-x-1/2 z-[200] flex items-center gap-3 px-4 py-2.5 rounded-2xl bg-surface-sunken/95 border border-line/70 shadow-2xl shadow-black/50 backdrop-blur-md"
>
{/* Selection count badge — bg-zinc-700 passes 7.2:1 on white text */}
<span className="text-[12px] font-semibold text-white bg-zinc-700 px-2.5 py-0.5 rounded-full tabular-nums">
{/* Selection count badge */}
<span className="text-[12px] font-semibold text-white bg-accent-strong/80 px-2.5 py-0.5 rounded-full tabular-nums">
{count} selected
</span>
@@ -112,7 +112,7 @@ export function BatchActionBar() {
type="button"
disabled={busy}
onClick={() => setPending("restart")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-white bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-sky-300 bg-sky-900/30 hover:bg-sky-800/50 border border-sky-700/30 hover:border-sky-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500/70"
>
<span aria-hidden="true"></span>
Restart All
@@ -122,7 +122,7 @@ export function BatchActionBar() {
type="button"
disabled={busy}
onClick={() => setPending("pause")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-white bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-warm bg-amber-900/30 hover:bg-amber-800/50 border border-amber-700/30 hover:border-amber-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/70"
>
<span aria-hidden="true"></span>
Pause All
@@ -132,7 +132,7 @@ export function BatchActionBar() {
type="button"
disabled={busy}
onClick={() => setPending("delete")}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-white bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[12px] font-medium text-bad bg-red-900/30 hover:bg-red-800/50 border border-red-700/30 hover:border-red-600/50 transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/70"
>
<span aria-hidden="true"></span>
Delete All
+1 -1
View File
@@ -96,7 +96,7 @@ export function ConfirmDialog({
// readable in both light and dark themes.
const confirmColors =
confirmVariant === "danger"
? "bg-red-700 hover:bg-red-600 text-white"
? "bg-red-600 hover:bg-red-700 text-white"
: confirmVariant === "warning"
? "bg-amber-800 hover:bg-amber-700 text-white"
: "bg-accent hover:bg-accent-strong text-white";
+1 -1
View File
@@ -318,7 +318,7 @@ export function ContextMenu() {
aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
/>
<span className="text-[10px] text-ink">{contextMenu.nodeData.status}</span>
<span className="text-[10px] text-ink-mid">{contextMenu.nodeData.status}</span>
</div>
</div>
@@ -187,7 +187,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
isError
? "bg-red-950/50 text-bad"
: isSend
? "bg-cyan-950 text-cyan-300"
? "bg-cyan-950/50 text-cyan-400"
: isReceive
? "bg-blue-950/50 text-accent"
: "bg-surface-card text-ink-mid"
@@ -251,7 +251,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Error */}
{isError && entry.error_detail && (
<div className="text-[10px] text-bad mt-1 truncate">
<div className="text-[10px] text-bad/80 mt-1 truncate">
{entry.error_detail.slice(0, 200)}
</div>
)}
@@ -272,7 +272,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
)}
{responseText && (
<div className="mt-1 bg-surface/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-good uppercase mb-1">Response</div>
<div className="text-[8px] text-good/60 uppercase mb-1">Response</div>
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
{responseText.slice(0, 2000)}
{responseText.length > 2000 && (
@@ -126,8 +126,8 @@ export function DeleteCascadeConfirmDialog({
{/* Cascade warning */}
<div className="rounded border border-red-900/40 bg-red-950/20 px-3 py-2.5 mb-4">
<p className="text-[12px] text-red-300 leading-relaxed">
Deleting will cascade <strong className="text-red-100">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
<p className="text-[12px] text-bad/80 leading-relaxed">
Deleting will cascade <strong className="text-red-200">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
</p>
</div>
@@ -164,13 +164,13 @@ export function DeleteCascadeConfirmDialog({
type="button"
onClick={onConfirm}
disabled={!checked}
// Hover goes DARKER, not lighter — bg-red-600 on white text
// drops contrast below AA. Same trap fixed in ConfirmDialog.
// focus-visible ring matches the canvas chrome.
// Hover goes DARKER, not lighter — bg-red-500 on white text
// drops contrast below AA vs bg-red-700. Same trap fixed in
// ConfirmDialog and ApprovalBanner. focus-visible ring matches.
className={`px-3.5 py-1.5 text-[13px] rounded-lg transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken
${checked
? "bg-red-700 hover:bg-red-600 text-white cursor-pointer"
: "bg-red-900/30 text-red-400 cursor-not-allowed"
? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
: "bg-red-900/30 text-bad/40 cursor-not-allowed"
}`}
>
Delete All
+2 -2
View File
@@ -51,7 +51,7 @@ export class ErrorBoundary extends React.Component<
render() {
if (this.state.hasError) {
return (
<div role="alert" aria-live="assertive" className="fixed inset-0 flex items-center justify-center bg-surface z-50">
<div className="fixed inset-0 flex items-center justify-center bg-surface z-50">
<div className="max-w-md rounded-2xl border border-red-500/30 bg-surface-sunken/90 px-8 py-8 text-center shadow-2xl shadow-black/40">
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-red-500/10 border border-red-500/30">
<svg
@@ -76,7 +76,7 @@ export class ErrorBoundary extends React.Component<
<p className="text-sm text-ink-mid mb-1">
An unexpected error occurred while rendering the application.
</p>
<p className="text-xs text-bad mb-6 font-mono break-all">
<p className="text-xs text-bad/80 mb-6 font-mono break-all">
{this.state.error?.message ?? "Unknown error"}
</p>
<div className="flex items-center justify-center gap-3">
@@ -360,7 +360,7 @@ function SnippetBlock({
<button
type="button"
onClick={onCopy}
className="text-xs px-2 py-1 rounded bg-accent text-white hover:bg-accent-strong transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-xs px-2 py-1 rounded bg-accent-strong/80 hover:bg-accent text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{copied ? "Copied!" : "Copy"}
</button>
+4 -4
View File
@@ -344,7 +344,7 @@ function ProviderPickerModal({
// wrapper's bounds instead of the viewport.
if (typeof document === "undefined") return null;
const allSaved = entries.every((e) => e.saved);
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
@@ -451,7 +451,7 @@ function ProviderPickerModal({
<button
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
>
{entry.saving ? "..." : "Save"}
</button>
@@ -492,7 +492,7 @@ function ProviderPickerModal({
!selectorValue.providerId ||
(showModelInput && model.trim() === "")
}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
>
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
</button>
@@ -616,7 +616,7 @@ function AllKeysModal({
if (!open) return null;
if (typeof document === "undefined") return null;
const allSaved = entries.every((e) => e.saved);
const allSaved = entries.length > 0 && entries.every((e) => e.saved);
const anySaving = entries.some((e) => e.saving);
const runtimeLabel = runtime
.replace(/[-_]/g, " ")
@@ -420,7 +420,7 @@ export function ProviderModelSelector({
spellCheck={false}
autoComplete="off"
data-testid="model-input"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:border-accent transition-colors disabled:opacity-50"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
/>
<p className="text-[9px] text-ink-mid mt-1 leading-relaxed">
{selected?.wildcard
@@ -389,7 +389,7 @@ export function ProvisioningTimeout({
<button
type="button"
onClick={handleCancelConfirm}
className="px-3.5 py-1.5 text-[12px] bg-red-800 hover:bg-red-700 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
className="px-3.5 py-1.5 text-[12px] bg-red-600 hover:bg-red-500 text-white rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
>
Remove Workspace
</button>
+2 -6
View File
@@ -61,12 +61,8 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
return;
}
setTheme(OPTIONS[next].value);
// Move focus to the new button so arrow-key navigation is continuous.
// Query is already scoped to radiogroup so no child-combinator needed;
// avoids accidentally focusing unrelated [role=radio] elements
// elsewhere in the DOM (e.g. React Flow canvas nodes).
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
const btns = radiogroup?.querySelectorAll<HTMLButtonElement>("[role=radio]");
// Move focus to the new button so arrow-key navigation is continuous
const btns = (e.currentTarget.closest("[role=radiogroup]") as HTMLElement)?.querySelectorAll<HTMLButtonElement>("[role=radio]");
btns?.[next]?.focus();
},
[]
+7 -10
View File
@@ -13,20 +13,17 @@ import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
/** Descendant count for the "N sub" badge — children are first-class nodes
* rendered as full cards inside this one via React Flow's native parentId,
* so we don't need to subscribe to the actual child list here.
* Selecting `nodes` stably avoids a new selector reference on every store
* update (React error #185 / Zustand + React 19 Object.is strictness). */
* so we don't need to subscribe to the actual child list here. */
function useDescendantCount(nodeId: string): number {
const nodes = useCanvasStore((s) => s.nodes);
return useMemo(() => countDescendants(nodeId, nodes), [nodeId, nodes]);
return useCanvasStore(
useCallback((s) => countDescendants(nodeId, s.nodes), [nodeId])
);
}
/** Boolean flag used to drive min-size and NodeResizer dimensions.
* Selecting `nodes` stably avoids re-render loops (same issue as
* useDescendantCount). */
function useHasChildren(nodeId: string): boolean {
const nodes = useCanvasStore((s) => s.nodes);
return useMemo(() => nodes.some((n) => n.data.parentId === nodeId), [nodes, nodeId]);
return useCanvasStore(
useCallback((s) => s.nodes.some((n) => n.data.parentId === nodeId), [nodeId])
);
}
/** Eject/extract arrow icon — visually distinct from delete ✕ */
@@ -24,12 +24,8 @@ vi.mock("@/lib/theme-provider", () => ({
})),
}));
// Wrap cleanup in act() so any pending React state updates (e.g. from
// keyDown handlers that call setTheme) flush before DOM unmount. Without
// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
// when the handleKeyDown callback tries to query the DOM mid-teardown.
afterEach(() => {
act(() => { cleanup(); });
cleanup();
vi.clearAllMocks();
});
@@ -150,7 +146,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
act(() => { radios[2].focus(); });
act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); });
fireEvent.keyDown(radios[2], { key: "ArrowRight" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -164,7 +160,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowLeft should go to dark (index 2)
act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); });
fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
@@ -178,7 +174,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowDown should go to system (index 1)
act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); });
fireEvent.keyDown(radios[0], { key: "ArrowDown" });
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
@@ -191,7 +187,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { radios[2].focus(); });
act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); });
fireEvent.keyDown(radios[2], { key: "Home" });
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
@@ -204,14 +200,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "End" }); });
fireEvent.keyDown(radios[0], { key: "End" });
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("does nothing on unrelated keys", () => {
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); });
fireEvent.keyDown(radios[0], { key: "Enter" });
expect(mockSetTheme).not.toHaveBeenCalled();
});
});
@@ -24,20 +24,16 @@ import {
*/
export function DropTargetBadge() {
const dragOverNodeId = useCanvasStore((s) => s.dragOverNodeId);
// Select nodes stably first — deriving targetName and childCount inside
// the same selector creates a new return value on every store mutation
// even when neither has changed (React error #185 / Zustand Object.is).
const nodes = useCanvasStore((s) => s.nodes);
const targetName = (() => {
if (!dragOverNodeId) return null;
const n = nodes.find((nn) => nn.id === dragOverNodeId);
const targetName = useCanvasStore((s) => {
if (!s.dragOverNodeId) return null;
const n = s.nodes.find((nn) => nn.id === s.dragOverNodeId);
return (n?.data as WorkspaceNodeData | undefined)?.name ?? null;
})();
const childCount = (() =>
!dragOverNodeId
});
const childCount = useCanvasStore((s) =>
!s.dragOverNodeId
? 0
: nodes.filter((n) => n.parentId === dragOverNodeId).length
)();
: s.nodes.filter((n) => n.parentId === s.dragOverNodeId).length,
);
const { getInternalNode, flowToScreenPosition } = useReactFlow();
if (!dragOverNodeId || !targetName) return null;
const internal = getInternalNode(dragOverNodeId);
@@ -68,7 +64,6 @@ export function DropTargetBadge() {
{ghostVisible && (
<div
data-testid="ghost-slot"
aria-hidden="true"
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
style={{
left: slotTL.x,
@@ -80,9 +75,7 @@ export function DropTargetBadge() {
)}
<div
data-testid="drop-badge"
role="status"
aria-label={`Drop target: ${targetName}`}
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-700 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-white shadow-lg shadow-emerald-950/40"
style={{ left: badge.x, top: badge.y - 6 }}
>
Drop into: {targetName}
@@ -1,311 +0,0 @@
/**
* Unit tests for buildDeployMap — the pure tree-traversal core of
* useOrgDeployState.
*
* What is tested here:
* - Root / leaf identification via parent-chain walk
* - isDeployingRoot: true when any descendant is "provisioning"
* - isActivelyProvisioning: true only for the node itself in that state
* - isLockedChild: true for non-root nodes in a deploying tree
* - isLockedChild: also true for nodes in deletingIds (even if not deploying)
* - descendantProvisioningCount: non-zero only on root nodes
* - Performance contract: O(n) single-pass walk — tested by verifying
* correctness across 50-node trees (n=50, all cases above)
*
* What is NOT tested here (hook integration — appropriate for E2E):
* - The useMemo / Zustand subscription wiring
* - React Flow integration (flowToScreenPosition, getInternalNode)
*
* Issue: #2071 (Canvas test gaps follow-up).
*/
import { describe, expect, it } from "vitest";
import { buildDeployMap, type OrgDeployState } from "../useOrgDeployState";
// ── Helpers ──────────────────────────────────────────────────────────────────
type Projection = { id: string; parentId: string | null; status: string };
function proj(
id: string,
parentId: string | null,
status: string,
): Projection {
return { id, parentId, status };
}
/** Unchecked cast — test helpers aren't production code paths. */
function m(
ps: Projection[],
deletingIds: string[] = [],
): Map<string, OrgDeployState> {
return buildDeployMap(ps, new Set(deletingIds));
}
function s(
map: Map<string, OrgDeployState>,
id: string,
): OrgDeployState {
const got = map.get(id);
if (!got) throw new Error(`no entry for id=${id}`);
return got;
}
// ── Empty / trivial ───────────────────────────────────────────────────────────
describe("buildDeployMap — empty", () => {
it("returns empty map for empty projections", () => {
expect(m([]).size).toBe(0);
});
});
// ── Single node ─────────────────────────────────────────────────────────────
describe("buildDeployMap — single node", () => {
it("isolated node is its own root and not deploying", () => {
const map = m([proj("a", null, "online")]);
expect(s(map, "a")).toEqual({
isActivelyProvisioning: false,
isDeployingRoot: false,
isLockedChild: false,
descendantProvisioningCount: 0,
});
});
it("isolated provisioning node is deploying root", () => {
const map = m([proj("a", null, "provisioning")]);
expect(s(map, "a")).toEqual({
isActivelyProvisioning: true,
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
});
});
});
// ── Parent / child chains ─────────────────────────────────────────────────────
describe("buildDeployMap — parent / child chains", () => {
it("root with online child: root is not deploying, child is not locked", () => {
// A ──► B
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
expect(s(map, "B")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
});
it("root with provisioning child: root is deploying, child is locked", () => {
// A ──► B (B is provisioning)
const map = m([
proj("A", null, "online"),
proj("B", "A", "provisioning"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
});
it("provisioning root with online child: root is deploying, child is locked", () => {
// A (provisioning) ──► B (online)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, isActivelyProvisioning: true });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
});
it("grandchild inherits deploy lock through intermediate online node", () => {
// A ──► B ──► C (A is provisioning)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "B", "online"),
]);
// B and C are both non-root descendants of the deploying root
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
});
it("deep chain: only the topmost node with a null parent counts as root", () => {
// A ──► B ──► C ──► D (A is provisioning)
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "B", "online"),
proj("D", "C", "online"),
]);
const roots = ["A", "B", "C", "D"].filter((id) => s(map, id).isDeployingRoot);
expect(roots).toEqual(["A"]);
});
});
// ── Sibling branching ─────────────────────────────────────────────────────────
describe("buildDeployMap — sibling branching", () => {
it("parent with multiple children: deploying root propagates to all children", () => {
// A (provisioning)
// / \
// B C
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
proj("C", "A", "online"),
]);
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true });
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 1 });
});
it("only one provisioning descendant marks the root as deploying", () => {
// A
// / | \
// B C D (only C is provisioning)
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
proj("C", "A", "provisioning"),
proj("D", "A", "online"),
]);
expect(s(map, "A")).toMatchObject({ isDeployingRoot: true, descendantProvisioningCount: 1 });
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
expect(s(map, "C")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: true });
expect(s(map, "D")).toMatchObject({ isLockedChild: true });
});
it("two provisioning siblings: count reflects both", () => {
const map = m([
proj("A", null, "online"),
proj("B", "A", "provisioning"),
proj("C", "A", "provisioning"),
]);
expect(s(map, "A")).toMatchObject({ descendantProvisioningCount: 2 });
expect(s(map, "B")).toMatchObject({ isActivelyProvisioning: true });
expect(s(map, "C")).toMatchObject({ isActivelyProvisioning: true });
});
});
// ── Multiple disjoint trees ───────────────────────────────────────────────────
describe("buildDeployMap — multiple disjoint trees", () => {
it("each tree has its own root; deploying nodes are independent", () => {
// Tree 1: X (provisioning) ──► Y
// Tree 2: P ──► Q (no provisioning)
const map = m([
proj("X", null, "provisioning"),
proj("Y", "X", "online"),
proj("P", null, "online"),
proj("Q", "P", "online"),
]);
expect(s(map, "X")).toMatchObject({ isDeployingRoot: true });
expect(s(map, "Y")).toMatchObject({ isLockedChild: true });
expect(s(map, "P")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
expect(s(map, "Q")).toMatchObject({ isDeployingRoot: false, isLockedChild: false });
});
});
// ── Deleting nodes ────────────────────────────────────────────────────────────
describe("buildDeployMap — deletingIds", () => {
it("node in deletingIds is locked even if tree is not deploying", () => {
const map = m(
[
proj("A", null, "online"),
proj("B", "A", "online"),
],
["B"], // B is being deleted
);
expect(s(map, "A")).toMatchObject({ isLockedChild: false });
expect(s(map, "B")).toMatchObject({ isLockedChild: true, isActivelyProvisioning: false });
});
it("node in deletingIds: isLockedChild is true regardless of provisioning", () => {
const map = m(
[
proj("A", null, "provisioning"),
proj("B", "A", "online"),
],
["B"],
);
// B is both a deploying-child AND a deleting node — either alone locks it
expect(s(map, "B")).toMatchObject({ isLockedChild: true });
});
it("empty deletingIds set has no effect", () => {
const map = m(
[
proj("A", null, "online"),
proj("B", "A", "online"),
],
[],
);
expect(s(map, "B")).toMatchObject({ isLockedChild: false });
});
});
// ── descendantProvisioningCount ───────────────────────────────────────────────
describe("buildDeployMap — descendantProvisioningCount", () => {
it("is 0 for non-root nodes", () => {
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "provisioning"),
]);
expect(s(map, "B").descendantProvisioningCount).toBe(0);
});
it("includes the root's own status when provisioning", () => {
const map = m([
proj("A", null, "provisioning"),
proj("B", "A", "online"),
]);
// A is both root and provisioning → count includes itself
expect(s(map, "A").descendantProvisioningCount).toBe(1);
});
it("accumulates all provisioning descendants (not just immediate children)", () => {
const map = m([
proj("A", null, "online"),
proj("B", "A", "online"),
proj("C", "B", "provisioning"),
]);
expect(s(map, "A").descendantProvisioningCount).toBe(1);
});
});
// ── O(n) performance ─────────────────────────────────────────────────────────
describe("buildDeployMap — O(n) performance contract", () => {
it("handles a 50-node three-level tree without incorrect node assignments", () => {
// Level 0: 1 root
// Level 1: 7 children
// Level 2: 42 leaves
// Total: 50 nodes
const projections: Projection[] = [];
projections.push(proj("root", null, "provisioning"));
for (let i = 0; i < 7; i++) {
projections.push(proj(`l1-${i}`, "root", "online"));
}
for (let i = 0; i < 42; i++) {
const parent = `l1-${Math.floor(i / 6)}`;
projections.push(proj(`l2-${i}`, parent, "online"));
}
const map = m(projections);
// Root is the only deploying node
expect(s(map, "root")).toMatchObject({
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
});
// Every other node is a locked child
for (let i = 0; i < 7; i++) {
expect(s(map, `l1-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
}
for (let i = 0; i < 42; i++) {
expect(s(map, `l2-${i}`)).toMatchObject({ isLockedChild: true, isDeployingRoot: false });
}
});
});
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useCallback, useEffect, useRef } from "react";
import { useReactFlow } from "@xyflow/react";
import { useCanvasStore } from "@/store/canvas";
import { appendClass, removeClass } from "@/store/classNames";
@@ -153,17 +153,10 @@ export function useCanvasViewport() {
// fit, the user has to manually pan + zoom to find what they just
// created. Only fires when TRANSITIONING from some-provisioning to
// zero-provisioning — not on every re-render.
//
// Selecting `nodes` stably (array reference) avoids the
// `.filter().length` anti-pattern which creates a new number on every
// store update and breaks the wasProvisioning/hasProvisioning
// transition detection (React error #185 / Zustand + React 19).
const nodes = useCanvasStore((s) => s.nodes);
const provisioningCount = useMemo(
() => nodes.filter((n) => n.data.status === "provisioning").length,
[nodes],
const provisioningCount = useCanvasStore(
(s) => s.nodes.filter((n) => n.data.status === "provisioning").length,
);
const nodeCount = nodes.length;
const nodeCount = useCanvasStore((s) => s.nodes.length);
useEffect(() => {
const hasProvisioning = provisioningCount > 0;
+97 -352
View File
@@ -5,22 +5,22 @@
// that the desktop ChatTab uses, but with a slimmer surface: no
// attachments, no A2A topology overlay, no conversation tracing.
import { useEffect, useMemo, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
import {
useChatHistory,
useChatSend,
useChatSocket,
} from "@/components/tabs/chat/hooks";
import { toMobileAgent } from "./components";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives";
interface ChatMessage {
id: string;
role: "user" | "agent" | "system";
text: string;
ts: string;
}
const formatStoredTimestamp = (iso: string): string => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
@@ -29,171 +29,16 @@ const formatStoredTimestamp = (iso: string): string => {
type SubTab = "my" | "a2a";
function MarkdownBubble({
children,
dark,
accent,
}: {
children: string;
dark: boolean;
accent: string;
}) {
const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
const linkColor = accent;
const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
p: ({ children }) => (
<div style={{ margin: "2px 0", lineHeight: "inherit" }}>{children}</div>
),
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
style={{ color: linkColor, textDecoration: "underline" }}
>
{children}
</a>
),
pre: ({ children }) => (
<pre
style={{
background: codeBlockBg,
padding: "8px 10px",
borderRadius: 8,
overflow: "auto",
fontSize: 12,
lineHeight: 1.5,
fontFamily: MOBILE_FONT_MONO,
margin: "4px 0",
}}
>
{children}
</pre>
),
code: ({ children, className }) => {
const isBlock = className != null && String(className).length > 0;
if (isBlock) {
return (
<code style={{ fontFamily: MOBILE_FONT_MONO, fontSize: 12 }}>
{children}
</code>
);
}
return (
<code
style={{
background: codeBg,
padding: "1px 4px",
borderRadius: 4,
fontSize: 13,
fontFamily: MOBILE_FONT_MONO,
}}
>
{children}
</code>
);
},
ul: ({ children }) => (
<ul style={{ margin: "4px 0", paddingLeft: 18, listStyle: "disc" }}>
{children}
</ul>
),
ol: ({ children }) => (
<ol style={{ margin: "4px 0", paddingLeft: 18, listStyle: "decimal" }}>
{children}
</ol>
),
li: ({ children }) => <li style={{ margin: "2px 0" }}>{children}</li>,
strong: ({ children }) => (
<strong style={{ fontWeight: 600 }}>{children}</strong>
),
em: ({ children }) => <em style={{ fontStyle: "italic" }}>{children}</em>,
h1: ({ children }) => (
<div style={{ fontSize: 16, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h2: ({ children }) => (
<div style={{ fontSize: 15, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h3: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 700, margin: "4px 0" }}>{children}</div>
),
h4: ({ children }) => (
<div style={{ fontSize: 14, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h5: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
h6: ({ children }) => (
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
),
blockquote: ({ children }) => (
<blockquote
style={{
borderLeft: `2px solid ${quoteBorder}`,
margin: "4px 0",
paddingLeft: 8,
opacity: 0.85,
}}
>
{children}
</blockquote>
),
hr: () => (
<hr
style={{
border: "none",
borderTop: `0.5px solid ${quoteBorder}`,
margin: "6px 0",
}}
/>
),
table: ({ children }) => (
<table
style={{
borderCollapse: "collapse",
fontSize: 13,
margin: "4px 0",
width: "100%",
}}
>
{children}
</table>
),
thead: ({ children }) => <thead style={{ fontWeight: 600 }}>{children}</thead>,
th: ({ children }) => (
<th
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
textAlign: "left",
}}
>
{children}
</th>
),
td: ({ children }) => (
<td
style={{
border: `0.5px solid ${quoteBorder}`,
padding: "4px 6px",
}}
>
{children}
</td>
),
}}
>
{children}
</ReactMarkdown>
);
interface A2AResponseShape {
result?: {
parts?: Array<{ kind?: string; text?: string }>;
};
error?: { message?: string };
}
const formatTime = (date: Date) =>
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
export function MobileChat({
agentId,
dark,
@@ -204,40 +49,34 @@ export function MobileChat({
onBack: () => void;
}) {
const p = usePalette(dark);
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
// Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
// same buffer to keep state coherent across viewports.
// NOTE: selector returns undefined (stable) — do NOT use ?? [] here,
// that creates a new [] reference on every store update when the key is
// absent, causing infinite re-render (React error #185).
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
(storedMessages ?? []).map((m) => ({
id: m.id,
role: "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
);
const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref
// mirrors the desktop ChatTab pattern (sendInFlightRef) and closes the
// double-send race a stale `sending` lets through.
const sendInFlightRef = useRef(false);
const composerRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
const {
messages,
loading: historyLoading,
loadError: historyError,
loadInitial,
appendMessageDeduped,
} = useChatHistory(agentId);
const {
sending,
uploading,
sendMessage,
error: sendError,
clearError,
releaseSendGuards,
} = useChatSend(agentId, {
getHistoryMessages: () => messages,
onUserMessage: appendMessageDeduped,
onAgentMessage: appendMessageDeduped,
});
useChatSocket(agentId, {
onAgentMessage: appendMessageDeduped,
onSendComplete: releaseSendGuards,
});
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
// shrinks when the user deletes text, then size to scrollHeight up to
@@ -256,20 +95,6 @@ export function MobileChat({
}
}, [messages]);
// Consume any agent messages that arrived while history was loading.
const initialConsumeDoneRef = useRef(false);
useEffect(() => {
if (historyLoading || initialConsumeDoneRef.current) return;
initialConsumeDoneRef.current = true;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(agentId);
for (const m of msgs) {
appendMessageDeduped(
createMessage("agent", m.content, m.attachments),
);
}
}, [historyLoading, agentId, appendMessageDeduped]);
if (!node) {
return (
<div
@@ -291,32 +116,58 @@ export function MobileChat({
const a = toMobileAgent(node);
const reachable = a.status === "online" || a.status === "degraded";
const onFilesPicked = (fileList: FileList | null) => {
if (!fileList) return;
const picked = Array.from(fileList);
setPendingFiles((prev) => {
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
});
if (fileInputRef.current) fileInputRef.current.value = "";
};
const removePendingFile = (index: number) =>
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
const send = async () => {
const text = draft.trim();
if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
clearError();
if (!text || sending || !reachable) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
setDraft("");
const files = pendingFiles;
setPendingFiles([]);
await sendMessage(text, files);
setError(null);
setSending(true);
const myMsg: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
text,
ts: formatTime(new Date()),
};
setMessages((m) => [...m, myMsg]);
try {
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts: [{ kind: "text", text }],
},
},
});
const reply =
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
if (reply) {
setMessages((m) => [
...m,
{
id: crypto.randomUUID(),
role: "agent",
text: reply,
ts: formatTime(new Date()),
},
]);
} else if (res.error?.message) {
setError(res.error.message);
}
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to send");
} finally {
setSending(false);
sendInFlightRef.current = false;
}
};
return (
<div
data-testid="chat-panel"
style={{
height: "100%",
display: "flex",
@@ -457,42 +308,7 @@ export function MobileChat({
Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab.
</div>
)}
{tab === "my" && historyLoading && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Loading chat history
</div>
)}
{tab === "my" && !historyLoading && historyError && messages.length === 0 && (
<div
role="alert"
style={{
padding: "14px 4px",
textAlign: "center",
color: p.failed,
fontSize: 13,
}}
>
<div style={{ marginBottom: 8 }}>Could not load chat history.</div>
<button
type="button"
onClick={() => {
loadInitial();
}}
style={{
padding: "6px 14px",
borderRadius: 14,
border: `0.5px solid ${p.failed}`,
background: "transparent",
color: p.failed,
fontSize: 12,
cursor: "pointer",
}}
>
Retry
</button>
</div>
)}
{tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
{tab === "my" && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Send a message to start chatting.
</div>
@@ -521,9 +337,7 @@ export function MobileChat({
overflowWrap: "anywhere",
}}
>
<MarkdownBubble dark={dark} accent={p.accent}>
{m.content}
</MarkdownBubble>
{m.text}
<div
style={{
fontSize: 10,
@@ -532,13 +346,13 @@ export function MobileChat({
fontFamily: MOBILE_FONT_MONO,
}}
>
{formatStoredTimestamp(m.timestamp)}
{m.ts}
</div>
</div>
</div>
);
})}
{sendError && (
{error && (
<div
role="alert"
style={{
@@ -550,7 +364,7 @@ export function MobileChat({
fontSize: 12,
}}
>
{sendError}
{error}
</div>
)}
</div>
@@ -581,60 +395,6 @@ export function MobileChat({
backdropFilter: "blur(14px)",
}}
>
{pendingFiles.length > 0 && (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: 6,
marginBottom: 8,
paddingLeft: 2,
}}
>
{pendingFiles.map((f, i) => (
<div
key={`${f.name}:${f.size}`}
style={{
display: "flex",
alignItems: "center",
gap: 4,
padding: "3px 8px",
borderRadius: 10,
background: dark ? "#2a2823" : "#ece9e0",
fontSize: 12,
color: p.text2,
maxWidth: "100%",
}}
>
<span
style={{
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{f.name}
</span>
<button
type="button"
onClick={() => removePendingFile(i)}
aria-label={`Remove ${f.name}`}
style={{
border: "none",
background: "transparent",
color: p.text3,
cursor: "pointer",
fontSize: 12,
padding: 0,
lineHeight: 1,
}}
>
</button>
</div>
))}
</div>
)}
<div
style={{
display: "flex",
@@ -646,32 +406,21 @@ export function MobileChat({
padding: "6px 6px 6px 12px",
}}
>
<input
ref={fileInputRef}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => onFilesPicked(e.target.files)}
aria-hidden="true"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={!reachable || sending || uploading}
aria-label="Attach"
style={{
width: 32,
height: 32,
borderRadius: 999,
border: "none",
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
cursor: "pointer",
background: "transparent",
color: p.text3,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
opacity: !reachable || sending || uploading ? 0.4 : 1,
}}
>
{Icons.attach({ size: 16 })}
@@ -717,32 +466,28 @@ export function MobileChat({
<button
type="button"
onClick={send}
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
disabled={!draft.trim() || !reachable || sending}
aria-label="Send"
style={{
width: 36,
height: 36,
borderRadius: 999,
border: "none",
cursor: (draft.trim() || pendingFiles.length > 0) && !sending && !uploading ? "pointer" : "not-allowed",
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
flexShrink: 0,
background:
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
draft.trim() && reachable && !sending
? p.accent
: dark
? "#2a2823"
: "#ece9e0",
color: (draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading ? "#fff" : p.text3,
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{uploading ? (
<span style={{ fontSize: 10, fontWeight: 600 }}></span>
) : (
Icons.send({ size: 16 })
)}
{Icons.send({ size: 16 })}
</button>
</div>
</div>
@@ -2,7 +2,7 @@
// 03 · Agent detail — pills + tabbed content (Overview/Activity/Config/Memory).
import { useEffect, useMemo, useState } from "react";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas";
@@ -32,10 +32,7 @@ export function MobileDetail({
onChat: () => void;
}) {
const p = usePalette(dark);
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
// creates a new return value on every store update (React error #185).
const nodes = useCanvasStore((s) => s.nodes);
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
const [tab, setTab] = useState<TabId>("overview");
if (!node) {
@@ -214,7 +211,6 @@ export function MobileDetail({
<button
type="button"
onClick={onChat}
data-testid="mobile-chat-cta"
style={{
width: "100%",
height: 52,
+4 -6
View File
@@ -12,7 +12,6 @@ import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import { type Template } from "@/lib/deploy-preflight";
import { isSaaSTenant } from "@/lib/tenant";
import { tierCode } from "./palette";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette";
@@ -27,7 +26,6 @@ const TIER_LABEL: Record<"T1" | "T2" | "T3" | "T4", string> = {
export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
const p = usePalette(dark);
const isSaaS = isSaaSTenant();
const [templates, setTemplates] = useState<Template[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(true);
const [tplId, setTplId] = useState<string | null>(null);
@@ -45,7 +43,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
setTemplates(list);
if (list.length > 0) {
setTplId(list[0].id);
setTier(isSaaS ? "T4" : tierCode(list[0].tier));
setTier(tierCode(list[0].tier));
}
})
.catch(() => {
@@ -57,7 +55,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
return () => {
cancelled = true;
};
}, [isSaaS]);
}, []);
const handleSpawn = async () => {
if (busy || !tplId) return;
@@ -69,7 +67,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
await api.post<{ id: string }>("/workspaces", {
name: (name.trim() || chosen.name),
template: chosen.id,
tier: isSaaS ? 4 : Number(tier.slice(1)),
tier: Number(tier.slice(1)),
canvas: {
x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100,
@@ -205,7 +203,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
>
{templates.map((t) => {
const on = tplId === t.id;
const tCode = isSaaS ? "T4" : tierCode(t.tier);
const tCode = tierCode(t.tier);
return (
<button
key={t.id}
@@ -8,19 +8,11 @@
* NOTE: No @testing-library/jest-dom — use DOM APIs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, waitFor } from "@testing-library/react";
import { cleanup, render } from "@testing-library/react";
import React from "react";
import { MobileChat } from "../MobileChat";
// ─── Mock API ─────────────────────────────────────────────────────────────────
// vi.mock without a factory auto-mocks the module. In tests, we configure
// api.get / api.post directly (they are vi.fn() from the auto-mock).
// Tests that need specific behaviour use mockResolvedValueOnce on the
// auto-mocked functions.
vi.mock("@/lib/api");
import { api } from "@/lib/api";
// ─── Mock store ───────────────────────────────────────────────────────────────
const mockAgentId = "ws-chat-test";
@@ -36,19 +28,12 @@ const mockStoreState = {
height?: number;
}>,
agentMessages: {} as Record<string, Array<{ id: string; content: string; timestamp: string }>>,
consumeAgentMessages: () => [],
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((sel?: (state: typeof mockStoreState) => unknown) => {
if (sel) return sel(mockStoreState);
return mockStoreState;
}),
{
getState: () => mockStoreState,
subscribe: vi.fn(() => vi.fn()),
},
vi.fn((sel) => sel(mockStoreState)),
{ getState: () => mockStoreState },
),
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
const agentCard = data.agentCard as Record<string, unknown> | null;
@@ -69,6 +54,16 @@ vi.mock("@/store/canvas", () => ({
}),
}));
// ─── Mock API ─────────────────────────────────────────────────────────────────
const { mockApiPost } = vi.hoisted(() => ({
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
}));
vi.mock("@/lib/api", () => ({
api: { post: mockApiPost },
}));
// ─── Fixtures ────────────────────────────────────────────────────────────────
const onlineNode = {
@@ -155,15 +150,7 @@ beforeEach(() => {
mockOnBack.mockClear();
mockStoreState.nodes = [];
mockStoreState.agentMessages = {};
// Set up spies on the real api methods. Tests override these per-call.
const getSpy = vi.spyOn(api, "get");
const postSpy = vi.spyOn(api, "post");
getSpy.mockResolvedValue({ messages: [], reached_end: true });
postSpy.mockResolvedValue({ result: { parts: [] } });
});
afterEach(() => {
vi.restoreAllMocks();
mockApiPost.mockClear();
});
afterEach(() => {
@@ -279,26 +266,15 @@ describe("MobileChat — empty state", () => {
mockStoreState.nodes = [onlineNode];
});
it('shows "Send a message to start chatting." when no messages', async () => {
// History fetch resolves immediately in tests (mockResolvedValue).
// act() flushes the microtask queue so the component reaches its
// post-load state before we assert.
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
it('shows "Send a message to start chatting." when no messages', () => {
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
it("shows no messages when agentMessages[agentId] is absent (undefined)", async () => {
it("shows no messages when agentMessages[agentId] is absent (undefined)", () => {
// Explicitly set to empty to simulate no stored messages
mockStoreState.agentMessages = {};
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
});
@@ -345,132 +321,3 @@ describe("MobileChat — dark mode", () => {
expect(container.querySelector('[aria-label="Back"]')).toBeTruthy();
});
});
// ─── Chat history loading ────────────────────────────────────────────────────
describe("MobileChat — chat history", () => {
beforeEach(() => {
mockStoreState.nodes = [onlineNode];
});
it("calls GET /workspaces/:id/chat-history on mount", async () => {
await act(async () => {
renderChat(mockAgentId);
});
expect(api.get).toHaveBeenCalledWith(
expect.stringContaining(`/workspaces/${mockAgentId}/chat-history`),
);
});
it("shows loading state while history is fetching", () => {
// Do NOT await — check the pre-resolve state.
const { container } = renderChat(mockAgentId);
expect(container.textContent ?? "").toContain("Loading chat history…");
});
it("shows empty state after history resolves with no messages", async () => {
// beforeEach already sets api.get to resolve with empty — no override needed.
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
it("renders messages from history response", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce({
messages: [
{
id: "msg-1",
role: "user",
content: "Hello agent",
timestamp: "2026-04-25T10:00:00Z",
},
{
id: "msg-2",
role: "agent",
content: "Hello back",
timestamp: "2026-04-25T10:00:01Z",
},
],
reached_end: true,
});
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Hello agent");
expect(container.textContent ?? "").toContain("Hello back");
});
it("maps user role from API correctly", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce({
messages: [
{
id: "msg-u",
role: "user",
content: "user message",
timestamp: "2026-04-25T10:00:00Z",
},
],
reached_end: true,
});
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
// User messages render right-aligned. The text content check is sufficient
// to confirm the message appeared.
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("user message");
});
it("shows error state when history fetch fails", async () => {
vi.spyOn(api, "get").mockRejectedValue(new Error("Network error"));
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
expect(container.textContent ?? "").toContain("Could not load chat history.");
expect(container.textContent ?? "").toContain("Retry");
});
it("Retry button re-fetches history after error", async () => {
// Make the initial mount call fail so the Retry button appears, then
// make the retry call succeed so we can verify the full flow.
const getSpy = vi.spyOn(api, "get");
getSpy
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce({ messages: [], reached_end: true });
let renderResult: ReturnType<typeof renderChat>;
await act(async () => {
renderResult = renderChat(mockAgentId);
});
const { container } = renderResult!;
// Error state should be shown with Retry button.
expect(container.textContent ?? "").toContain("Could not load chat history.");
expect(container.textContent ?? "").toContain("Retry");
// Click Retry — the button's onClick fires api.get again.
// The second mockResolvedValueOnce makes it succeed.
const retryBtn = Array.from(container.querySelectorAll("button")).find(
(b) => b.textContent?.trim() === "Retry",
);
expect(retryBtn).toBeTruthy();
await act(async () => {
retryBtn?.click();
});
// waitFor polls until the retry resolves and component re-renders.
await waitFor(() => {
expect(container.textContent ?? "").toContain("Send a message to start chatting.");
});
// Initial call + retry = 2.
expect(getSpy).toHaveBeenCalledTimes(2);
});
});
@@ -288,7 +288,6 @@ export function AgentCard({
return (
<button
type="button"
data-testid="workspace-card"
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
onClick={onClick}
style={{
+3 -3
View File
@@ -307,7 +307,7 @@ function ActivityRow({
{/* Error detail */}
{isError && entry.error_detail && (
<div className="text-[9px] text-bad mt-1 truncate">
<div className="text-[9px] text-bad/80 mt-1 truncate">
{entry.error_detail}
</div>
)}
@@ -358,10 +358,10 @@ function A2AErrorPreview({ label, raw }: { label: string; raw: string }) {
const hint = inferA2AErrorHint(detail);
return (
<div>
<div className="text-[8px] text-bad uppercase tracking-wider mb-1">{label} delivery failed</div>
<div className="text-[8px] text-bad/80 uppercase tracking-wider mb-1">{label} delivery failed</div>
<div className="text-[10px] text-bad bg-red-950/30 border border-red-800/40 rounded p-2 space-y-1.5">
<div className="font-mono whitespace-pre-wrap break-words max-h-32 overflow-y-auto">{detail}</div>
<div className="text-[9px] text-bad leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
<div className="text-[9px] text-bad/70 leading-relaxed border-t border-red-800/30 pt-1.5">{hint}</div>
</div>
</div>
);
+1 -1
View File
@@ -243,7 +243,7 @@ export function BudgetSection({ workspaceId }: Props) {
onClick={handleSave}
disabled={saving}
data-testid="budget-save-btn"
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium 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-zinc-900"
className="px-4 py-1.5 bg-accent-strong hover:bg-accent active:bg-accent-strong rounded-lg text-xs font-medium text-white disabled:opacity-50 transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
+2 -2
View File
@@ -255,7 +255,7 @@ export function ChannelsTab({ workspaceId }: Props) {
</h3>
<button
onClick={() => setShowForm(!showForm)}
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[10px] px-2.5 py-1 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition"
>
{showForm ? "Cancel" : "+ Connect"}
</button>
@@ -308,7 +308,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<button
onClick={handleDiscover}
disabled={discovering || !formValues["bot_token"]}
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[10px] px-2 py-0.5 rounded bg-accent-strong/20 text-accent hover:bg-accent-strong/30 transition disabled:opacity-40"
>
{discovering ? "Detecting..." : "Detect Chats"}
</button>
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -176,7 +176,7 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
// exactly the point of the platform adaptor. The deep `~/.hermes/
// config.yaml` on the container is a separate runtime-internal file,
// not this one.
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli", "openclaw"]);
const RUNTIMES_WITH_OWN_CONFIG = new Set<string>(["external", "kimi", "kimi-cli"]);
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
{ value: "", label: "LangGraph (default)", models: [], providers: [] },
+4 -4
View File
@@ -325,10 +325,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
<button
type="button"
onClick={handleDelete}
// Red-600 on white text = 3.9:1 (WCAG AA FAIL).
// Red-700 = 4.6:1 (PASS). Hover goes DARKER (red-600)
// to signal press. Same pattern as ConfirmDialog/DeleteCascade.
className="px-3 py-1 bg-red-700 hover:bg-red-600 text-xs 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"
// hover:bg-red-500 LIGHTER on white text drops AA;
// flipped to bg-red-700 + focus-visible danger ring,
// matching the ConfirmDialog/DeleteCascade pattern.
className="px-3 py-1 bg-red-600 hover:bg-red-700 text-xs 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"
>
Confirm Delete
</button>
@@ -131,7 +131,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
<button
type="button"
onClick={doRotate}
className="px-3 py-1.5 bg-red-800 hover:bg-red-700 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
className="px-3 py-1.5 bg-red-700 hover:bg-red-600 text-xs rounded text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-1"
>
Rotate
</button>
+2 -2
View File
@@ -226,7 +226,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
<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">
<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>
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-600 hover:bg-red-700 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>
<button type="button" onClick={() => setShowDeleteAll(false)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
</div>
</div>
@@ -240,7 +240,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
<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">
<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>
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-600 hover:bg-red-700 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>
<button type="button" onClick={() => setConfirmDelete(null)} className="px-2 py-0.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-[10px] rounded text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Cancel</button>
</div>
</div>
@@ -32,7 +32,7 @@ export function FilesToolbar({
value={root}
onChange={(e) => setRoot(e.target.value)}
aria-label="File root directory"
className="text-[10px] bg-surface-card text-ink-mid border border-line rounded px-1.5 py-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] bg-surface-card text-ink-mid border border-line rounded px-1.5 py-0.5 outline-none"
>
<option value="/configs">/configs</option>
<option value="/home">/home</option>
@@ -1,181 +1,217 @@
// @vitest-environment jsdom
/**
* Tests for the main FilesTab / PlatformOwnedFilesTab component.
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
*
* Covers: NotAvailablePanel (external runtime), loading/empty/error states,
* FilesToolbar actions, and the /configs-only upload guard.
* NotAvailablePanel: pure presentational component — renders a "feature not
* available" placeholder for external-runtime workspaces.
* FilesToolbar: pure props-driven component — directory selector, file count,
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
*
* No @testing-library/jest-dom — use textContent / className / getAttribute.
* No @testing-library/jest-dom import — use textContent / className /
* getAttribute checks to avoid "expect is not defined" errors.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { cleanup, render, screen } from "@testing-library/react";
import React from "react";
import { FilesTab } from "../../FilesTab.tsx";
import { FilesToolbar } from "../FilesToolbar.tsx";
import type { FileEntry } from "../../FilesTab/tree";
import { FilesToolbar } from "../FilesToolbar";
import { NotAvailablePanel } from "../NotAvailablePanel";
// ─── Mock ──────────────────────────────────────────────────────────────────
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: { get: _mockGet, put: vi.fn(), del: vi.fn() },
}));
// ─── afterEach ─────────────────────────────────────────────────────────────────
afterEach(() => {
cleanup();
_mockGet.mockReset();
vi.restoreAllMocks();
});
// ─── Helpers ───────────────────────────────────────────────────────────────
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
const emptyFileList: FileEntry[] = [];
describe("NotAvailablePanel", () => {
it("renders heading 'Files not available'", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("Files not available");
});
/** Render FilesTab with a non-external runtime (triggers PlatformOwnedFilesTab). */
function renderPlatformTab(extraProps: Partial<React.ComponentProps<typeof FilesTab>> = {}) {
return render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "claude-code", status: "online", tier: 0, skills: [], created_at: "" }}
{...extraProps}
/>,
);
}
it("renders the runtime name in monospace", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
expect(container.textContent).toContain("external");
const spans = container.querySelectorAll("span");
const monoSpans = Array.from(spans).filter(
(s) => s.className && s.className.includes("font-mono"),
);
expect(monoSpans.length).toBeGreaterThan(0);
});
/** Render FilesToolbar directly with stub handlers. */
function renderToolbar(extraProps: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
return render(
<FilesToolbar
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
{...extraProps}
/>
);
}
it("renders a Chat tab hint in description", () => {
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
expect(container.textContent).toContain("Chat tab");
});
// ─── NotAvailablePanel ──────────────────────────────────────────────────────
it("SVG icon has aria-hidden=true", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
describe("FilesTab — NotAvailablePanel", () => {
it("renders NotAvailablePanel when runtime is external", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
it("renders without crashing for any runtime string", () => {
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
expect(container.textContent).toContain("unknown-runtime");
});
it("applies the correct layout classes to root div", () => {
const { container } = render(<NotAvailablePanel runtime="external" />);
const root = container.firstElementChild as HTMLElement;
expect(root.className).toContain("flex");
expect(root.className).toContain("flex-col");
expect(root.className).toContain("items-center");
});
});
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
describe("FilesToolbar", () => {
const noop = vi.fn();
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
return render(
<FilesToolbar
root="/configs"
setRoot={noop}
fileCount={0}
onNewFile={noop}
onUpload={noop}
onDownloadAll={noop}
onClearAll={noop}
onRefresh={noop}
{...props}
/>,
);
expect(screen.getByText(/Files not available/i)).toBeTruthy();
}
it("renders the directory selector with correct aria-label", () => {
const { container } = renderToolbar();
const select = container.querySelector("select");
expect(select?.getAttribute("aria-label")).toBe("File root directory");
});
it("renders the runtime name in NotAvailablePanel", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
/>,
it("directory selector has all four options", () => {
const { container } = renderToolbar();
const select = container.querySelector("select") as HTMLSelectElement;
const options = Array.from(select?.options ?? []);
const values = options.map((o) => o.value);
expect(values).toContain("/configs");
expect(values).toContain("/home");
expect(values).toContain("/workspace");
expect(values).toContain("/plugins");
});
it("calls setRoot when directory changes", () => {
const setRoot = vi.fn();
const { container } = renderToolbar({ setRoot });
const select = container.querySelector("select") as HTMLSelectElement;
select.value = "/home";
select.dispatchEvent(new Event("change", { bubbles: true }));
expect(setRoot).toHaveBeenCalledWith("/home");
});
it("displays the file count", () => {
const { container } = renderToolbar({ fileCount: 42 });
expect(container.textContent).toContain("42 files");
});
it("shows New + Upload + Clear buttons for /configs", () => {
const { container } = renderToolbar({ root: "/configs" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(screen.getByText(/external/i)).toBeTruthy();
expect(texts).toContain("+ New");
expect(texts).toContain("Upload");
expect(texts).toContain("Clear");
expect(texts).toContain("Export");
expect(texts).toContain("↻");
});
it("does NOT call api.get when runtime is external", async () => {
render(
<FilesTab
workspaceId="ws-1"
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
/>,
it("hides New + Upload + Clear for /workspace", () => {
const { container } = renderToolbar({ root: "/workspace" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(_mockGet).not.toHaveBeenCalled();
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
expect(texts).toContain("Export");
});
});
// ─── Loading / Empty / Error states ────────────────────────────────────────
describe("FilesTab — states", () => {
it("shows loading text while fetching files", () => {
_mockGet.mockImplementation(
() => new Promise<unknown>(() => {}) as unknown as Promise<unknown>,
it("hides New + Upload + Clear for /home", () => {
const { container } = renderToolbar({ root: "/home" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
renderPlatformTab();
expect(screen.getByText("Loading files...")).toBeTruthy();
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
});
it("shows 'No config files yet' when root is /configs and no files", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByText(/No config files yet/i)).toBeTruthy();
});
it("hides New + Upload + Clear for /plugins", () => {
const { container } = renderToolbar({ root: "/plugins" });
const texts = Array.from(container.querySelectorAll("button")).map(
(b) => b.textContent?.trim(),
);
expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
});
it("fetches from the correct endpoint", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(_mockGet).toHaveBeenCalledWith(expect.stringContaining("/workspaces/ws-1/files"));
});
it("New button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const newBtn = container.querySelector('button[aria-label="Create new file"]');
expect(newBtn?.textContent?.trim()).toBe("+ New");
});
it("shows file count from toolbar when files exist", async () => {
_mockGet.mockResolvedValue([
{ path: "configs/a.yaml", size: 10, dir: false },
{ path: "configs/b.yaml", size: 20, dir: false },
]);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByText("2 files")).toBeTruthy();
});
});
});
// ─── FilesToolbar ──────────────────────────────────────────────────────────
describe("FilesTab — FilesToolbar", () => {
it("shows Refresh button", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByLabelText("Refresh file list")).toBeTruthy();
});
it("Export button has correct aria-label", () => {
const { container } = renderToolbar();
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
expect(exportBtn?.textContent?.trim()).toBe("Export");
});
it("shows root directory selector", async () => {
_mockGet.mockResolvedValueOnce(emptyFileList);
renderPlatformTab();
await waitFor(() => {
expect(screen.getByRole("combobox")).toBeTruthy();
});
it("Clear button has correct aria-label", () => {
const { container } = renderToolbar({ root: "/configs" });
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
expect(clearBtn?.textContent?.trim()).toBe("Clear");
});
it("Refresh button triggers a reload", async () => {
// Use persistent mock — loadFiles fires on mount AND on Refresh click.
_mockGet.mockResolvedValue(emptyFileList);
renderPlatformTab();
await waitFor(() => screen.getByLabelText("Refresh file list"));
const before = _mockGet.mock.calls.length;
fireEvent.click(screen.getByLabelText("Refresh file list"));
await waitFor(() => {
expect(_mockGet.mock.calls.length).toBeGreaterThan(before);
});
it("Refresh button has correct aria-label", () => {
const { container } = renderToolbar();
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
expect(refreshBtn?.textContent?.trim()).toBe("↻");
});
});
// ─── Upload guard ──────────────────────────────────────────────────────────
it("calls onNewFile when New button is clicked", () => {
const onNewFile = vi.fn();
const { container } = renderToolbar({ root: "/configs", onNewFile });
container.querySelector('button[aria-label="Create new file"]')!.click();
expect(onNewFile).toHaveBeenCalledTimes(1);
});
describe("FilesTab — upload guard", () => {
it("no error alert on dragover when root is /configs (default)", async () => {
_mockGet.mockResolvedValue(emptyFileList);
renderPlatformTab();
await waitFor(() => screen.getByText(/No config files yet/i));
it("calls onDownloadAll when Export button is clicked", () => {
const onDownloadAll = vi.fn();
const { container } = renderToolbar({ onDownloadAll });
container.querySelector('button[aria-label="Download all files"]')!.click();
expect(onDownloadAll).toHaveBeenCalledTimes(1);
});
// No alert should be present
expect(screen.queryByRole("alert")).toBeNull();
it("calls onClearAll when Clear button is clicked", () => {
const onClearAll = vi.fn();
const { container } = renderToolbar({ root: "/configs", onClearAll });
container.querySelector('button[aria-label="Delete all files"]')!.click();
expect(onClearAll).toHaveBeenCalledTimes(1);
});
it("calls onRefresh when Refresh button is clicked", () => {
const onRefresh = vi.fn();
const { container } = renderToolbar({ onRefresh });
container.querySelector('button[aria-label="Refresh file list"]')!.click();
expect(onRefresh).toHaveBeenCalledTimes(1);
});
it("applies focus-visible ring to all interactive buttons", () => {
@@ -1,218 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for tree.ts — buildTree and getIcon pure functions.
*/
import { describe, expect, it } from "vitest";
import type { FileEntry } from "../tree";
import { buildTree, getIcon } from "../tree";
// ─── getIcon ─────────────────────────────────────────────────────────────────
describe("getIcon", () => {
it("returns folder emoji for directories", () => {
expect(getIcon("/configs", true)).toBe("📁");
});
it("returns correct emoji for .md", () => {
expect(getIcon("readme.md", false)).toBe("📄");
});
it("returns correct emoji for .yaml", () => {
expect(getIcon("config.yaml", false)).toBe("⚙");
});
it("returns correct emoji for .yml", () => {
expect(getIcon("config.yml", false)).toBe("⚙");
});
it("returns correct emoji for .py", () => {
expect(getIcon("script.py", false)).toBe("🐍");
});
it("returns correct emoji for .ts", () => {
expect(getIcon("index.ts", false)).toBe("💠");
});
it("returns correct emoji for .tsx", () => {
expect(getIcon("App.tsx", false)).toBe("💠");
});
it("returns correct emoji for .js", () => {
expect(getIcon("index.js", false)).toBe("📜");
});
it("returns correct emoji for .json", () => {
expect(getIcon("package.json", false)).toBe("{}");
});
it("returns correct emoji for .html", () => {
expect(getIcon("index.html", false)).toBe("🌐");
});
it("returns correct emoji for .css", () => {
expect(getIcon("style.css", false)).toBe("🎨");
});
it("returns correct emoji for .sh", () => {
expect(getIcon("deploy.sh", false)).toBe("▸");
});
it("returns default file emoji for unknown extensions", () => {
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
expect(getIcon("Rakefile", false)).toBe("📄");
});
it("extension matching is case-insensitive", () => {
expect(getIcon("readme.MD", false)).toBe("📄");
expect(getIcon("script.PY", false)).toBe("🐍");
});
});
// ─── buildTree ───────────────────────────────────────────────────────────────
describe("buildTree", () => {
it("returns empty array for empty input", () => {
expect(buildTree([])).toEqual([]);
});
it("adds a single file at root", () => {
const files: FileEntry[] = [{ path: "config.yaml", size: 128, dir: false }];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
name: "config.yaml",
path: "config.yaml",
isDir: false,
children: [],
size: 128,
});
});
it("adds a single directory at root", () => {
const files: FileEntry[] = [{ path: "skills", size: 0, dir: true }];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0]).toMatchObject({
name: "skills",
path: "skills",
isDir: true,
children: [],
size: 0,
});
});
it("sorts dirs before files at the same level", () => {
const files: FileEntry[] = [
{ path: "b.txt", size: 10, dir: false },
{ path: "a.txt", size: 10, dir: false },
{ path: "z-dir", size: 0, dir: true },
{ path: "a-dir", size: 0, dir: true },
];
const tree = buildTree(files);
expect(tree).toHaveLength(4);
// Dirs first: z-dir, a-dir alphabetically → a before z
expect(tree[0].name).toBe("a-dir");
expect(tree[1].name).toBe("z-dir");
// Then files alphabetically
expect(tree[2].name).toBe("a.txt");
expect(tree[3].name).toBe("b.txt");
});
it("alphabetically sorts files within the same level", () => {
const files: FileEntry[] = [
{ path: "z.yaml", size: 10, dir: false },
{ path: "a.yaml", size: 10, dir: false },
{ path: "m.yaml", size: 10, dir: false },
];
const tree = buildTree(files);
expect(tree.map((n) => n.name)).toEqual(["a.yaml", "m.yaml", "z.yaml"]);
});
it("nests a file under its parent directory", () => {
const files: FileEntry[] = [
{ path: "skills", size: 0, dir: true },
{ path: "skills/readme.md", size: 64, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("skills");
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0]).toMatchObject({
name: "readme.md",
path: "skills/readme.md",
isDir: false,
size: 64,
});
});
it("creates intermediate directories automatically", () => {
const files: FileEntry[] = [
{ path: "a/b/c/deep.txt", size: 32, dir: false },
];
const tree = buildTree(files);
// Root has one child: "a"
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("a");
expect(tree[0].isDir).toBe(true);
// "a" has one child: "b"
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].name).toBe("b");
// "b" has one child: "c"
expect(tree[0].children[0].children).toHaveLength(1);
expect(tree[0].children[0].children[0].name).toBe("c");
// "c" has the file
expect(tree[0].children[0].children[0].children[0].name).toBe("deep.txt");
expect(tree[0].children[0].children[0].children[0].size).toBe(32);
});
it("adds multiple files to the same directory", () => {
const files: FileEntry[] = [
{ path: "configs", size: 0, dir: true },
{ path: "configs/a.yaml", size: 10, dir: false },
{ path: "configs/b.yaml", size: 20, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1);
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["a.yaml", "b.yaml"]);
});
it("does not duplicate a directory already created as intermediate", () => {
const files: FileEntry[] = [
{ path: "a/b.txt", size: 5, dir: false },
{ path: "a", size: 0, dir: true },
];
const tree = buildTree(files);
// "a" should appear only once
expect(tree).toHaveLength(1);
expect(tree[0].name).toBe("a");
// The dir "a" should still contain "b.txt"
expect(tree[0].children).toHaveLength(1);
expect(tree[0].children[0].name).toBe("b.txt");
});
it("intermediate dirs have size 0", () => {
const files: FileEntry[] = [
{ path: "a/b/c/file.txt", size: 1, dir: false },
];
const tree = buildTree(files);
expect(tree[0].size).toBe(0);
expect(tree[0].children[0].size).toBe(0);
});
it("handles deeply nested mixed dirs and files", () => {
const files: FileEntry[] = [
{ path: "a", size: 0, dir: true },
{ path: "a/b", size: 0, dir: true },
{ path: "a/b/c", size: 0, dir: true },
{ path: "a/b/c/d.txt", size: 1, dir: false },
{ path: "a/b/e.txt", size: 2, dir: false },
{ path: "a/f.txt", size: 3, dir: false },
];
const tree = buildTree(files);
expect(tree).toHaveLength(1); // root: "a"
expect(tree[0].children.map((n) => n.name).sort()).toEqual(["b", "f.txt"]);
expect(tree[0].children.find((n) => n.name === "b")!.children.map((n) => n.name).sort())
.toEqual(["c", "e.txt"]);
});
});
+6 -13
View File
@@ -194,7 +194,7 @@ export function ScheduleTab({ workspaceId }: Props) {
</span>
<button
onClick={() => { resetForm(); setShowForm(true); }}
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[11px] px-2 py-0.5 bg-accent-strong/20 text-accent rounded hover:bg-accent-strong/30 transition-colors"
>
+ Add Schedule
</button>
@@ -332,14 +332,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<div className="flex items-center gap-1.5">
<button
onClick={() => handleToggle(sched)}
aria-label={
sched.last_status === "error"
? "Last run failed — click to disable"
: sched.last_status === "ok"
? "Last run OK — click to disable"
: "Never run — click to enable"
}
className={`w-2 h-2 rounded-full flex-shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900 ${
className={`w-2 h-2 rounded-full flex-shrink-0 ${
sched.last_status === "error"
? "bg-red-400"
: sched.last_status === "ok"
@@ -367,7 +360,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<span>Runs: {sched.run_count}</span>
</div>
{sched.last_error && (
<div className="text-[8px] text-bad mt-0.5 truncate">
<div className="text-[8px] text-bad/70 mt-0.5 truncate">
Error: {sched.last_error}
</div>
)}
@@ -376,7 +369,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => handleRunNow(sched)}
aria-label={`Run schedule ${sched.name} now`}
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[11px] px-1.5 py-0.5 text-accent hover:bg-accent-strong/20 rounded transition-colors"
title="Run now"
>
@@ -384,7 +377,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => handleEdit(sched)}
aria-label={`Edit schedule ${sched.name}`}
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[11px] px-1.5 py-0.5 text-ink-mid hover:bg-surface-card rounded transition-colors"
title="Edit"
>
@@ -392,7 +385,7 @@ export function ScheduleTab({ workspaceId }: Props) {
<button
onClick={() => setPendingDelete({ id: sched.id, name: sched.name })}
aria-label={`Delete schedule ${sched.name}`}
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-900"
className="text-[11px] px-1.5 py-0.5 text-bad hover:bg-red-600/20 rounded transition-colors"
title="Delete"
>
+1 -1
View File
@@ -492,7 +492,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
<div className="text-[10px] text-bad font-semibold mb-0.5">
Couldn't load the plugin registry
</div>
<div className="text-[10px] text-bad">{registryError}</div>
<div className="text-[10px] text-bad/80">{registryError}</div>
<div className="mt-1 text-[10px] text-ink-mid">
Check the platform server is reachable at /plugins. The Retry button is in the header above.
</div>
@@ -1,3 +0,0 @@
export { useChatHistory } from "./useChatHistory";
export { useChatSend } from "./useChatSend";
export { useChatSocket } from "./useChatSocket";
@@ -1,11 +0,0 @@
"use client";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
/** Resolve a workspace ID to its human-readable name.
* Falls back to the first 8 chars of the ID. */
export function resolveWorkspaceName(id: string): string {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}
@@ -1,134 +0,0 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "@/lib/api";
import { type ChatMessage, appendMessageDeduped as appendMessageDedupedFn } from "../types";
const INITIAL_HISTORY_LIMIT = 10;
const OLDER_HISTORY_BATCH = 20;
async function loadMessagesFromDB(
workspaceId: string,
limit: number,
beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try {
const params = new URLSearchParams({ limit: String(limit) });
if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`,
);
return {
messages: resp.messages ?? [],
error: null,
reachedEnd: resp.reached_end,
};
} catch (err) {
return {
messages: [],
error: err instanceof Error ? err.message : "Failed to load chat history",
reachedEnd: true,
};
}
}
export interface ScrollAnchor {
savedDistanceFromBottom: number;
expectFirstIdNotEqual: string | null;
}
export function useChatHistory(
workspaceId: string,
containerRef?: React.RefObject<HTMLDivElement | null>,
) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
const fetchTokenRef = useRef(0);
const oldestMessageRef = useRef<ChatMessage | null>(null);
const hasMoreRef = useRef(true);
const inflightRef = useRef(false);
const scrollAnchorRef = useRef<ScrollAnchor | null>(null);
useEffect(() => {
oldestMessageRef.current = messages[0] ?? null;
}, [messages]);
useEffect(() => {
hasMoreRef.current = hasMore;
}, [hasMore]);
const loadInitial = useCallback(() => {
setLoading(true);
setLoadError(null);
setHasMore(true);
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
return loadMessagesFromDB(workspaceId, INITIAL_HISTORY_LIMIT).then(
({ messages: msgs, error: fetchErr, reachedEnd }) => {
if (fetchTokenRef.current !== myToken) return;
setMessages(msgs);
setLoadError(fetchErr);
setHasMore(!reachedEnd);
setLoading(false);
},
);
}, [workspaceId]);
useEffect(() => {
loadInitial();
}, [loadInitial]);
const loadOlder = useCallback(async () => {
if (inflightRef.current || !hasMoreRef.current) return;
const oldest = oldestMessageRef.current;
if (!oldest) return;
const container = containerRef?.current;
if (!container) return;
inflightRef.current = true;
scrollAnchorRef.current = {
savedDistanceFromBottom: container.scrollHeight - container.scrollTop,
expectFirstIdNotEqual: oldest.id,
};
fetchTokenRef.current += 1;
const myToken = fetchTokenRef.current;
setLoadingOlder(true);
try {
const { messages: older, reachedEnd } = await loadMessagesFromDB(
workspaceId,
OLDER_HISTORY_BATCH,
oldest.timestamp,
);
if (fetchTokenRef.current !== myToken) {
scrollAnchorRef.current = null;
return;
}
if (older.length > 0) {
setMessages((prev) => [...older, ...prev]);
} else {
scrollAnchorRef.current = null;
}
setHasMore(!reachedEnd);
} finally {
setLoadingOlder(false);
inflightRef.current = false;
}
}, [workspaceId, containerRef]);
return {
messages,
loading,
loadError,
loadingOlder,
hasMore,
loadInitial,
loadOlder,
appendMessageDeduped: (msg: ChatMessage) =>
setMessages((prev) => appendMessageDedupedFn(prev, msg)),
setMessages,
scrollAnchorRef,
};
}
@@ -1,182 +0,0 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { api } from "@/lib/api";
import { uploadChatFiles } from "../uploads";
import { createMessage, type ChatMessage, type ChatAttachment } from "../types";
import { extractFilesFromTask } from "../message-parser";
interface A2APart {
kind: string;
text?: string;
file?: {
name?: string;
mimeType?: string;
uri?: string;
size?: number;
};
}
interface A2AResponse {
result?: {
parts?: A2APart[];
artifacts?: Array<{ parts: A2APart[] }>;
};
}
export function extractReplyText(resp: A2AResponse): string {
const collect = (parts: A2APart[] | undefined): string => {
if (!parts) return "";
return parts
.filter((p) => p.kind === "text")
.map((p) => p.text ?? "")
.filter(Boolean)
.join("\n");
};
const result = resp?.result;
const collected: string[] = [];
const fromParts = collect(result?.parts);
if (fromParts) collected.push(fromParts);
if (result?.artifacts) {
for (const a of result.artifacts) {
const t = collect(a.parts);
if (t) collected.push(t);
}
}
return collected.join("\n");
}
export interface UseChatSendOptions {
getHistoryMessages: () => ChatMessage[];
onUserMessage?: (msg: ChatMessage) => void;
onAgentMessage?: (msg: ChatMessage) => void;
}
export function useChatSend(workspaceId: string, options: UseChatSendOptions) {
const [sending, setSending] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const sendInFlightRef = useRef(false);
const sendingFromAPIRef = useRef(false);
const sendTokenRef = useRef(0);
const optionsRef = useRef(options);
optionsRef.current = options;
const releaseSendGuards = useCallback(() => {
setSending(false);
sendingFromAPIRef.current = false;
sendInFlightRef.current = false;
}, []);
const clearError = useCallback(() => setError(null), []);
const sendMessage = useCallback(
async (text: string, files: File[] = []) => {
const trimmed = text.trim();
if ((!trimmed && files.length === 0) || sending || uploading) return;
if (sendInFlightRef.current) return;
sendInFlightRef.current = true;
let uploaded: ChatAttachment[] = [];
if (files.length > 0) {
setUploading(true);
try {
uploaded = await uploadChatFiles(workspaceId, files);
} catch (e) {
setUploading(false);
sendInFlightRef.current = false;
setError(
e instanceof Error ? `Upload failed: ${e.message}` : "Upload failed",
);
return;
}
setUploading(false);
}
const userMsg = createMessage("user", trimmed, uploaded);
optionsRef.current.onUserMessage?.(userMsg);
setSending(true);
sendingFromAPIRef.current = true;
setError(null);
const myToken = ++sendTokenRef.current;
const history = optionsRef.current
.getHistoryMessages()
.filter((m) => m.role === "user" || m.role === "agent")
.slice(-20)
.map((m) => ({
role: m.role === "user" ? "user" : "agent",
parts: [{ kind: "text", text: m.content }],
}));
const parts: A2APart[] = [];
if (trimmed) parts.push({ kind: "text", text: trimmed });
for (const att of uploaded) {
parts.push({
kind: "file",
file: {
name: att.name,
mimeType: att.mimeType,
uri: att.uri,
size: att.size,
},
});
}
api
.post<A2AResponse>(
`/workspaces/${workspaceId}/a2a`,
{
method: "message/send",
params: {
message: {
role: "user",
messageId: crypto.randomUUID(),
parts,
},
metadata: { history },
},
},
{ timeoutMs: 120_000 },
)
.then((resp) => {
if (sendTokenRef.current !== myToken) return;
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
const replyText = extractReplyText(resp);
const replyFiles = extractFilesFromTask(
(resp?.result ?? {}) as Record<string, unknown>,
);
if (replyText || replyFiles.length > 0) {
optionsRef.current.onAgentMessage?.(
createMessage("agent", replyText, replyFiles),
);
}
releaseSendGuards();
})
.catch(() => {
if (sendTokenRef.current !== myToken) return;
if (!sendingFromAPIRef.current) {
sendInFlightRef.current = false;
return;
}
releaseSendGuards();
setError("Failed to send message — agent may be unreachable");
});
},
[workspaceId, sending, uploading],
);
return {
sending,
uploading,
sendMessage,
error,
clearError,
releaseSendGuards,
sendingFromAPIRef,
};
}
@@ -1,100 +0,0 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { useSocketEvent } from "@/hooks/useSocketEvent";
import { createMessage, type ChatMessage } from "../types";
export interface UseChatSocketCallbacks {
onAgentMessage?: (msg: ChatMessage) => void;
onActivityLog?: (entry: string) => void;
onSendComplete?: () => void;
onSendError?: (error: string) => void;
}
export function useChatSocket(
workspaceId: string,
callbacks: UseChatSocketCallbacks,
): void {
const callbacksRef = useRef(callbacks);
callbacksRef.current = callbacks;
// Agent push messages from global store
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[workspaceId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(workspaceId);
for (const m of msgs) {
callbacksRef.current.onAgentMessage?.(
createMessage("agent", m.content, m.attachments),
);
}
if (msgs.length > 0) {
callbacksRef.current.onSendComplete?.();
}
}, [pendingAgentMsgs, workspaceId]);
const resolveWorkspaceName = useCallback((id: string) => {
const nodes = useCanvasStore.getState().nodes;
const node = nodes.find((n) => n.id === id);
return (node?.data as WorkspaceNodeData)?.name || id.slice(0, 8);
}, []);
useSocketEvent((msg) => {
try {
if (msg.event === "ACTIVITY_LOGGED") {
if (msg.workspace_id !== workspaceId) return;
const p = msg.payload || {};
const type = p.activity_type as string;
const method = (p.method as string) || "";
const status = (p.status as string) || "";
const targetId = (p.target_id as string) || "";
const durationMs = p.duration_ms as number | undefined;
const summary = (p.summary as string) || "";
let line = "";
if (type === "a2a_receive" && method === "message/send") {
const targetName = resolveWorkspaceName(targetId || msg.workspace_id);
if (status === "ok" && durationMs) {
const sec = Math.round(durationMs / 1000);
line = `${targetName} responded (${sec}s)`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) callbacksRef.current.onSendComplete?.();
} else if (status === "error") {
line = `${targetName} error`;
const own = (targetId || msg.workspace_id) === workspaceId;
if (own) {
callbacksRef.current.onSendComplete?.();
callbacksRef.current.onSendError?.(
"Agent error (Exception) — see workspace logs for details.",
);
}
}
} else if (type === "a2a_send") {
const targetName = resolveWorkspaceName(targetId);
line = `→ Delegating to ${targetName}...`;
} else if (type === "task_update") {
if (summary) line = `${summary}`;
} else if (type === "agent_log") {
if (summary) line = summary;
}
if (line) {
callbacksRef.current.onActivityLog?.(line);
}
} else if (
msg.event === "TASK_UPDATED" &&
msg.workspace_id === workspaceId
) {
const task = (msg.payload?.current_task as string) || "";
if (task) {
callbacksRef.current.onActivityLog?.(`${task}`);
}
}
} catch {
/* ignore */
}
});
}
-3
View File
@@ -1,5 +1,2 @@
export { type ChatMessage, createMessage, appendMessageDeduped } from "./types";
export { extractAgentText, extractTextsFromParts, extractResponseText } from "./message-parser";
export { useChatHistory } from "./hooks/useChatHistory";
export { useChatSend } from "./hooks/useChatSend";
export { useChatSocket } from "./hooks/useChatSocket";
+1 -2
View File
@@ -8,7 +8,6 @@ import {
type PreflightResult,
type Template,
} from "@/lib/deploy-preflight";
import { isSaaSTenant } from "@/lib/tenant";
import { MissingKeysModal } from "@/components/MissingKeysModal";
/**
@@ -106,7 +105,7 @@ export function useTemplateDeploy(
const ws = await api.post<{ id: string }>("/workspaces", {
name: template.name,
template: template.id,
tier: isSaaSTenant() ? 4 : template.tier,
tier: template.tier,
canvas: coords,
...(model ? { model } : {}),
});
-189
View File
@@ -1,189 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for hydrate.ts — canvas store hydration with exponential backoff.
*
* Covers:
* - Successful hydration on first attempt (no retries)
* - Retry with exponential backoff on failure
* - onRetrying callback called at correct intervals
* - Error propagation after MAX_RETRIES exhausted
* - Viewport persisted on success
* - Viewport failure is non-fatal
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { WorkspaceData } from "@/store/socket";
// ---------------------------------------------------------------------------
// Mock modules — must precede imports that use them
// ---------------------------------------------------------------------------
const mockHydrate = vi.fn();
const mockSetViewport = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(),
},
PLATFORM_URL: "https://platform.test",
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
() => ({}),
{
getState: () => ({
hydrate: mockHydrate,
setViewport: mockSetViewport,
}),
},
),
}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import { api } from "@/lib/api";
import { hydrateCanvas, MAX_RETRIES } from "../hydrate";
// ---------------------------------------------------------------------------
// Mock data
// ---------------------------------------------------------------------------
const WORKSPACES: WorkspaceData[] = [
{ id: "ws-1", name: "Test Workspace" } as WorkspaceData,
];
const VIEWPORT = { x: 10, y: 20, zoom: 1.5 };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const mockApiGet = vi.mocked(api.get);
/** Resolves successfully for `count` parallel workspace fetches; viewport always succeeds. */
function succeedTimes(count: number) {
let workspaceRemaining = count;
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") return VIEWPORT;
if (workspaceRemaining > 0) {
workspaceRemaining--;
return WORKSPACES;
}
throw new Error("API error");
});
}
/** Always fails with the given message. */
function alwaysFail(msg = "Network error") {
mockApiGet.mockRejectedValue(new Error(msg));
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("hydrateCanvas", () => {
beforeEach(() => {
vi.clearAllMocks();
mockApiGet.mockReset();
mockHydrate.mockReset();
mockSetViewport.mockReset();
});
// ── Success on first attempt ─────────────────────────────────────────────
it("hydrates the store and returns null error on first attempt success", async () => {
succeedTimes(1);
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
});
it("persists viewport when returned by the API", async () => {
succeedTimes(1);
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockSetViewport).toHaveBeenCalledWith(VIEWPORT);
});
// ── Viewport failure is non-fatal ─────────────────────────────────────────
it("returns null error when viewport fetch fails but workspaces succeed", async () => {
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") throw new Error("Viewport error");
return WORKSPACES;
});
const result = await hydrateCanvas();
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
expect(mockSetViewport).not.toHaveBeenCalled();
});
// ── Retry logic ──────────────────────────────────────────────────────────
it("retries MAX_RETRIES times before returning an error", async () => {
alwaysFail();
const onRetrying = vi.fn();
const result = await Promise.race([
hydrateCanvas(onRetrying),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out — retries not awaited correctly");
expect(result.error).not.toBeNull();
expect(onRetrying).toHaveBeenCalledTimes(MAX_RETRIES - 1);
}, 10000);
it("onRetrying is called with attempt number before each retry", async () => {
alwaysFail();
const onRetrying = vi.fn();
await Promise.race([
hydrateCanvas(onRetrying),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
expect(onRetrying).toHaveBeenNthCalledWith(1, 1);
expect(onRetrying).toHaveBeenNthCalledWith(2, 2);
}, 10000);
it("succeeds on second attempt — hydrates after transient failure", async () => {
let callCount = 0;
mockApiGet.mockImplementation(async (url: string) => {
if (url === "/canvas/viewport") return null;
callCount++;
if (callCount === 1) throw new Error("Transient error");
return WORKSPACES;
});
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result).toEqual({ error: null });
expect(mockHydrate).toHaveBeenCalledOnce();
}, 10000);
// ── Error messages ────────────────────────────────────────────────────────
it("error message includes the platform URL after all retries exhausted", async () => {
alwaysFail("Connection refused");
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result.error).toContain("platform.test");
expect(result.error).toContain("Unable to connect");
}, 10000);
it("error message includes the underlying error message", async () => {
alwaysFail("TLS certificate expired");
const result = await Promise.race([
hydrateCanvas(),
new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 5000)),
]);
if (result === "timeout") throw new Error("Test timed out");
expect(result.error).not.toBeNull();
expect(typeof result.error).toBe("string");
}, 10000);
});
@@ -1,205 +0,0 @@
// @vitest-environment jsdom
"use client";
/**
* Tests for palette-context.tsx — MobileAccentProvider context + usePalette hook.
*
* Test coverage (9 cases):
* 1. MobileAccentProvider renders children
* 2. usePalette(false) without provider → MOL_LIGHT
* 3. usePalette(true) without provider → MOL_DARK
* 4. accent=null returns base palette unchanged
* 5. accent=base.accent returns base palette unchanged (identity guard)
* 6. accent="#custom" overrides both accent and online
* 7. MOL_LIGHT singleton never mutated
* 8. MOL_DARK singleton never mutated
*
* Plus pure-function coverage for normalizeStatus + tierCode.
*/
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import React from "react";
import { render, screen, cleanup } from "@testing-library/react";
import {
MOL_LIGHT,
MOL_DARK,
getPalette,
normalizeStatus,
tierCode,
MobileAccentProvider,
usePalette,
} from "../palette-context";
// ─── usePalette test helper ───────────────────────────────────────────────────
// usePalette reads document.documentElement.dataset.theme internally.
// We set this before rendering so the hook sees the right value.
function setDataTheme(theme: "light" | "dark") {
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = theme;
}
}
// ─── Pure function tests ──────────────────────────────────────────────────────
describe("normalizeStatus", () => {
it("returns emerald-400 for online status", () => {
expect(normalizeStatus("online", false)).toBe("bg-emerald-400");
expect(normalizeStatus("online", true)).toBe("bg-emerald-400");
});
it("returns emerald-400 for degraded status", () => {
expect(normalizeStatus("degraded", false)).toBe("bg-emerald-400");
expect(normalizeStatus("degraded", true)).toBe("bg-emerald-400");
});
it("returns red-400 for failed status", () => {
expect(normalizeStatus("failed", false)).toBe("bg-red-400");
expect(normalizeStatus("failed", true)).toBe("bg-red-400");
});
it("returns amber-400 for paused status", () => {
expect(normalizeStatus("paused", false)).toBe("bg-amber-400");
expect(normalizeStatus("paused", true)).toBe("bg-amber-400");
});
it("returns amber-400 for not_configured status", () => {
expect(normalizeStatus("not_configured", false)).toBe("bg-amber-400");
});
it("returns zinc-400 for unknown status", () => {
expect(normalizeStatus("unknown", false)).toBe("bg-zinc-400");
expect(normalizeStatus("", false)).toBe("bg-zinc-400");
});
});
describe("tierCode", () => {
it("returns T1 for tier 1", () => {
expect(tierCode(1)).toBe("T1");
});
it("returns T2 for tier 2", () => {
expect(tierCode(2)).toBe("T2");
});
it("returns T4 for tier 4", () => {
expect(tierCode(4)).toBe("T4");
});
it("returns generic T{n} for non-standard tiers", () => {
expect(tierCode(99)).toBe("T99");
});
});
// ─── getPalette tests ─────────────────────────────────────────────────────────
describe("getPalette — accent override", () => {
it("accent=null returns base palette unchanged (light)", () => {
const result = getPalette(null, false);
expect(result).toEqual({ ...MOL_LIGHT });
expect(result).not.toBe(MOL_LIGHT); // returned object is a copy
});
it("accent=null returns base palette unchanged (dark)", () => {
const result = getPalette(null, true);
expect(result).toEqual({ ...MOL_DARK });
expect(result).not.toBe(MOL_DARK);
});
it("accent=base.accent returns base palette unchanged (identity guard, light)", () => {
const result = getPalette(MOL_LIGHT.accent, false);
expect(result).toEqual({ ...MOL_LIGHT });
expect(result).not.toBe(MOL_LIGHT);
});
it("accent=base.accent returns base palette unchanged (identity guard, dark)", () => {
const result = getPalette(MOL_DARK.accent, true);
expect(result).toEqual({ ...MOL_DARK });
expect(result).not.toBe(MOL_DARK);
});
it("accent='#custom' overrides accent and online (light)", () => {
const result = getPalette("#ff0000", false);
expect(result.accent).toBe("#ff0000");
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", false)
});
it("accent='#custom' overrides accent and online (dark)", () => {
const result = getPalette("#00ff00", true);
expect(result.accent).toBe("#00ff00");
expect(result.online).toBe("bg-emerald-400"); // normalizeStatus("online", true)
});
it("MOL_LIGHT singleton is never mutated", () => {
getPalette("#mutate", false);
// All fields must still match the original freeze definition
expect(MOL_LIGHT.accent).toBe("bg-blue-500");
expect(MOL_LIGHT.online).toBe("bg-emerald-400");
expect(MOL_LIGHT.surface).toBe("bg-zinc-900");
expect(MOL_LIGHT.ink).toBe("text-zinc-100");
expect(MOL_LIGHT.line).toBe("border-zinc-700");
expect(MOL_LIGHT.bg).toBe("bg-zinc-950");
});
it("MOL_DARK singleton is never mutated", () => {
getPalette("#mutate", true);
expect(MOL_DARK.accent).toBe("bg-sky-400");
expect(MOL_DARK.online).toBe("bg-emerald-400");
expect(MOL_DARK.surface).toBe("bg-zinc-800");
expect(MOL_DARK.ink).toBe("text-zinc-100");
expect(MOL_DARK.line).toBe("border-zinc-700");
expect(MOL_DARK.bg).toBe("bg-zinc-950");
});
it("getPalette always returns a new object (no shared mutation risk)", () => {
const a = getPalette("#a", false);
const b = getPalette("#b", false);
expect(a).not.toBe(b);
expect(a.accent).not.toBe(b.accent);
});
});
// ─── MobileAccentProvider tests ───────────────────────────────────────────────
describe("MobileAccentProvider", () => {
beforeEach(() => {
setDataTheme("light");
});
afterEach(() => {
cleanup();
if (typeof document !== "undefined") {
document.documentElement.dataset.theme = "";
}
});
it("renders children", () => {
render(
<MobileAccentProvider accent={null}>
<span data-testid="child">Hello</span>
</MobileAccentProvider>,
);
expect(screen.getByTestId("child")).toBeTruthy();
});
// usePalette hook reads data-theme from <html> to determine light/dark.
// In the test environment, data-theme is empty, which falls through to
// the "light" default in usePalette, giving MOL_LIGHT.
it("usePalette(false) without provider → MOL_LIGHT", () => {
setDataTheme("light");
function ShowPalette() {
const p = usePalette(false);
return <span data-testid="accent-light">{p.accent}</span>;
}
render(<ShowPalette />);
expect(screen.getByTestId("accent-light").textContent).toBe(MOL_LIGHT.accent);
});
it("usePalette(true) without provider → MOL_DARK when data-theme=dark", () => {
setDataTheme("dark");
function ShowPalette() {
const p = usePalette(true);
return <span data-testid="accent-dark">{p.accent}</span>;
}
render(<ShowPalette />);
expect(screen.getByTestId("accent-dark").textContent).toBe(MOL_DARK.accent);
});
});
+8 -12
View File
@@ -8,18 +8,14 @@ import { getTenantSlug } from "./tenant";
export const PLATFORM_URL =
process.env.NEXT_PUBLIC_PLATFORM_URL ?? "http://localhost:8080";
// 35s is long enough for the slowest server-side path (EIC SSH
// tunnel for tenant EC2 file operations, bounded server-side by
// `eicFileOpTimeout = 30 * time.Second` in
// workspace-server/internal/handlers/template_files_eic.go) so the
// canvas surfaces the server's real error instead of aborting first
// with a generic timeout. Shorter values caused "Save & Restart" to
// time out at the client before the backend returned its 5xx. The
// abort still propagates through AbortController so React components
// can render a retry affordance. Callers that know an endpoint is
// intentionally slow (org import walks a tree of workspaces with
// server-side pacing) can pass `timeoutMs` to override.
const DEFAULT_TIMEOUT_MS = 35_000;
// 15s is long enough for slow CP queries but short enough that a
// hung backend doesn't leave the UI spinning forever. The abort
// propagates through AbortController so React components can observe
// the error and render a retry affordance. Callers that know the
// endpoint is intentionally slow (org import walks a tree of
// workspaces with server-side pacing) can pass `timeoutMs` to
// override.
const DEFAULT_TIMEOUT_MS = 15_000;
export interface RequestOptions {
timeoutMs?: number;
+2 -2
View File
@@ -21,8 +21,8 @@ export function statusDotClass(status: string): string {
export const TIER_CONFIG: Record<number, { label: string; color: string; border: string }> = {
1: { label: "T1", color: "text-ink-mid bg-surface-card border border-line", border: "text-ink-mid border-line" },
2: { label: "T2", color: "text-white bg-accent border border-accent-strong", border: "text-accent border-accent" },
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-white border-violet-500" },
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-white border-warm" },
3: { label: "T3", color: "text-white bg-violet-600 border border-violet-700", border: "text-violet-600 border-violet-500" },
4: { label: "T4", color: "text-white bg-warm border border-warm", border: "text-warm border-warm" },
};
export const COMM_TYPE_LABELS: Record<string, string> = {
-167
View File
@@ -1,167 +0,0 @@
"use client";
/**
* palette-context.tsx
*
* Mobile canvas accent palette system.
*
* - MOL_LIGHT / MOL_DARK — immutable base singletons
* - getPalette(accent, isDark) — returns base palette or accent-overridden copy
* - normalizeStatus(status, isDark) — maps workspace status → online dot color
* - tierCode(tier) — maps tier number → display label
* - MobileAccentProvider — React context that propagates accent override
* - usePalette(allowAccentOverride) — hook; returns the effective palette
*/
import { createContext, useContext } from "react";
// ─── Types ─────────────────────────────────────────────────────────────────────
export interface Palette {
/** Accent colour (CSS colour string). */
accent: string;
/** Online indicator colour (CSS class string, e.g. "bg-emerald-400"). */
online: string;
/** Surface background colour class. */
surface: string;
/** Primary text colour class. */
ink: string;
/** Border/divider colour class. */
line: string;
/** Background colour class. */
bg: string;
/** Tier display code, e.g. "T1". */
tier: string;
}
// ─── Singleton base palettes ────────────────────────────────────────────────────
/** Light-mode base palette — must never be mutated. */
export const MOL_LIGHT: Readonly<Palette> = Object.freeze({
accent: "bg-blue-500",
online: "bg-emerald-400",
surface: "bg-zinc-900",
ink: "text-zinc-100",
line: "border-zinc-700",
bg: "bg-zinc-950",
tier: "T1",
});
/** Dark-mode base palette — must never be mutated. */
export const MOL_DARK: Readonly<Palette> = Object.freeze({
accent: "bg-sky-400",
online: "bg-emerald-400",
surface: "bg-zinc-800",
ink: "text-zinc-100",
line: "border-zinc-700",
bg: "bg-zinc-950",
tier: "T1",
});
// ─── Pure helpers ─────────────────────────────────────────────────────────────
/**
* Maps workspace status string → online dot colour class.
* Returns the appropriate green for light/dark mode.
*/
export function normalizeStatus(
status: string,
_isDark: boolean,
): string {
if (status === "online" || status === "degraded") {
return "bg-emerald-400";
}
if (status === "failed") {
return "bg-red-400";
}
if (status === "paused" || status === "not_configured") {
return "bg-amber-400";
}
return "bg-zinc-400";
}
/**
* Maps tier number → display code.
*/
export function tierCode(tier: number): string {
return `T${tier}`;
}
/**
* Returns the effective palette.
*
* - `accent = null` → base palette (light or dark) unchanged
* - `accent = basePalette.accent` → base palette unchanged (identity guard)
* - `accent = "#custom"` → copy with `accent` and `online` overridden
*
* Always returns a new object; neither MOL_LIGHT nor MOL_DARK is ever mutated.
*/
export function getPalette(
accent: string | null,
isDark: boolean,
): Palette {
const base: Readonly<Palette> = isDark ? MOL_DARK : MOL_LIGHT;
// null accent → use base unchanged
if (accent === null) return { ...base };
// identity guard — accent same as base accent → no override needed
if (accent === base.accent) return { ...base };
// Custom accent: override accent + online to keep them in sync
return { ...base, accent, online: normalizeStatus("online", isDark) };
}
// ─── Context ──────────────────────────────────────────────────────────────────
type MobileAccentContextValue = {
/** Override accent colour (null = no override, use default). */
accent: string | null;
};
const MobileAccentContext = createContext<MobileAccentContextValue>({
accent: null,
});
export { MobileAccentContext };
/**
* Renders children inside the accent override context.
*/
export function MobileAccentProvider({
accent,
children,
}: {
accent: string | null;
children: React.ReactNode;
}) {
return (
<MobileAccentContext.Provider value={{ accent }}>
{children}
</MobileAccentContext.Provider>
);
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
/**
* Returns the effective `Palette` for the current context.
*
* @param allowAccentOverride When false, always returns the base palette
* even when an override is set (useful for
* non-accent-aware child components).
*/
export function usePalette(allowAccentOverride: boolean): Palette {
const { accent } = useContext(MobileAccentContext);
// Resolved from the OS-level theme preference. In a real app this would
// be derived from useTheme().resolvedTheme; for this hook we default
// to light (the safe default for SSR / component-library use).
// We read data-theme from <html> to stay in sync with the theme system.
const isDark =
typeof document !== "undefined" &&
document.documentElement.dataset.theme === "dark";
const effectiveAccent = allowAccentOverride ? accent : null;
return getPalette(effectiveAccent, isDark);
}
-4
View File
@@ -519,10 +519,6 @@ export function buildNodesAndEdges(
// #2054 — server-declared per-workspace provisioning timeout.
// Falls through to the runtime profile when null/absent.
provisionTimeoutMs: ws.provision_timeout_ms ?? null,
// Workspace abilities — defaults preserved for old platform versions
// that don't yet include these columns in the GET response.
broadcastEnabled: ws.broadcast_enabled ?? false,
talkToUserEnabled: ws.talk_to_user_enabled ?? true,
},
};
if (hasParent) {
-7
View File
@@ -99,13 +99,6 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
* @/lib/runtimeProfiles. Lets a slow runtime declare its cold-boot
* expectation without a canvas release. */
provisionTimeoutMs?: number | null;
/** When true the workspace may POST /broadcast to send org-wide messages.
* Default false. Toggled by user/admin via PATCH /workspaces/:id/abilities. */
broadcastEnabled?: boolean;
/** When false the workspace cannot deliver canvas chat messages.
* send_message_to_user / POST /notify return 403 and the canvas
* shows a "not enabled" state with a button to re-enable. Default true. */
talkToUserEnabled?: boolean;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
-3
View File
@@ -299,9 +299,6 @@ export interface WorkspaceData {
* `@/lib/runtimeProfiles` when absent (the default behavior for any
* template that hasn't yet declared the field). */
provision_timeout_ms?: number | null;
/** Workspace ability flags (migration 20260514). */
broadcast_enabled?: boolean;
talk_to_user_enabled?: boolean;
}
let socket: ReconnectingSocket | null = null;
+6 -34
View File
@@ -282,17 +282,13 @@
}
.secret-row__save-btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.secret-row__save-btn:hover {
background: #1e40af;
}
.secret-row__save-btn:focus-visible {
@@ -374,17 +370,13 @@
}
.add-key-form__save-btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.add-key-form__save-btn:hover {
background: #1e40af;
}
.add-key-form__save-btn:focus-visible {
@@ -518,7 +510,7 @@
.empty-state__body { font-size: 14px; color: #a1a1aa; margin: 0 0 24px; line-height: 1.5; }
.empty-state__cta {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 10px 20px;
@@ -526,10 +518,6 @@
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.empty-state__cta:hover {
background: #1e40af;
}
.empty-state__cta:focus-visible { outline: var(--focus-ring); outline-offset: var(--focus-ring-offset); }
@@ -573,16 +561,12 @@
.secrets-tab__error p { color: var(--status-invalid); margin: 0 0 12px; }
.secrets-tab__refresh-btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
}
.secrets-tab__refresh-btn:hover {
background: #1e40af;
}
.secrets-tab__no-results {
@@ -706,16 +690,12 @@
}
.guard-dialog__discard-btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.15s;
}
.guard-dialog__discard-btn:hover {
background: #1e40af;
}
.guard-dialog__discard-btn:focus-visible {
@@ -767,20 +747,12 @@
.top-bar__name { font-size: 14px; font-weight: 500; color: #d4d4d8; }
.top-bar__btn {
background: #1d4ed8;
background: #2563eb;
color: #ffffff;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 13px;
cursor: pointer;
transition: background-color 0.15s;
}
.top-bar__btn:hover {
background: #1e40af;
}
.top-bar__btn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px #18181b, 0 0 0 4px #3b82f6;
}
+4 -1
View File
@@ -30,7 +30,10 @@
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
{"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"},
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}
{"name": "crewai", "repo": "molecule-ai/molecule-ai-workspace-template-crewai", "ref": "main"},
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"},
{"name": "deepagents", "repo": "molecule-ai/molecule-ai-workspace-template-deepagents", "ref": "main"},
{"name": "gemini-cli", "repo": "molecule-ai/molecule-ai-workspace-template-gemini-cli", "ref": "main"}
],
"org_templates": [
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
+2 -36
View File
@@ -179,7 +179,6 @@ cp_redeploy_tenant() {
# 1 — any other failure
# stdout = response body. stderr = "HTTP_STATUS=NNN" line.
local slug="$1" tag="$2"
validate_slug "$slug"
_mock_call cp_redeploy_tenant "$slug" "$tag"; local _mrc=$?
[[ $_mrc -ne 99 ]] && return $_mrc
local tok="${!CP_TOKEN_ENV:-}"
@@ -205,7 +204,6 @@ cp_redeploy_tenant() {
tenant_buildinfo() {
# args: <slug>; prints JSON
local slug="$1"
validate_slug "$slug"
_mock_call tenant_buildinfo "$slug"; local _mrc=$?
[[ $_mrc -ne 99 ]] && return $_mrc
curl -sf --max-time 10 "https://${slug}.moleculesai.app/buildinfo"
@@ -214,7 +212,6 @@ tenant_buildinfo() {
tenant_health() {
# args: <slug>; prints raw response, returns 0 if "ok"
local slug="$1"
validate_slug "$slug"
_mock_call tenant_health "$slug"; local _mrc=$?
[[ $_mrc -ne 99 ]] && return $_mrc
curl -sf --max-time 10 "https://${slug}.moleculesai.app/health"
@@ -259,7 +256,6 @@ print(json.dumps({'commands': [ecr_login]}))
resolve_tenant_instance_id() {
# args: <slug>; prints i-xxx
local slug="$1"
validate_slug "$slug"
_mock_call resolve_tenant_instance_id "$slug"; local _mrc=$?
[[ $_mrc -ne 99 ]] && return $_mrc
local tok="${!CP_TOKEN_ENV:-}"
@@ -275,19 +271,6 @@ resolve_tenant_instance_id() {
log() { printf '[%s] %s\n' "$(date -u +%H:%M:%SZ)" "$*"; }
err() { printf '[%s] ERROR: %s\n' "$(date -u +%H:%M:%SZ)" "$*" >&2; }
# validate_slug — exit 64 if slug contains characters outside the safe set.
# Prevents SSRF via query-separator injection (?foo) and subdomain takeover
# (@evil) when slug is interpolated into URL paths or subdomains.
# OFFSEC-006 fix.
validate_slug() {
local slug="$1"
if ! [[ "$slug" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]]; then
printf '[%s] ERROR: invalid slug: %s\n' \
"$(date -u +%H:%M:%SZ)" "$slug" >&2
exit 64
fi
}
preflight() {
log "preflight: source=$SOURCE_TAG dest=$DEST_TAG repo=$REPO region=$REGION"
local src_manifest
@@ -356,7 +339,6 @@ promote() {
redeploy_tenant() {
# args: <slug> — handle the 403→SSM-refresh→retry pattern
local slug="$1"
validate_slug "$slug"
log " redeploy: $slug"
if [[ "$DRY_RUN" == "true" ]]; then
log " [dry-run] would POST /redeploy slug=$slug"
@@ -390,7 +372,6 @@ redeploy_tenant() {
verify_tenant() {
local slug="$1"
validate_slug "$slug"
log " verify: $slug"
if [[ "$DRY_RUN" == "true" ]]; then
log " [dry-run] would curl /buildinfo + /health"
@@ -417,7 +398,6 @@ rollback() {
rm -f "$mfile"
IFS=',' read -ra slugs <<<"$TENANTS"
for slug in "${slugs[@]}"; do
validate_slug "$slug"
redeploy_tenant "$slug" || err " rollback redeploy failed for $slug"
done
log "rollback: complete"
@@ -428,13 +408,6 @@ rollback() {
# ─────────────────────────────────────────────────────────────────────────────
main() {
# OFFSEC-006: validate slugs before any network I/O.
IFS=',' read -ra _slugs <<<"$TENANTS"
for _slug in "${_slugs[@]}"; do
validate_slug "$_slug"
done
unset _slugs _slug
preflight || return 1
snapshot_dest_tag || return 2
promote || return 2
@@ -442,15 +415,8 @@ main() {
local promote_rc=0
IFS=',' read -ra slugs <<<"$TENANTS"
for slug in "${slugs[@]}"; do
validate_slug "$slug"
if ! redeploy_tenant "$slug"; then
promote_rc=1
fi
if [[ $promote_rc -eq 0 ]]; then
if ! verify_tenant "$slug"; then
promote_rc=1
fi
fi
redeploy_tenant "$slug" || promote_rc=1
[[ $promote_rc -eq 0 ]] && { verify_tenant "$slug" || promote_rc=1; }
[[ $promote_rc -ne 0 ]] && break
done
+4 -48
View File
@@ -267,51 +267,7 @@ else
printf ' ✗ unknown-flag should fail (got %s)\n' "$rc"
fi
printf '\n== Test 9: slug validation — invalid slugs rejected with exit 64 (OFFSEC-006) ==\n'
# Attack vectors: SSRF via ? (curl query separator), subdomain takeover via @,
# path traversal via /, shell metacharacters. Use a newline-delimited temp file
# so slugs containing spaces are NOT split by shell word-splitting.
_invalid_tmp=$(mktemp)
cat > "$_invalid_tmp" <<'INVALID_EOF'
a?url=https://evil.com
a&url=https://evil.com
a@evil.com
a/b
a\b
a b
chloe-dong?url=http://evil.com
evil.com@legitimate
INVALID_EOF
while IFS= read -r attack || [[ -n "$attack" ]]; do
set +e
out=$("$SCRIPT" --source-tag x --dest-tag y --tenants "$attack" 2>&1); rc=$?
set -e
if [[ $rc -eq 64 ]] && printf '%s' "$out" | grep -q 'invalid slug'; then
PASS=$((PASS + 1)); printf ' ✓ slug rejected: %s\n' "$(printf '%q' "$attack")"
else
FAIL=$((FAIL + 1)); FAIL_NAMES+=("slug-reject:$attack")
printf ' ✗ slug should be rejected: %s — got exit %s\n' "$(printf '%q' "$attack")" "$rc"
fi
done < "$_invalid_tmp"
rm -f "$_invalid_tmp"
printf '\n== Test 10: slug validation — valid slugs pass through ==\n'
valid_slugs='chloe-dong hongming ab a abc123 my-tenant-42'
for slug in $valid_slugs; do
set +e
out=$("$SCRIPT" --source-tag x --dest-tag y --tenants "$slug" --mock-dir /nonexistent 2>&1); rc=$?
set -e
# valid slugs: script should fail at preflight (no such mock dir / no real infra),
# but NOT at slug validation (exit 64). So we check exit != 64.
if [[ $rc -ne 64 ]]; then
PASS=$((PASS + 1)); printf ' ✓ valid slug accepted: %s\n' "$slug"
else
FAIL=$((FAIL + 1)); FAIL_NAMES+=("slug-accept:$slug")
printf ' ✗ valid slug rejected: %s (should have passed slug check)\n' "$slug"
fi
done
printf '\n== Test 11: ROLLBACK_TAG follows YYYYMMDD via NOW_OVERRIDE_DATE ==\n'
printf '\n== Test 9: ROLLBACK_TAG follows YYYYMMDD via NOW_OVERRIDE_DATE ==\n'
m=$(mkmock)
mock_set "$m" aws_ecr_get_image '{}' 0
mock_set "$m" aws_ecr_describe_image '' 1
@@ -333,7 +289,7 @@ fi
assert_calls_contain "rollback tag uses NOW_OVERRIDE_DATE (20260603)" "$m" 'aws_ecr_put_image b-prev-20260603'
rm -rf "$m"
printf '\n== Test 12: empty source manifest fails preflight ==\n'
printf '\n== Test 10: empty source manifest fails preflight ==\n'
m=$(mkmock)
mock_set "$m" aws_ecr_get_image '' 0 # rc=0 but empty body (the "None" case)
out=$(run_script "$m")
@@ -341,7 +297,7 @@ assert_exit "empty source manifest fails preflight" "$out" 1
assert_contains "empty manifest message" "$out" 'returned empty manifest'
rm -rf "$m"
printf '\n== Test 13: tenant_buildinfo failure during verify → rollback ==\n'
printf '\n== Test 11: tenant_buildinfo failure during verify → rollback ==\n'
m=$(mkmock)
mock_set "$m" aws_ecr_get_image '{"manifests":[]}' 0
mock_set "$m" aws_ecr_describe_image '' 1
@@ -355,7 +311,7 @@ assert_contains "logs buildinfo failure" "$out" '/buildinfo failed for chloe-don
assert_contains "rollback fired after verify fail" "$out" 'ROLLBACK:'
rm -rf "$m"
printf '\n== Test 14: ssm_refresh_ecr_auth JSON escaping (CWE-78 / OFFSEC-001) ==\n'
printf '\n== Test 12: ssm_refresh_ecr_auth JSON escaping (CWE-78 / OFFSEC-001) ==\n'
# Verify the python3 snippet in ssm_refresh_ecr_auth produces valid JSON and
# correctly escapes shell-injection characters in region + account ID fields.
# The fix replaces unquoted shell-printf interpolation with json.dumps.
@@ -1,376 +0,0 @@
#!/usr/bin/env bash
# Staging E2E — fresh-provision peer-visibility gate via the LITERAL MCP path.
#
# WHY THIS EXISTS
# ---------------
# Hermes and OpenClaw were repeatedly reported "fleet-verified / cascade-
# complete" because the *proxy* signals were green:
# - registry-registration + heartbeat (Hermes), and
# - model round-trip 200 (OpenClaw).
# But a freshly-provisioned workspace, asked on canvas "can you see your
# peers", actually FAILS:
# - Hermes: 401 on the molecule MCP `list_peers` call,
# - OpenClaw: falls back to native `sessions_list`, sees no platform peers.
# Tasks #142/#159 were even marked "completed" under this same proxy flaw.
#
# This script codifies the LITERAL user-facing path so it can never silently
# regress: it provisions a brand-new throwaway org + sibling workspaces via
# the real control-plane provisioning path, then for each runtime that should
# have platform peer-visibility it drives the EXACT MCP call the canvas agent
# makes — `POST /workspaces/:id/mcp` JSON-RPC tools/call name=list_peers,
# authenticated by that workspace's own bearer token through the real
# WorkspaceAuth + MCPRateLimiter middleware chain. It then asserts:
# (1) HTTP 200,
# (2) JSON-RPC `result` present (NOT an `error` object — a -32000
# "tool call failed" or a 401 from WorkspaceAuth fails here),
# (3) the returned peer set CONTAINS the other provisioned sibling
# workspace IDs — not an empty list, not a native-sessions fallback.
#
# This is NOT a proxy. It does not look at a registry row, /health, the
# heartbeat table, or `GET /registry/:id/peers`. It drives the byte-for-byte
# JSON-RPC envelope that mcp_molecule_list_peers issues from a real agent.
#
# 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, OpenClaw MCP
# wiring) actually land. That is the point: it is the objective proof gate.
#
# AUTH MODEL (mirrors tests/e2e/test_staging_full_saas.sh)
# --------------------------------------------------------
# Single MOLECULE_ADMIN_TOKEN (= CP_ADMIN_API_TOKEN on Railway staging)
# drives: POST /cp/admin/orgs (provision), GET
# /cp/admin/orgs/:slug/admin-token (per-tenant token), DELETE
# /cp/admin/tenants/:slug (teardown). The per-tenant admin token drives
# tenant workspace creation; each workspace's OWN auth_token (returned by
# POST /workspaces) drives its MCP call.
#
# Required env:
# MOLECULE_ADMIN_TOKEN CP admin bearer — Railway staging CP_ADMIN_API_TOKEN
# Optional env:
# MOLECULE_CP_URL default https://staging-api.moleculesai.app
# E2E_RUN_ID slug suffix; CI passes ${GITHUB_RUN_ID}
# PV_RUNTIMES space list; default "hermes openclaw claude-code"
# E2E_PROVISION_TIMEOUT_SECS default 1800 (hermes/openclaw cold EC2 budget)
# E2E_MINIMAX_API_KEY / E2E_ANTHROPIC_API_KEY / E2E_OPENAI_API_KEY
# LLM provider key injected so the runtime can boot
# E2E_KEEP_ORG 1 → skip teardown (local debugging only)
#
# Exit codes:
# 0 every runtime saw its peers via the literal MCP call
# 1 generic failure
# 2 missing required env
# 3 provisioning timed out
# 4 teardown left orphan resources
# 10 peer-visibility regression reproduced (the gate firing as designed)
set -uo pipefail
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)-$$}"
PV_RUNTIMES="${PV_RUNTIMES:-hermes openclaw claude-code}"
PROVISION_TIMEOUT_SECS="${E2E_PROVISION_TIMEOUT_SECS:-1800}"
# Slug MUST start with 'e2e-' so the sweep-stale-e2e-orgs safety net
# (EPHEMERAL_PREFIXES) catches any leak this run fails to tear down.
SLUG="e2e-pv-$(date +%Y%m%d)-${RUN_ID_SUFFIX}"
SLUG=$(echo "$SLUG" | tr '[:upper:]' '[:lower:]' | tr -cd 'a-z0-9-' | head -c 32)
ORG_ID=""
TENANT_URL=""
TENANT_TOKEN=""
log() { echo "[$(date +%H:%M:%S)] $*"; }
fail() { echo "[$(date +%H:%M:%S)] ❌ $*" >&2; exit 1; }
ok() { echo "[$(date +%H:%M:%S)] ✅ $*"; }
admin_call() {
local method="$1" path="$2"; shift 2
curl -sS -X "$method" "$CP_URL$path" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" "$@"
}
tenant_call() {
local method="$1" path="$2"; shift 2
curl -sS -X "$method" "$TENANT_URL$path" \
-H "Authorization: Bearer $TENANT_TOKEN" \
-H "X-Molecule-Org-Id: $ORG_ID" \
-H "Content-Type: application/json" "$@"
}
# ─── Scoped teardown ───────────────────────────────────────────────────
# Deletes ONLY the org this run created (DELETE /cp/admin/tenants/$SLUG
# with the {"confirm":$SLUG} fat-finger guard). Never a cluster-wide
# sweep — honors feedback_cleanup_after_each_test and
# feedback_never_run_cluster_cleanup_tests_on_live_platform. The
# workflow's always() step + sweep-stale-e2e-orgs are the outer nets.
teardown() {
local rc=$?
set +e
if [ "${E2E_KEEP_ORG:-0}" = "1" ]; then
echo ""
log "[teardown] E2E_KEEP_ORG=1 — leaving $SLUG for debugging (REMEMBER TO DELETE)"
exit $rc
fi
echo ""
log "[teardown] DELETE /cp/admin/tenants/$SLUG (scoped to this run only)"
admin_call DELETE "/cp/admin/tenants/$SLUG" --max-time 120 \
-d "{\"confirm\":\"$SLUG\"}" >/dev/null 2>&1
for j in $(seq 1 24); do
LIST=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null)
LEAK=$(echo "$LIST" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(1); sys.exit(0)
orgs = d if isinstance(d, list) else d.get('orgs', [])
print(sum(1 for o in orgs if o.get('slug') == '$SLUG' and o.get('instance_status') not in ('purged',) and o.get('status') != 'purged'))
" 2>/dev/null || echo 1)
if [ "$LEAK" = "0" ]; then
log "[teardown] ✓ $SLUG purged (after ${j}x5s)"
exit $rc
fi
sleep 5
done
echo "::warning::[teardown] $SLUG still present after 120s — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES" >&2
[ $rc -eq 0 ] && rc=4
exit $rc
}
trap teardown EXIT INT TERM
# ─── 1. Provision the throwaway org ────────────────────────────────────
log "1/6 POST /cp/admin/orgs — slug=$SLUG"
CREATE=$(admin_call POST /cp/admin/orgs \
-d "{\"slug\":\"$SLUG\",\"name\":\"E2E peer-visibility $SLUG\",\"owner_user_id\":\"e2e-runner:$SLUG\"}")
ORG_ID=$(echo "$CREATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[ -n "$ORG_ID" ] || fail "org creation failed: $(echo "$CREATE" | head -c 300)"
log " ORG_ID=$ORG_ID"
# ─── 2. Wait for tenant EC2 + DNS ──────────────────────────────────────
log "2/6 waiting for tenant instance_status=running (cold EC2 + cloudflared)..."
DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
while true; do
[ "$(date +%s)" -gt "$DEADLINE" ] && fail "tenant never came up within ${PROVISION_TIMEOUT_SECS}s"
STATUS=$(admin_call GET "/cp/admin/orgs?limit=500" 2>/dev/null | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: sys.exit(0)
orgs = d if isinstance(d, list) else d.get('orgs', [])
for o in orgs:
if o.get('slug') == '$SLUG':
print(o.get('instance_status') or o.get('status') or 'unknown'); break
" 2>/dev/null)
case "$STATUS" in running|online|ready) break ;; esac
sleep 10
done
log " tenant status=$STATUS"
# ─── 3. Per-tenant admin token + tenant URL ────────────────────────────
log "3/6 fetching per-tenant admin token..."
TT_RESP=$(admin_call GET "/cp/admin/orgs/$SLUG/admin-token")
TENANT_TOKEN=$(echo "$TT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('admin_token',''))" 2>/dev/null)
[ -n "$TENANT_TOKEN" ] || fail "tenant token fetch failed: $(echo "$TT_RESP" | head -c 200)"
CP_HOST=$(echo "$CP_URL" | sed -E 's#^https?://##; s#/.*$##')
case "$CP_HOST" in
api.*) DERIVED_DOMAIN="${CP_HOST#api.}" ;;
staging-api.*) DERIVED_DOMAIN="staging.${CP_HOST#staging-api.}" ;;
*) DERIVED_DOMAIN="$CP_HOST" ;;
esac
TENANT_URL="https://${SLUG}.${DERIVED_DOMAIN}"
log " tenant url: $TENANT_URL"
log "3b. waiting for tenant /health (TLS/DNS, up to 10min)..."
for i in $(seq 1 120); do
curl -fsS "$TENANT_URL/health" -m 5 -k >/dev/null 2>&1 && { log " /health ok (attempt $i)"; break; }
sleep 5
done
# ─── 4. Provision the parent + one sibling per runtime under test ──────
# Inject the LLM provider key so each runtime can authenticate at boot.
# Priority: MiniMax → direct-Anthropic → OpenAI (mirrors
# test_staging_full_saas.sh's secrets-injection chain).
SECRETS_JSON='{}'
if [ -n "${E2E_MINIMAX_API_KEY:-}" ]; then
SECRETS_JSON=$(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
SECRETS_JSON=$(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
SECRETS_JSON=$(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'}))")
fi
log "4/6 provisioning parent (claude-code) + one sibling per runtime under test..."
P_RESP=$(tenant_call POST /workspaces \
-d "{\"name\":\"pv-parent\",\"runtime\":\"claude-code\",\"tier\":3,\"secrets\":$SECRETS_JSON}")
PARENT_ID=$(echo "$P_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
[ -n "$PARENT_ID" ] || fail "parent create failed: $(echo "$P_RESP" | head -c 300)"
log " PARENT_ID=$PARENT_ID"
# WS_IDS[runtime]=id ; WS_TOKENS[runtime]=auth_token (the MCP bearer)
declare -A WS_IDS WS_TOKENS
ALL_WS_IDS="$PARENT_ID"
for rt in $PV_RUNTIMES; do
R=$(tenant_call POST /workspaces \
-d "{\"name\":\"pv-$rt\",\"runtime\":\"$rt\",\"tier\":2,\"parent_id\":\"$PARENT_ID\",\"secrets\":$SECRETS_JSON}")
WID=$(echo "$R" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null)
# auth_token is top-level for container runtimes; external-like nest it
# under connection.auth_token (verified vs staging response shape).
WTOK=$(echo "$R" | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: print(''); sys.exit(0)
print(d.get('auth_token') or d.get('connection', {}).get('auth_token') or '')
" 2>/dev/null)
[ -n "$WID" ] || fail "$rt workspace create failed: $(echo "$R" | head -c 300)"
[ -n "$WTOK" ] || fail "$rt workspace did not return an auth_token — cannot drive its MCP call (resp: $(echo "$R" | head -c 300))"
WS_IDS[$rt]="$WID"
WS_TOKENS[$rt]="$WTOK"
ALL_WS_IDS="$ALL_WS_IDS $WID"
log " $rt$WID"
done
# ─── 5. Wait for every sibling online ──────────────────────────────────
log "5/6 waiting for all workspaces status=online (up to ${PROVISION_TIMEOUT_SECS}s — cold boot)..."
WS_DEADLINE=$(( $(date +%s) + PROVISION_TIMEOUT_SECS ))
for rt in $PV_RUNTIMES; do
wid="${WS_IDS[$rt]}"
LAST=""
while true; do
[ "$(date +%s)" -gt "$WS_DEADLINE" ] && fail "$rt ($wid) never reached online (last=$LAST)"
S=$(tenant_call GET "/workspaces/$wid" 2>/dev/null | python3 -c "
import sys, json
try: d = json.load(sys.stdin)
except Exception: sys.exit(0)
w = d.get('workspace') if isinstance(d.get('workspace'), dict) else d
print(w.get('status') or '')
" 2>/dev/null)
[ "$S" != "$LAST" ] && { log " $rt$S"; LAST="$S"; }
case "$S" in
online) break ;;
failed) sleep 10 ;; # transient: bootstrap-watcher 5-min deadline, heartbeat recovers
*) sleep 10 ;;
esac
done
ok " $rt online"
done
# ─── 6. THE GATE — literal mcp_molecule_list_peers via POST /:id/mcp ────
# This is the byte-for-byte user-facing call. NOT GET /registry/:id/peers,
# NOT /health, NOT the heartbeat table. JSON-RPC 2.0 tools/call,
# name=list_peers, authenticated by the workspace's OWN bearer token
# 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
echo ""
done
echo "=== SUMMARY — fresh-provision peer-visibility (literal MCP list_peers) ==="
for rt in $PV_RUNTIMES; do
printf ' %-14s %s\n' "$rt" "${VERDICT[$rt]:-NO_RUN}"
done
echo ""
if [ "$REGRESSED" -ne 0 ]; then
echo "✗ GATE FAILED — at least one runtime cannot see its peers via the"
echo " literal mcp_molecule_list_peers call. This is the real user-facing"
echo " failure the proxy signals (registry row / heartbeat / model 200)"
echo " were hiding. Expected RED until the Hermes-401 + OpenClaw-MCP-wiring"
echo " root-cause fixes land; goes green only when they actually do."
exit 10
fi
ok "GATE PASSED — every runtime under test sees its platform peers via the literal MCP call."
exit 0
-296
View File
@@ -1,296 +0,0 @@
#!/usr/bin/env bash
# E2E test: workspace broadcast and talk-to-user platform abilities.
#
# What this proves:
# 1. talk_to_user_enabled (default true) — POST /notify works out-of-the-box.
# 2. PATCH /workspaces/:id/abilities { talk_to_user_enabled: false } disables
# delivery: /notify → 403 with error="talk_to_user_disabled" + delegate hint.
# 3. Re-enabling talk_to_user_enabled restores delivery.
# 4. broadcast_enabled (default false) — POST /broadcast → 403 when disabled.
# 5. PATCH { broadcast_enabled: true } enables fan-out.
# 6. POST /broadcast delivers to all non-sender, non-removed workspaces:
# - Returns {"status":"sent","delivered":N}
# - Receiver's activity log has a broadcast_receive entry with the message.
# - Sender's activity log has a broadcast_sent entry.
# 7. The sender itself does NOT receive a broadcast_receive entry.
#
# Usage: tests/e2e/test_workspace_abilities_e2e.sh
# Prereqs: workspace-server on http://localhost:8080, MOLECULE_ENV != production
set -euo pipefail
source "$(dirname "$0")/_lib.sh"
PASS=0
FAIL=0
SENDER_ID=""
RECEIVER_ID=""
cleanup() {
for wid in "$SENDER_ID" "$RECEIVER_ID"; do
if [ -n "$wid" ]; then
curl -s -X DELETE "$BASE/workspaces/$wid?confirm=true" > /dev/null || true
fi
done
}
trap cleanup EXIT INT TERM
assert() {
local label="$1" actual="$2" expected="$3"
if [ "$actual" = "$expected" ]; then
echo " PASS — $label"
PASS=$((PASS+1))
else
echo " FAIL — $label"
echo " expected: $expected"
echo " actual: $actual"
FAIL=$((FAIL+1))
fi
}
assert_contains() {
local label="$1" haystack="$2" needle="$3"
if echo "$haystack" | grep -qF "$needle"; then
echo " PASS — $label"
PASS=$((PASS+1))
else
echo " FAIL — $label"
echo " needle: $needle"
echo " haystack: $haystack"
FAIL=$((FAIL+1))
fi
}
assert_not_contains() {
local label="$1" haystack="$2" needle="$3"
if ! echo "$haystack" | grep -qF "$needle"; then
echo " PASS — $label"
PASS=$((PASS+1))
else
echo " FAIL — $label (unexpected match)"
echo " needle: $needle"
echo " haystack: $haystack"
FAIL=$((FAIL+1))
fi
}
# ── Pre-sweep: remove any stale leftover workspaces from a prior aborted run ──
echo "=== Setup ==="
for NAME in "Abilities Sender" "Abilities Receiver"; do
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') == '$NAME'))
except Exception:
pass
")
for _wid in $PRIOR; do
echo "Sweeping leftover '$NAME' workspace: $_wid"
curl -s -X DELETE "$BASE/workspaces/$_wid?confirm=true" > /dev/null || true
done
done
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Abilities Sender","tier":1}')
SENDER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$SENDER_ID" ] || { echo "Failed to create sender workspace: $R"; exit 1; }
echo "Created sender workspace: $SENDER_ID"
R=$(curl -s -X POST "$BASE/workspaces" -H "Content-Type: application/json" \
-d '{"name":"Abilities Receiver","tier":1}')
RECEIVER_ID=$(echo "$R" | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])' 2>/dev/null || true)
[ -n "$RECEIVER_ID" ] || { echo "Failed to create receiver workspace: $R"; exit 1; }
echo "Created receiver workspace: $RECEIVER_ID"
# Mint workspace-scoped bearer tokens (test-only endpoint, disabled in prod).
SENDER_TOKEN=$(e2e_mint_test_token "$SENDER_ID")
[ -n "$SENDER_TOKEN" ] || { echo "Failed to mint sender token"; exit 1; }
SENDER_AUTH="Authorization: Bearer $SENDER_TOKEN"
# Admin token — any live workspace bearer satisfies AdminAuth in local dev.
# In production-like envs, set MOLECULE_ADMIN_TOKEN.
ADMIN_TOKEN="${MOLECULE_ADMIN_TOKEN:-$SENDER_TOKEN}"
ADMIN_AUTH="Authorization: Bearer $ADMIN_TOKEN"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Part 1: talk_to_user ability ==="
echo ""
echo "--- 1a: /notify works with default talk_to_user_enabled=true ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Hello from sender"}')
assert "POST /notify returns 200 when talk_to_user_enabled=true (default)" "$CODE" "200"
echo ""
echo "--- 1b: Disable talk_to_user ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"talk_to_user_enabled": false}')
assert "PATCH /abilities talk_to_user_enabled=false returns 200" "$CODE" "200"
# Verify the flag is reflected in the workspace GET response.
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))')
assert "GET /workspaces/:id reflects talk_to_user_enabled=false" "$FLAG" "False"
echo ""
echo "--- 1c: /notify blocked when talk_to_user disabled ---"
BODY=$(curl -s -w "" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Should be blocked"}')
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Should be blocked"}')
assert "POST /notify returns 403 when talk_to_user_enabled=false" "$CODE" "403"
ERR=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("error",""))' 2>/dev/null || echo "")
assert_contains "403 body contains talk_to_user_disabled error code" "$ERR" "talk_to_user_disabled"
HINT=$(echo "$BODY" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("hint",""))' 2>/dev/null || echo "")
assert_contains "403 body contains delegate_task hint" "$HINT" "delegate_task"
echo ""
echo "--- 1d: Re-enable talk_to_user and verify /notify works again ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"talk_to_user_enabled": true}')
assert "PATCH /abilities talk_to_user_enabled=true returns 200" "$CODE" "200"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/notify" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Re-enabled, should work"}')
assert "POST /notify returns 200 after re-enabling talk_to_user" "$CODE" "200"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Part 2: broadcast ability ==="
echo ""
echo "--- 2a: Broadcast blocked by default (broadcast_enabled=false) ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Should be blocked"}')
assert "POST /broadcast returns 403 when broadcast_enabled=false (default)" "$CODE" "403"
echo ""
echo "--- 2b: Enable broadcast ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"broadcast_enabled": true}')
assert "PATCH /abilities broadcast_enabled=true returns 200" "$CODE" "200"
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
FLAG=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))')
assert "GET /workspaces/:id reflects broadcast_enabled=true" "$FLAG" "True"
echo ""
echo "--- 2c: Successful broadcast fan-out ---"
BCAST=$(curl -s -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":"Org-wide notice: scheduled maintenance in 5 minutes."}')
BSTATUS=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("status",""))' 2>/dev/null || echo "")
BDELIVERED=$(echo "$BCAST" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("delivered","-1"))' 2>/dev/null || echo "-1")
assert "POST /broadcast returns status=sent" "$BSTATUS" "sent"
# delivered count must be >= 1 (the receiver workspace).
echo " INFO — broadcast delivered=$BDELIVERED"
if python3 -c "import sys; sys.exit(0 if int('$BDELIVERED') >= 1 else 1)" 2>/dev/null; then
echo " PASS — delivered count >= 1"
PASS=$((PASS+1))
else
echo " FAIL — expected delivered >= 1, got $BDELIVERED"
FAIL=$((FAIL+1))
fi
echo ""
echo "--- 2d: Receiver activity log has broadcast_receive entry ---"
RECEIVER_TOKEN=$(e2e_mint_test_token "$RECEIVER_ID")
[ -n "$RECEIVER_TOKEN" ] || { echo "Failed to mint receiver token"; exit 1; }
RECEIVER_AUTH="Authorization: Bearer $RECEIVER_TOKEN"
ACT=$(curl -s -H "$RECEIVER_AUTH" "$BASE/workspaces/$RECEIVER_ID/activity?source=agent&limit=20")
ROW=$(echo "$ACT" | python3 -c '
import json, sys
rows = json.load(sys.stdin) or []
for r in rows:
if r.get("activity_type") == "broadcast_receive":
print(json.dumps(r))
break
')
[ -n "$ROW" ] || {
echo " FAIL — could not find broadcast_receive row in receiver activity"
FAIL=$((FAIL+1))
}
if [ -n "$ROW" ]; then
# Message is stored in summary field.
MSG=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("summary",""))')
assert_contains "broadcast_receive row summary has original message" "$MSG" "scheduled maintenance"
# Sender ID is stored in source_id field.
SRC=$(echo "$ROW" | python3 -c 'import json,sys;r=json.load(sys.stdin);print(r.get("source_id",""))')
assert "broadcast_receive row source_id is sender workspace" "$SRC" "$SENDER_ID"
fi
echo ""
echo "--- 2e: Sender activity log has broadcast_sent entry ---"
ACT_SENDER=$(curl -s -H "$SENDER_AUTH" "$BASE/workspaces/$SENDER_ID/activity?limit=20")
SENT_ROW=$(echo "$ACT_SENDER" | python3 -c '
import json, sys
rows = json.load(sys.stdin) or []
for r in rows:
if r.get("activity_type") == "broadcast_sent":
print(json.dumps(r))
break
')
[ -n "$SENT_ROW" ] || {
echo " FAIL — could not find broadcast_sent row in sender activity"
FAIL=$((FAIL+1))
}
if [ -n "$SENT_ROW" ]; then
# Delivered count is baked into the summary field (no response_body for sender row).
SUMMARY=$(echo "$SENT_ROW" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("summary",""))')
assert_contains "broadcast_sent summary mentions workspace count" "$SUMMARY" "workspace"
fi
echo ""
echo "--- 2f: Sender does NOT receive a broadcast_receive entry ---"
SELF_RECV=$(echo "$ACT_SENDER" | python3 -c '
import json, sys
rows = json.load(sys.stdin) or []
for r in rows:
if r.get("activity_type") == "broadcast_receive":
print("found")
break
')
assert_not_contains "sender has no broadcast_receive in own activity log" "${SELF_RECV:-}" "found"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "--- 2g: Empty message is rejected ---"
CODE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BASE/workspaces/$SENDER_ID/broadcast" \
-H "Content-Type: application/json" -H "$SENDER_AUTH" \
-d '{"message":""}')
assert "POST /broadcast with empty message returns 400" "$CODE" "400"
echo ""
echo "--- 2h: Partial PATCH does not clobber other flags ---"
# Set talk_to_user=false, then patch only broadcast — talk_to_user must stay false.
curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"talk_to_user_enabled": false}'
curl -s -o /dev/null -X PATCH "$BASE/workspaces/$SENDER_ID/abilities" \
-H "Content-Type: application/json" -H "$ADMIN_AUTH" \
-d '{"broadcast_enabled": false}'
WS=$(curl -s "$BASE/workspaces/$SENDER_ID" -H "$SENDER_AUTH")
TUF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("talk_to_user_enabled","MISSING"))')
BEF=$(echo "$WS" | python3 -c 'import json,sys;print(json.load(sys.stdin).get("broadcast_enabled","MISSING"))')
assert "partial PATCH preserves talk_to_user_enabled=false" "$TUF" "False"
assert "partial PATCH sets broadcast_enabled=false" "$BEF" "False"
# ─────────────────────────────────────────────────────────────────────────────
echo ""
echo "=== Results: $PASS passed, $FAIL failed ==="
[ "$FAIL" -eq 0 ]
-151
View File
@@ -22,7 +22,6 @@ Cross-links:
"""
from __future__ import annotations
import re
import subprocess
import sys
import textwrap
@@ -543,153 +542,3 @@ def test_rule9_prod_manual_deploy_allows_rollback_control(tmp_path):
_write(tmp_path, "ok.yml", PROD_ROLLBACK_OK)
r = _run_lint(tmp_path)
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
# ---------------------------------------------------------------------------
# Rule 10 — docker info piped to head under pipefail
# ---------------------------------------------------------------------------
DOCKER_INFO_HEAD_BAD = """
name: docker-info-head-bad
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
set -euo pipefail
docker info 2>&1 | head -5 || exit 1
"""
DOCKER_INFO_CAPTURE_OK = """
name: docker-info-capture-ok
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
set -euo pipefail
docker_info="$(docker info 2>&1)" || exit 1
printf '%s\\n' "${docker_info}" | sed -n '1,5p'
"""
DOCKER_INFO_SEPARATE_STEP_OK = """
name: docker-info-separate-step-ok
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: |
set -euo pipefail
echo setup
- run: |
docker info 2>&1 | head -5 || true
"""
def test_rule10_docker_info_head_under_pipefail_detects_violation(tmp_path):
_write(tmp_path, "bad.yml", DOCKER_INFO_HEAD_BAD)
r = _run_lint(tmp_path)
assert r.returncode == 1
assert "docker info" in r.stdout.lower()
assert "pipefail" in r.stdout.lower()
def test_rule10_docker_info_capture_passes(tmp_path):
_write(tmp_path, "ok.yml", DOCKER_INFO_CAPTURE_OK)
r = _run_lint(tmp_path)
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
def test_rule10_docker_info_head_in_separate_step_without_pipefail_passes(tmp_path):
_write(tmp_path, "ok.yml", DOCKER_INFO_SEPARATE_STEP_OK)
r = _run_lint(tmp_path)
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
# ---------------------------------------------------------------------------
# CI change detector fanout — workflow-only PRs keep required contexts without
# running Go/Canvas/Python/shellcheck heavy steps.
# ---------------------------------------------------------------------------
CI_WORKFLOW = REPO_ROOT / ".gitea" / "workflows" / "ci.yml"
CI_SURFACES = ("platform", "canvas", "python", "scripts")
def _ci_change_patterns() -> dict[str, re.Pattern[str]]:
text = CI_WORKFLOW.read_text(encoding="utf-8")
patterns: dict[str, re.Pattern[str]] = {}
for surface, pattern in re.findall(
r'echo "(platform|canvas|python|scripts)=.*?grep -qE \'([^\']+)\'',
text,
):
patterns[surface] = re.compile(pattern)
assert set(patterns) == set(CI_SURFACES)
return patterns
def _classify_ci_change(*paths: str) -> dict[str, bool]:
patterns = _ci_change_patterns()
return {
surface: any(pattern.search(path) for path in paths)
for surface, pattern in patterns.items()
}
def test_ci_change_detector_workflow_only_edits_do_not_trigger_heavy_surfaces():
assert _classify_ci_change(".gitea/workflows/ci.yml") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change(".github/workflows/ci.yml") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
def test_ci_change_detector_narrow_surface_edits_only_trigger_their_surface():
assert _classify_ci_change("workspace-server/internal/handlers/foo.go") == {
"platform": True,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change("canvas/app/page.tsx") == {
"platform": False,
"canvas": True,
"python": False,
"scripts": False,
}
assert _classify_ci_change("workspace/a2a_mcp_server.py") == {
"platform": False,
"canvas": False,
"python": True,
"scripts": False,
}
assert _classify_ci_change("tests/e2e/test_model_slug.sh") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": True,
}
def test_ci_change_detector_docs_and_meta_scripts_do_not_trigger_surfaces():
assert _classify_ci_change("README.md") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
assert _classify_ci_change(".gitea/scripts/lint-workflow-yaml.py") == {
"platform": False,
"canvas": False,
"python": False,
"scripts": False,
}
+1 -62
View File
@@ -495,7 +495,7 @@ def test_reap_required_check_pull_request_suffix_never_touched(sr_module, monkey
}
counters = sr_module.reap(workflow_map, combined, SHA, dry_run=False)
assert counters["compensated"] == 0
assert counters["preserved_pr_without_push_success"] == 1
assert counters["preserved_non_push_suffix"] == 1
assert calls == []
@@ -1009,64 +1009,3 @@ def test_reap_continues_on_per_sha_apierror(sr_module, monkeypatch, capsys):
captured = capsys.readouterr()
assert "::warning::" in captured.out or "::notice::" in captured.out
assert SHA_A[:10] in captured.out
def test_main_soft_skips_when_commit_listing_times_out(sr_module, monkeypatch, capsys):
"""A transient outage while listing recent commits should not paint main red.
Per-SHA status read failures are already isolated inside `reap_branch`.
The real 2026-05-14 failure was earlier: `/commits?sha=main&limit=30`
timed out after all retries, aborting the tick. The next 5-minute tick can
retry safely, so `main()` should emit an observable warning and return 0.
"""
monkeypatch.setattr(sr_module, "scan_workflows", lambda _: {"workflow-without-push": False})
def fake_list_recent_commit_shas(*args, **kwargs):
raise sr_module.ApiError(
"GET /repos/owner/repo/commits failed after 4 attempts: timed out"
)
monkeypatch.setattr(sr_module, "list_recent_commit_shas", fake_list_recent_commit_shas)
monkeypatch.setattr(sys, "argv", ["status-reaper.py"])
assert sr_module.main() == 0
captured = capsys.readouterr()
assert "::warning::status-reaper skipped this tick" in captured.out
assert '"skipped": true' in captured.out
assert '"skip_reason": "commit-list-api-error"' in captured.out
def test_main_does_not_soft_skip_status_write_failures(sr_module, monkeypatch):
"""Only commit-list read failures are soft-skipped.
A compensation write failure means the reaper could not repair a red
status. That must still fail the job loudly instead of being mislabeled as
a transient commit-list outage.
"""
monkeypatch.setattr(sr_module, "scan_workflows", lambda _: {"workflow-without-push": False})
monkeypatch.setattr(sr_module, "list_recent_commit_shas", lambda *_args, **_kwargs: [SHA_A])
monkeypatch.setattr(
sr_module,
"get_combined_status",
lambda _sha: {
"state": "failure",
"statuses": [
{
"context": "workflow-without-push / job (push)",
"status": "failure",
"description": "stranded class-O red",
}
],
},
)
def fake_post_compensating_status(*args, **kwargs):
raise sr_module.ApiError("POST /statuses failed: 403")
monkeypatch.setattr(sr_module, "post_compensating_status", fake_post_compensating_status)
monkeypatch.setattr(sys, "argv", ["status-reaper.py"])
with pytest.raises(sr_module.ApiError, match="POST /statuses failed"):
sr_module.main()
-3
View File
@@ -18,7 +18,6 @@ require (
github.com/opencontainers/image-spec v1.1.1
github.com/redis/go-redis/v9 v9.19.0
github.com/robfig/cron/v3 v3.0.1
github.com/stretchr/testify v1.11.1
go.moleculesai.app/plugin/gh-identity v0.0.0-20260509010445-788988195fce
golang.org/x/crypto v0.50.0
gopkg.in/yaml.v3 v3.0.1
@@ -34,7 +33,6 @@ require (
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -60,7 +58,6 @@ require (
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
@@ -1,261 +0,0 @@
package bundle
import (
"os"
"path/filepath"
"testing"
)
// ---------------------------------------------------------------------------
// extractDescription
// ---------------------------------------------------------------------------
func TestExtractDescription_WithFrontmatter(t *testing.T) {
// YAML frontmatter is skipped; first non-comment, non-empty line after
// the closing `---` is the description.
content := `---
title: My Workspace
---
# This is a comment
This is the description line.
Another line.`
got := extractDescription(content)
if got != "This is the description line." {
t.Errorf("got %q, want %q", got, "This is the description line.")
}
}
func TestExtractDescription_NoFrontmatter(t *testing.T) {
// No frontmatter: first non-comment, non-empty line is returned.
content := `# Copyright header
My workspace description
Another line.`
got := extractDescription(content)
if got != "My workspace description" {
t.Errorf("got %q, want %q", got, "My workspace description")
}
}
func TestExtractDescription_CommentOnly(t *testing.T) {
// All content is comments or empty → empty string.
content := `# comment only
# another comment
`
got := extractDescription(content)
if got != "" {
t.Errorf("got %q, want empty string", got)
}
}
func TestExtractDescription_EmptyInput(t *testing.T) {
got := extractDescription("")
if got != "" {
t.Errorf("got %q, want empty string", got)
}
}
func TestExtractDescription_UnclosedFrontmatter(t *testing.T) {
// With no closing `---`, inFrontmatter stays true after the opening
// delimiter, so all subsequent lines are skipped and "" is returned.
// This is the documented behaviour: without a closing delimiter,
// all lines are considered frontmatter.
content := `---
title: No closing delimiter
This is the description.`
got := extractDescription(content)
if got != "" {
t.Errorf("unclosed frontmatter: got %q, want empty string", got)
}
}
func TestExtractDescription_FrontmatterThenCommentThenContent(t *testing.T) {
content := `---
tags: [test]
---
# internal comment
Real description here.
`
got := extractDescription(content)
if got != "Real description here." {
t.Errorf("got %q, want %q", got, "Real description here.")
}
}
func TestExtractDescription_BlankLinesSkipped(t *testing.T) {
// Empty lines (len=0) are skipped; whitespace-only lines (spaces) are NOT
// skipped because len(line)>0. First non-comment, non-empty line is returned.
content := "\n\n\n\nA. Description\nB. Should not be returned.\n"
got := extractDescription(content)
if got != "A. Description" {
t.Errorf("got %q, want %q", got, "A. Description")
}
}
// ---------------------------------------------------------------------------
// splitLines
// ---------------------------------------------------------------------------
func TestSplitLines_Basic(t *testing.T) {
got := splitLines("a\nb\nc")
want := []string{"a", "b", "c"}
if len(got) != len(want) {
t.Fatalf("len=%d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("got[%d]=%q, want %q", i, got[i], want[i])
}
}
}
func TestSplitLines_TrailingNewline(t *testing.T) {
got := splitLines("line1\nline2\n")
want := []string{"line1", "line2"}
if len(got) != len(want) {
t.Errorf("trailing newline: got %v, want %v", got, want)
}
}
func TestSplitLines_NoNewline(t *testing.T) {
got := splitLines("no newline")
want := []string{"no newline"}
if len(got) != 1 || got[0] != want[0] {
t.Errorf("got %v, want %v", got, want)
}
}
func TestSplitLines_EmptyString(t *testing.T) {
got := splitLines("")
if len(got) != 0 {
t.Errorf("empty string: got %v, want []", got)
}
}
func TestSplitLines_OnlyNewlines(t *testing.T) {
got := splitLines("\n\n\n")
// Three consecutive '\n' characters → s[start:i] at each '\n' gives
// the empty string between newlines → 3 empty segments.
// (No trailing segment because start == len(s) at the end.)
if len(got) != 3 {
t.Errorf("only newlines: got %v (len=%d), want 3 empty strings", got, len(got))
}
for i, s := range got {
if s != "" {
t.Errorf("got[%d]=%q, want empty string", i, s)
}
}
}
func TestSplitLines_MultipleConsecutiveNewlines(t *testing.T) {
got := splitLines("a\n\n\nb")
// a\n\n\nb → ["a", "", "", "b"]
if len(got) != 4 {
t.Errorf("consecutive newlines: got %v (len=%d)", got, len(got))
}
if got[0] != "a" || got[3] != "b" {
t.Errorf("first/last: got %v, want [a, ..., b]", got)
}
}
// ---------------------------------------------------------------------------
// findConfigDir
// ---------------------------------------------------------------------------
func TestFindConfigDir_NameMatch(t *testing.T) {
tmp := t.TempDir()
// Create two sub-dirs; only the one with matching name should be found.
mustMkdir(filepath.Join(tmp, "workspace-a"))
mustWrite(filepath.Join(tmp, "workspace-a", "config.yaml"),
"name: other-workspace\ntier: 1\n")
mustMkdir(filepath.Join(tmp, "workspace-b"))
mustWrite(filepath.Join(tmp, "workspace-b", "config.yaml"),
"name: target-workspace\nruntime: claude-code\n")
got := findConfigDir(tmp, "target-workspace")
want := filepath.Join(tmp, "workspace-b")
if got != want {
t.Errorf("got %q, want %q", got, want)
}
}
func TestFindConfigDir_NoMatch_UsesFallback(t *testing.T) {
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "first"))
mustWrite(filepath.Join(tmp, "first", "config.yaml"), "name: workspace-a\n")
mustMkdir(filepath.Join(tmp, "second"))
mustWrite(filepath.Join(tmp, "second", "config.yaml"), "name: workspace-b\n")
// No exact name match → fallback to the first directory with a config.yaml.
got := findConfigDir(tmp, "nonexistent")
want := filepath.Join(tmp, "first")
if got != want {
t.Errorf("no match: got %q, want fallback %q", got, want)
}
}
func TestFindConfigDir_MissingDir(t *testing.T) {
got := findConfigDir("/nonexistent/path/for/findConfigDir", "any-name")
if got != "" {
t.Errorf("missing dir: got %q, want empty string", got)
}
}
func TestFindConfigDir_NoSubdirs(t *testing.T) {
tmp := t.TempDir()
// Empty directory → no matches, no fallback.
got := findConfigDir(tmp, "any")
if got != "" {
t.Errorf("empty dir: got %q, want empty string", got)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func mustMkdir(path string) {
os.MkdirAll(path, 0o755)
}
func mustWrite(path, content string) {
os.WriteFile(path, []byte(content), 0o644)
}
// ---------------------------------------------------------------------------
// findConfigDir
// ---------------------------------------------------------------------------
func TestFindConfigDir_SubdirWithoutConfig(t *testing.T) {
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "empty-skill"))
// Sub-dir without config.yaml → skipped.
got := findConfigDir(tmp, "any")
if got != "" {
t.Errorf("no config.yaml: got %q, want empty string", got)
}
}
func TestFindConfigDir_FirstWithConfigIsFallback(t *testing.T) {
// When name doesn't match, fallback is the FIRST dir with config.yaml,
// not the last. Confirm ordering by creating three dirs.
tmp := t.TempDir()
mustMkdir(filepath.Join(tmp, "a"))
mustWrite(filepath.Join(tmp, "a", "config.yaml"), "name: alpha\n")
mustMkdir(filepath.Join(tmp, "b"))
mustWrite(filepath.Join(tmp, "b", "config.yaml"), "name: beta\n")
mustMkdir(filepath.Join(tmp, "c"))
mustWrite(filepath.Join(tmp, "c", "config.yaml"), "name: gamma\n")
got := findConfigDir(tmp, "nonexistent")
want := filepath.Join(tmp, "a") // first dir with config.yaml
if got != want {
t.Errorf("fallback order: got %q, want first-with-config %q", got, want)
}
}

Some files were not shown because too many files have changed in this diff Show More