Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ac7c395855 | |||
| 3edb68ab77 |
@@ -1 +0,0 @@
|
||||
refire:1778784369
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
@@ -127,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
|
||||
|
||||
|
||||
@@ -210,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:
|
||||
@@ -244,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]:
|
||||
@@ -345,12 +294,8 @@ 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(
|
||||
@@ -417,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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"
|
||||
Regular → Executable
+10
-13
@@ -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]
|
||||
#
|
||||
@@ -118,19 +118,17 @@ _DIRECTIVE_RE = re.compile(
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> tuple[list[tuple[str, str, str]], list]:
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
|
||||
Returns (directives, na_directives) where:
|
||||
directives is a list of (kind, canonical_slug, note) tuples
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
na_directives is reserved for future N/A handling (always [] for now)
|
||||
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 "")
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return out, []
|
||||
return out
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
@@ -161,7 +159,7 @@ def parse_directives(
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out, []
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -251,8 +249,7 @@ def compute_ack_state(
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
directives, _na = parse_directives(body, numeric_aliases)
|
||||
for kind, slug, _note in directives:
|
||||
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
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+20
-23
@@ -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")
|
||||
|
||||
|
||||
@@ -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) =="
|
||||
|
||||
@@ -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
@@ -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)")
|
||||
'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -68,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::"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,17 +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' }}
|
||||
# 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
|
||||
@@ -97,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"
|
||||
@@ -122,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."
|
||||
@@ -163,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)
|
||||
@@ -252,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`;
|
||||
@@ -270,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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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" \
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
staging trigger 2026-05-14T17:35:02Z
|
||||
staging trigger
|
||||
@@ -1 +0,0 @@
|
||||
trigger
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -212,7 +212,7 @@ function AccountBar({ session }: { session: Session }) {
|
||||
// edge cases (jsdom, blocked navigation) where it doesn't.
|
||||
setSigningOut(false);
|
||||
}}
|
||||
className="rounded border border-line bg-surface-card px-3 py-1 text-xs text-ink hover:bg-surface-card disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1"
|
||||
className="rounded border border-line bg-surface-card px-3 py-1 text-xs text-ink hover:bg-surface-card disabled:opacity-50"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
{signingOut ? "Signing out…" : "Sign out"}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,100 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
/** Org-wide broadcast banner.
|
||||
*
|
||||
* Rendered at the top of the canvas (below the toolbar) whenever the store
|
||||
* holds one or more unread BROADCAST_MESSAGE entries. Each entry shows:
|
||||
* - sender name (workspace that issued the broadcast)
|
||||
* - the message text
|
||||
* - a dismiss button
|
||||
*
|
||||
* Dismissing an entry removes it from the store via consumeBroadcastMessages.
|
||||
* The dismissed state is intentionally ephemeral — dismissed broadcasts reappear
|
||||
* on page refresh since they are not persisted server-side; this is intentional
|
||||
* (the platform's activity log already provides the audit trail).
|
||||
*/
|
||||
export function BroadcastBanner() {
|
||||
const broadcastMessages = useCanvasStore((s) => s.broadcastMessages);
|
||||
const dismissBroadcastMessage = useCanvasStore((s) => s.dismissBroadcastMessage);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(id: string) => {
|
||||
dismissBroadcastMessage(id);
|
||||
},
|
||||
[dismissBroadcastMessage],
|
||||
);
|
||||
|
||||
if (broadcastMessages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center w-full max-w-xl px-4 pointer-events-none">
|
||||
{broadcastMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="pointer-events-auto w-full bg-blue-950/80 backdrop-blur-md border border-blue-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Megaphone icon */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-7 h-7 rounded-lg bg-blue-900/50 flex items-center justify-center shrink-0 mt-0.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-300"
|
||||
>
|
||||
<path d="M3 11l18-5v12L3 13v-2z" />
|
||||
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-blue-300 font-semibold">
|
||||
Broadcast from{" "}
|
||||
<span className="text-blue-100">{msg.sender}</span>
|
||||
</div>
|
||||
<div className="text-sm text-blue-50 mt-0.5 leading-snug break-words">
|
||||
{msg.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDismiss(msg.id)}
|
||||
aria-label="Dismiss broadcast"
|
||||
className="shrink-0 w-6 h-6 rounded text-blue-400 hover:text-blue-200 hover:bg-blue-800/50 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 focus-visible:ring-offset-blue-950"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { TemplatePalette } from "./TemplatePalette";
|
||||
import { ApprovalBanner } from "./ApprovalBanner";
|
||||
import { BroadcastBanner } from "./BroadcastBanner";
|
||||
import { BundleDropZone } from "./BundleDropZone";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
@@ -368,7 +367,6 @@ function CanvasInner() {
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BroadcastBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -471,7 +471,7 @@ function ProviderPickerModal({
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
@@ -480,7 +480,7 @@ function ProviderPickerModal({
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors 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] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
>
|
||||
Cancel Deploy
|
||||
</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>
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
[]
|
||||
|
||||
@@ -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 ✕ */
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for BroadcastBanner component.
|
||||
* WCAG compliance: role=alert, aria-live=polite, per-message dismiss.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { BroadcastBanner } from "../BroadcastBanner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
const mockDismiss = vi.fn();
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: ReturnType<typeof useCanvasStore.getState>) => unknown) => {
|
||||
const state = {
|
||||
broadcastMessages: [] as Array<{
|
||||
id: string;
|
||||
senderId: string;
|
||||
sender: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}>,
|
||||
dismissBroadcastMessage: mockDismiss,
|
||||
};
|
||||
return selector(state);
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockDismiss.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const broadcastMessages = [
|
||||
{ id: "m1", senderId: "ws-ops", sender: "Ops Agent", message: "Deploy in 5 min", timestamp: "2026-05-16T00:00:00Z" },
|
||||
{ id: "m2", senderId: "ws-sre", sender: "SRE Team", message: "Maintenance window tonight", timestamp: "2026-05-16T00:01:00Z" },
|
||||
];
|
||||
|
||||
function setup(messages = broadcastMessages) {
|
||||
vi.mocked(useCanvasStore).mockImplementation(
|
||||
(selector: (s: { broadcastMessages: typeof broadcastMessages; dismissBroadcastMessage: typeof mockDismiss }) => unknown) => {
|
||||
const state = {
|
||||
broadcastMessages: messages,
|
||||
dismissBroadcastMessage: mockDismiss,
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
return render(<BroadcastBanner />);
|
||||
}
|
||||
|
||||
describe("BroadcastBanner", () => {
|
||||
it("renders nothing when there are no messages", () => {
|
||||
setup([]);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a role=alert banner for each broadcast message", () => {
|
||||
setup();
|
||||
const alerts = screen.getAllByRole("alert");
|
||||
expect(alerts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows sender name and message content", () => {
|
||||
setup();
|
||||
expect(screen.getByText("Deploy in 5 min")).toBeTruthy();
|
||||
expect(screen.getByText("Ops Agent")).toBeTruthy();
|
||||
expect(screen.getByText("Maintenance window tonight")).toBeTruthy();
|
||||
expect(screen.getByText("SRE Team")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("each banner has a dismiss button with accessible label", () => {
|
||||
setup();
|
||||
const buttons = screen.getAllByRole("button", { name: /dismiss/i });
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("dismissing a banner calls dismissBroadcastMessage with the correct id", () => {
|
||||
setup();
|
||||
const buttons = screen.getAllByRole("button", { name: /dismiss/i });
|
||||
// Dismiss the second message (Maintenance window)
|
||||
fireEvent.click(buttons[1]);
|
||||
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(mockDismiss).toHaveBeenCalledWith("m2");
|
||||
});
|
||||
|
||||
it("dismissing one banner does not dismiss others", () => {
|
||||
setup();
|
||||
const buttons = screen.getAllByRole("button", { name: /dismiss/i });
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(mockDismiss).toHaveBeenCalledWith("m1");
|
||||
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dismiss button has focus-visible ring (WCAG 2.4.7)", () => {
|
||||
setup();
|
||||
const button = screen.getAllByRole("button", { name: /dismiss/i })[0];
|
||||
expect(button.className).toContain("focus-visible:ring");
|
||||
});
|
||||
|
||||
it("sender and message text use adequate contrast color classes", () => {
|
||||
setup();
|
||||
// text-blue-300 (#93C5FD) on blue-950/80 ≈ 5.9:1 contrast — WCAG AA ✓
|
||||
const senderLabel = screen.getByText("Ops Agent").closest("div");
|
||||
expect(senderLabel?.className).toContain("text-blue-300");
|
||||
// text-blue-50 (#EFF6FF) on blue-950/80 ≈ 11.7:1 — WCAG AAA ✓
|
||||
const messageEl = screen.getByText("Deploy in 5 min");
|
||||
expect(messageEl.className).toContain("text-blue-50");
|
||||
});
|
||||
});
|
||||
@@ -73,8 +73,6 @@ const mockStoreState = {
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -102,7 +100,6 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -91,8 +91,6 @@ const mockStoreState = {
|
||||
// an empty Set mirrors the idle canvas and doesn't interact with
|
||||
// any pan/fit behaviour under test here.
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -119,7 +117,6 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -195,47 +195,6 @@ describe("DropTargetBadge — renders ghost slot + badge for valid drag target",
|
||||
expect(screen.getByTestId("ghost-slot").style.height).toBe("260px");
|
||||
});
|
||||
|
||||
it("ghost has aria-hidden=true (decorative visual affordance)", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 500 },
|
||||
});
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => {
|
||||
if (x === 210 && y === 200) return { x: 420, y: 400 };
|
||||
if (x === 116 && y === 330) return { x: 232, y: 660 };
|
||||
if (x === 356 && y === 460) return { x: 712, y: 920 };
|
||||
if (x === 100 && y === 200) return { x: 200, y: 400 };
|
||||
if (x === 320 && y === 700) return { x: 640, y: 1400 };
|
||||
return { x: x * 2, y: y * 2 };
|
||||
});
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 500 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
const ghost = screen.getByTestId("ghost-slot");
|
||||
expect(ghost.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("drop badge has role=status and aria-label including target name", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 120 },
|
||||
});
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => ({ x: x * 2, y: y * 2 }));
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [{ id: "ws-target", data: { name: "Ops Workspace" }, parentId: null }],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
const badge = screen.getByTestId("drop-badge");
|
||||
expect(badge.getAttribute("role")).toBe("status");
|
||||
expect(badge.getAttribute("aria-label")).toBe("Drop target: Ops Workspace");
|
||||
});
|
||||
|
||||
it("ghost is hidden when slot falls entirely outside parent bounds", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -205,7 +205,6 @@ export function MobileCanvas({
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
aria-label="Reset zoom"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
@@ -273,7 +272,6 @@ export function MobileCanvas({
|
||||
key={l.agent.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(l.agent.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${l.x}%`,
|
||||
@@ -378,7 +376,6 @@ export function MobileCanvas({
|
||||
type="button"
|
||||
onClick={onSpawn}
|
||||
aria-label="Spawn new agent"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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 { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
@@ -36,19 +36,6 @@ interface A2AResponseShape {
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
|
||||
interface ApiChatMessage {
|
||||
id: string;
|
||||
role: string; // "user" | "agent" | "system"
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ChatHistoryResponse {
|
||||
messages: ApiChatMessage[];
|
||||
reached_end: boolean;
|
||||
}
|
||||
|
||||
const formatTime = (date: Date) =>
|
||||
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
|
||||
@@ -62,10 +49,7 @@ export function MobileChat({
|
||||
onBack: () => 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));
|
||||
// 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
|
||||
@@ -74,18 +58,24 @@ export function MobileChat({
|
||||
// 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]);
|
||||
// Start empty — history is loaded via useEffect below.
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
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 [loading, setLoading] = useState(true); // history is loading on mount
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Guard: don't treat the initial store population as a live push.
|
||||
// Set to false after the first render completes.
|
||||
const initDoneRef = useRef(false);
|
||||
// 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);
|
||||
|
||||
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
|
||||
@@ -99,75 +89,6 @@ export function MobileChat({
|
||||
el.style.height = `${next}px`;
|
||||
}, [draft]);
|
||||
|
||||
// Fetch chat history on mount; keep merging live agentMessages while the
|
||||
// panel is open. InitDoneRef prevents the initial store snapshot from
|
||||
// triggering the live-merge path (the store buffer is populated by
|
||||
// ChatTab on desktop, not on mobile — this effect loads history as the
|
||||
// mobile-native path).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
|
||||
id: m.id,
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
});
|
||||
|
||||
const syncLive = () => {
|
||||
const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
|
||||
if (live.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const existingIds = new Set(prev.map((m) => m.id));
|
||||
const newOnes = live
|
||||
.filter((m) => !existingIds.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent" as const,
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
}));
|
||||
return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrap = async (): Promise<(() => void) | undefined> => {
|
||||
setLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const res = await api.get<ChatHistoryResponse>(
|
||||
`/workspaces/${agentId}/chat-history?limit=50`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const initial = (res.messages ?? []).map(mapApiMessage);
|
||||
setMessages(initial);
|
||||
// Mark init done BEFORE marking loading=false so any store push
|
||||
// that arrives in the same tick is treated as live, not init.
|
||||
initDoneRef.current = true;
|
||||
setLoading(false);
|
||||
// Subscribe to live pushes after init is complete.
|
||||
syncLive();
|
||||
const unsubscribe = useCanvasStore.subscribe(syncLive);
|
||||
return unsubscribe; // returned for cleanup
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let maybeUnsubscribe: (() => void) | undefined;
|
||||
bootstrap().then((fn) => { maybeUnsubscribe = fn; });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (maybeUnsubscribe) maybeUnsubscribe();
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
@@ -198,6 +119,8 @@ export function MobileChat({
|
||||
const send = async () => {
|
||||
const text = draft.trim();
|
||||
if (!text || sending || !reachable) return;
|
||||
if (sendInFlightRef.current) return;
|
||||
sendInFlightRef.current = true;
|
||||
setDraft("");
|
||||
setError(null);
|
||||
setSending(true);
|
||||
@@ -239,12 +162,12 @@ export function MobileChat({
|
||||
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",
|
||||
@@ -267,7 +190,6 @@ export function MobileChat({
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -314,7 +236,6 @@ export function MobileChat({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -345,7 +266,6 @@ export function MobileChat({
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
padding: "4px 0 8px",
|
||||
border: "none",
|
||||
@@ -388,63 +308,7 @@ export function MobileChat({
|
||||
Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab.
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && loading && (
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div style={{ marginBottom: 6, opacity: 0.6, animation: "spin 1s linear infinite", display: "inline-block", fontSize: 16 }}>⟳</div>
|
||||
<div>Loading chat history…</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !loading && historyError && (
|
||||
<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"
|
||||
aria-label="Retry loading chat history"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setHistoryError(null);
|
||||
api.get(`/workspaces/${agentId}/chat-history?limit=50`).then(
|
||||
(res: unknown) => {
|
||||
const r = res as ChatHistoryResponse;
|
||||
setMessages((r.messages ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
})));
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
},
|
||||
).catch((e: unknown) => {
|
||||
setHistoryError(e instanceof Error ? e.message : "Failed to load");
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
});
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-failed,#ef4444)] focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
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" && !loading && !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>
|
||||
@@ -545,7 +409,6 @@ export function MobileChat({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Attach"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -584,7 +447,6 @@ export function MobileChat({
|
||||
placeholder={reachable ? "Send a message…" : `Agent is ${a.status}`}
|
||||
disabled={!reachable}
|
||||
rows={1}
|
||||
className="focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1"
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
@@ -606,7 +468,6 @@ export function MobileChat({
|
||||
onClick={send}
|
||||
disabled={!draft.trim() || !reachable || sending}
|
||||
aria-label="Send"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
|
||||
@@ -218,7 +218,6 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(o.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -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) {
|
||||
@@ -83,12 +80,11 @@ export function MobileDetail({
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={iconButtonStyle(p, dark)}
|
||||
>
|
||||
{Icons.back({ size: 18 })}
|
||||
</button>
|
||||
<button type="button" aria-label="More" className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900" style={iconButtonStyle(p, dark)}>
|
||||
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
|
||||
{Icons.more({ size: 18 })}
|
||||
</button>
|
||||
</div>
|
||||
@@ -184,7 +180,6 @@ export function MobileDetail({
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: 999,
|
||||
@@ -216,8 +211,6 @@ export function MobileDetail({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChat}
|
||||
data-testid="mobile-chat-cta"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
|
||||
@@ -183,7 +183,6 @@ export function MobileHome({
|
||||
type="button"
|
||||
onClick={onSpawn}
|
||||
aria-label="Spawn new agent"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
|
||||
@@ -83,7 +83,6 @@ export function MobileMe({
|
||||
type="button"
|
||||
onClick={() => setAccent(c)}
|
||||
aria-label={`Set accent ${c}`}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -174,7 +173,6 @@ function SegmentedRow({
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onChange(o.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
|
||||
@@ -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,
|
||||
@@ -148,7 +146,6 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -206,17 +203,15 @@ 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}
|
||||
type="button"
|
||||
aria-label={`Select template: ${t.name} (tier ${t.tier})`}
|
||||
onClick={() => {
|
||||
setTplId(t.id);
|
||||
setTier(tCode);
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
background: on
|
||||
? dark
|
||||
@@ -332,10 +327,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
aria-label={`Select tier ${t}: ${TIER_LABEL[t]}`}
|
||||
aria-pressed={tier === t}
|
||||
onClick={() => setTier(t)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
@@ -381,10 +373,8 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<div style={{ padding: "20px 14px max(env(safe-area-inset-bottom), 28px)" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Spawn agent"
|
||||
onClick={handleSpawn}
|
||||
disabled={busy || !tplId || templates.length === 0}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
|
||||
@@ -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(
|
||||
`/workspaces/${mockAgentId}/chat-history?limit=50`,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -133,7 +133,6 @@ export function TabBar({
|
||||
aria-label={t.label}
|
||||
onClick={() => onChange(t.id)}
|
||||
onKeyDown={(e) => handleKeyDown(e, idx)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
@@ -289,10 +288,8 @@ 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}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
@@ -446,7 +443,6 @@ export function FilterChips({
|
||||
type="button"
|
||||
aria-checked={on}
|
||||
onClick={() => onChange(o.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -139,7 +139,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
aria-pressed={filter === f.id}
|
||||
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
|
||||
filter === f.id
|
||||
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
||||
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
|
||||
@@ -152,7 +152,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
aria-pressed={autoRefresh}
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
||||
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-mid"
|
||||
}`}
|
||||
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
|
||||
@@ -161,9 +161,8 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTraceOpen(true)}
|
||||
aria-label="Full trace"
|
||||
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
title="View full conversation trace"
|
||||
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30"
|
||||
title="View full conversation trace across all workspaces"
|
||||
>
|
||||
Full Trace
|
||||
</button>
|
||||
@@ -308,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>
|
||||
)}
|
||||
@@ -359,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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -331,9 +331,8 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
aria-label={showManualInput ? "Hide manual input" : "Show manual input"}
|
||||
onClick={() => setShowManualInput(!showManualInput)}
|
||||
className="text-[10px] text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="text-[10px] text-accent hover:underline"
|
||||
>
|
||||
{showManualInput ? "hide manual input" : "edit manually"}
|
||||
</button>
|
||||
@@ -409,16 +408,15 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
aria-label={testing === ch.id ? "Sent!" : "Test channel"}
|
||||
onClick={() => handleTest(ch)}
|
||||
disabled={testing === ch.id}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50"
|
||||
>
|
||||
{testing === ch.id ? "Sent!" : "Test"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(ch)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition ${
|
||||
ch.enabled
|
||||
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
|
||||
: "bg-surface-card/50 text-ink-mid hover:text-ink-mid"
|
||||
@@ -427,9 +425,8 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
{ch.enabled ? "On" : "Off"}
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Remove ${ch.config.chat_id || ch.config.channel_id || "channel"}`}
|
||||
onClick={() => setPendingDelete(ch)}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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: [] },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -45,54 +45,11 @@ export function FilesTab({ workspaceId, data }: Props) {
|
||||
if (data && isExternalLikeRuntime(data.runtime)) {
|
||||
return <NotAvailablePanel runtime={data.runtime} />;
|
||||
}
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} runtime={data?.runtime} />;
|
||||
return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
/** Picks the initial root for the FilesTab dropdown based on the
|
||||
* workspace's runtime. Decision: per-runtime default (Hongming
|
||||
* 2026-05-15, internal#425 Decisions §2).
|
||||
*
|
||||
* - openclaw → `/agent-home` (the agent's identity/state — the
|
||||
* user-facing interesting files for that runtime live in
|
||||
* `~/.openclaw/` inside the container, which `/agent-home` maps to
|
||||
* via the Phase 2b docker-exec backend).
|
||||
* - everything else (claude-code, hermes, external-like, undefined)
|
||||
* → `/configs` (the legacy default — managed config that flows
|
||||
* through the per-runtime indirection in
|
||||
* workspace-server/internal/handlers/template_files_eic.go).
|
||||
*
|
||||
* When the runtime is undefined (legacy callers that don't thread
|
||||
* `data` through, or a workspace whose runtime field hasn't loaded
|
||||
* yet) the default is `/configs` — matches today's behaviour, no
|
||||
* surprise.
|
||||
*
|
||||
* Note on `/agent-home` pre-Phase-2b: the backend short-circuits
|
||||
* with HTTP 501 and the canonical "implementation pending" body.
|
||||
* The tab renders empty + the error banner explains. This is by
|
||||
* design — lets us land the canvas UX before the backend ships,
|
||||
* per the RFC's phased rollout. The 501 is graceful: it doesn't
|
||||
* poison error toasts or generate "workspace not found" noise.
|
||||
*
|
||||
* Adding a new runtime that should default to `/agent-home`: add it
|
||||
* to the agentHomeDefaultRuntimes set below. Adding a runtime that
|
||||
* should default to a different root: extend this function. */
|
||||
const agentHomeDefaultRuntimes = new Set(["openclaw"]);
|
||||
|
||||
function defaultRootForRuntime(runtime: string | undefined): string {
|
||||
if (runtime && agentHomeDefaultRuntimes.has(runtime)) {
|
||||
return "/agent-home";
|
||||
}
|
||||
return "/configs";
|
||||
}
|
||||
|
||||
function PlatformOwnedFilesTab({
|
||||
workspaceId,
|
||||
runtime,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
runtime?: string;
|
||||
}) {
|
||||
const [root, setRoot] = useState(() => defaultRootForRuntime(runtime));
|
||||
function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
|
||||
const [root, setRoot] = useState("/configs");
|
||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||
const [fileContent, setFileContent] = useState("");
|
||||
const [editContent, setEditContent] = useState("");
|
||||
@@ -269,7 +226,7 @@ function PlatformOwnedFilesTab({
|
||||
<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>
|
||||
@@ -283,7 +240,7 @@ function PlatformOwnedFilesTab({
|
||||
<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>
|
||||
|
||||
@@ -3,22 +3,6 @@
|
||||
import { useRef } from "react";
|
||||
import { getIcon } from "./tree";
|
||||
|
||||
// secretShapeMarker is the canonical body the workspace-server Files
|
||||
// API returns when a file's path OR content matched a credential
|
||||
// regex (internal#425 RFC, Phase 2b — backed by
|
||||
// workspace-server/internal/secrets.ScanBytes). The marker is a
|
||||
// fixed prefix so the canvas can detect it without parsing JSON and
|
||||
// without round-tripping the matched bytes through the editor (which
|
||||
// would defeat the purpose — clipboard, browser history, log
|
||||
// surfaces would all see them).
|
||||
//
|
||||
// Today (Phase 1 / before 2b ships) the backend returns 501 for the
|
||||
// only root that uses this path, so the marker is dead code until
|
||||
// 2b lands. Wiring it in now keeps the canvas + backend contracts
|
||||
// aligned in one PR rather than a follow-up. The constant is
|
||||
// importable so a future test can pin the exact string.
|
||||
export const SECRET_SHAPE_DENIED_MARKER = "<denied: secret-shape>";
|
||||
|
||||
interface Props {
|
||||
selectedFile: string | null;
|
||||
fileContent: string;
|
||||
@@ -47,22 +31,6 @@ export function FileEditor({
|
||||
const editorRef = useRef<HTMLTextAreaElement>(null);
|
||||
const isDirty = editContent !== fileContent;
|
||||
|
||||
// internal#425 Phase 3: detect the secret-shape denial marker and
|
||||
// render a placeholder instead of the editor. The marker comes
|
||||
// from workspace-server Phase 2b (secrets.ScanBytes) which refuses
|
||||
// to surface the file's bytes. We deliberately don't expose
|
||||
// the matched pattern's Name here — the canvas just shows the
|
||||
// generic denial. The Files API log surface has the Pattern.Name
|
||||
// for operators who need to debug a false positive.
|
||||
const isSecretShapeDenied = fileContent === SECRET_SHAPE_DENIED_MARKER;
|
||||
|
||||
// /agent-home is read-only from the canvas (Phase 2b ships read +
|
||||
// delete; Phase-2b-followup may add write). Edits to /configs are
|
||||
// unchanged. Until 2b ships, /agent-home returns 501 so this
|
||||
// read-only gate is also dead code, but wiring it in now keeps
|
||||
// the UI honest the moment 2b lands without a follow-up canvas PR.
|
||||
const isReadOnlyRoot = root !== "/configs";
|
||||
|
||||
if (!selectedFile) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
@@ -88,7 +56,7 @@ export function FileEditor({
|
||||
<button
|
||||
onClick={onDownload}
|
||||
aria-label="Download file"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 rounded transition-colors"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
@@ -96,7 +64,7 @@ export function FileEditor({
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="text-[10px] text-accent hover:text-accent disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 rounded transition-colors"
|
||||
className="text-[10px] text-accent hover:text-accent disabled:opacity-30"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
@@ -107,42 +75,11 @@ export function FileEditor({
|
||||
{/* Editor area */}
|
||||
{loadingFile ? (
|
||||
<div className="p-4 text-xs text-ink-mid">Loading...</div>
|
||||
) : isSecretShapeDenied ? (
|
||||
// Files API refused to surface this file's bytes because its
|
||||
// path or content matched a credential regex
|
||||
// (workspace-server/internal/secrets, internal#425 Phase 2b).
|
||||
// We render a placeholder INSTEAD OF the textarea so the
|
||||
// matched bytes never enter the DOM. Clipboard / view-source
|
||||
// / element-inspector all see the placeholder, not the
|
||||
// credential.
|
||||
<div
|
||||
role="region"
|
||||
aria-label="File content denied"
|
||||
className="flex-1 flex items-center justify-center p-6 bg-surface"
|
||||
>
|
||||
<div className="max-w-md text-center space-y-2">
|
||||
<div className="text-2xl opacity-40">🛡️</div>
|
||||
<p className="text-[11px] font-mono text-warm">
|
||||
{SECRET_SHAPE_DENIED_MARKER}
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-mid leading-relaxed">
|
||||
The platform refused to surface this file because its
|
||||
path or content matched a credential-shape pattern.
|
||||
The bytes never left the workspace container.
|
||||
</p>
|
||||
<p className="text-[10px] text-ink-mid leading-relaxed">
|
||||
If this is a false positive (test fixture, docs example,
|
||||
or content that happens to share a credential's shape),
|
||||
rename the file or adjust the content via the workspace
|
||||
terminal so the regex no longer matches, then refresh.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
ref={editorRef}
|
||||
value={editContent}
|
||||
readOnly={isReadOnlyRoot}
|
||||
readOnly={root !== "/configs"}
|
||||
onChange={(e) => setEditContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||
|
||||
@@ -32,21 +32,12 @@ 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>
|
||||
<option value="/workspace">/workspace</option>
|
||||
<option value="/plugins">/plugins</option>
|
||||
{/* internal#425 Phase 1+3: container-internal $HOME root.
|
||||
Backend lands the docker-exec dispatch in Phase 2b. Until
|
||||
then the stub returns 501 with a canonical
|
||||
"implementation pending" message — the dropdown renders
|
||||
the option so the canvas affordance is design-frozen
|
||||
even before the backend ships.
|
||||
Runtime-default selection logic in FilesTab.tsx picks
|
||||
this as the initial value for openclaw workspaces. */}
|
||||
<option value="/agent-home">/agent-home</option>
|
||||
</select>
|
||||
<span className="text-[10px] text-ink-mid">{fileCount} files</span>
|
||||
</div>
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for FileTree — complements FileTreeContextMenu.test.tsx with:
|
||||
* - Empty tree render
|
||||
* - File row: icon, name, selection highlight
|
||||
* - Directory row: folder icon, expand/collapse chevron, loading indicator
|
||||
* - Directory expand/collapse via click
|
||||
* - File select callback
|
||||
* - Delete button: aria-label, stopPropagation
|
||||
* - Drop-target highlight (drag hover)
|
||||
* - Context menu opens on right-click
|
||||
* - Nested tree: recursive rendering
|
||||
* - WCAG: aria-label on all interactive elements
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, createEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
// ── Mock FileTreeContextMenu (rendered by FileTree on right-click) ─────────────
|
||||
vi.mock("../FileTreeContextMenu", () => ({
|
||||
FileTreeContextMenu: ({ items }: { items: Array<{ id: string; label: string; disabled?: boolean }>; onClose: () => void }) => (
|
||||
<div data-testid="file-context-menu">
|
||||
{items.map((item, i) => (
|
||||
<button key={item.id} data-menu-id={item.id} role="menuitem" disabled={item.disabled}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Import component + types AFTER mocks ────────────────────────────────────────
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Test helpers ───────────────────────────────────────────────────────────────
|
||||
const makeNode = (
|
||||
name: string,
|
||||
opts: Partial<{
|
||||
isDir: boolean;
|
||||
path: string;
|
||||
children: TreeNode[];
|
||||
}>
|
||||
): TreeNode => ({
|
||||
name,
|
||||
path: opts.path ?? `/${name}`,
|
||||
isDir: opts.isDir ?? false,
|
||||
children: opts.children ?? [],
|
||||
size: 0,
|
||||
});
|
||||
|
||||
const EMPTY_CALLBACKS = {
|
||||
selectedPath: null as string | null,
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
canDelete: true,
|
||||
expandedDirs: new Set<string>(),
|
||||
onToggleDir: vi.fn(),
|
||||
loadingDir: null as string | null,
|
||||
};
|
||||
|
||||
describe("FileTree — empty render", () => {
|
||||
it("renders nothing when nodes is an empty array", () => {
|
||||
render(<FileTree nodes={[]} {...EMPTY_CALLBACKS} />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — file row", () => {
|
||||
it("renders a file row with the file name", () => {
|
||||
const file = makeNode("config.yaml", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
expect(screen.getByText("config.yaml")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders file icon via getIcon (📜 for .yaml)", () => {
|
||||
const file = makeNode("README.md", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
// Icon is a span with the emoji
|
||||
const icon = document.querySelector('[class*="gap-1"] span');
|
||||
expect(icon?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("file row has aria-label on the delete button", () => {
|
||||
const file = makeNode("script.py", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const delBtn = document.querySelector('button[aria-label="Delete script.py"]');
|
||||
expect(delBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking a file row calls onSelect with the file path", () => {
|
||||
const onSelect = vi.fn();
|
||||
const file = makeNode("app.ts", { path: "/src/app.ts", isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} selectedPath={null} onSelect={onSelect} />);
|
||||
fireEvent.click(screen.getByText("app.ts"));
|
||||
expect(onSelect).toHaveBeenCalledWith("/src/app.ts");
|
||||
});
|
||||
|
||||
it("selected file has different background class than unselected", () => {
|
||||
const file = makeNode("main.py", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} selectedPath="/main.py" />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
expect(row).toBeTruthy();
|
||||
// bg-blue-900/30 is applied when selected
|
||||
expect(row.className).toContain("bg-blue-900/30");
|
||||
});
|
||||
|
||||
it("clicking the delete button calls onDelete (stops propagation)", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const file = makeNode("temp.txt", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} onSelect={onSelect} onDelete={onDelete} />);
|
||||
const delBtn = screen.getByRole("button", { name: /Delete temp\.txt/i });
|
||||
fireEvent.click(delBtn);
|
||||
expect(onDelete).toHaveBeenCalledWith("/temp.txt");
|
||||
// onSelect should NOT be called (stopPropagation)
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — directory row", () => {
|
||||
it("renders a directory row with 📁 icon and directory name", () => {
|
||||
const dir = makeNode("src", { isDir: true, path: "/src" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} />);
|
||||
expect(screen.getByText("src")).toBeTruthy();
|
||||
expect(screen.getByText("📁")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows ▶ chevron when collapsed", () => {
|
||||
const dir = makeNode("lib", { isDir: true, path: "/lib" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} />);
|
||||
// collapsed → ▶
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows ▼ chevron when expanded", () => {
|
||||
const dir = makeNode("lib", { isDir: true, path: "/lib" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/lib"])} />);
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows … (loading indicator) when loadingDir matches", () => {
|
||||
const dir = makeNode("pkg", { isDir: true, path: "/pkg" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} loadingDir="/pkg" expandedDirs={new Set(["/pkg"])} />);
|
||||
expect(screen.getByText("…")).toBeTruthy();
|
||||
// Chevron is replaced by loading indicator
|
||||
expect(screen.queryByText("▼")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking a collapsed directory calls onToggleDir", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const dir = makeNode("docs", { isDir: true, path: "/docs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} onToggleDir={onToggleDir} />);
|
||||
fireEvent.click(screen.getByText("docs"));
|
||||
expect(onToggleDir).toHaveBeenCalledWith("/docs");
|
||||
});
|
||||
|
||||
it("clicking an expanded directory calls onToggleDir to collapse", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const dir = makeNode("docs", { isDir: true, path: "/docs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/docs"])} onToggleDir={onToggleDir} />);
|
||||
fireEvent.click(screen.getByText("docs"));
|
||||
expect(onToggleDir).toHaveBeenCalledWith("/docs");
|
||||
});
|
||||
|
||||
it("expanded directory renders its children recursively", () => {
|
||||
const childFile = makeNode("index.ts", { isDir: false, path: "/src/index.ts" });
|
||||
const dir = makeNode("src", { isDir: true, path: "/src", children: [childFile] });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/src"])} />);
|
||||
expect(screen.getByText("index.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapsed directory does NOT render its children", () => {
|
||||
const childFile = makeNode("inner.ts", { isDir: false, path: "/outer/inner.ts" });
|
||||
const dir = makeNode("outer", { isDir: true, path: "/outer", children: [childFile] });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} />);
|
||||
expect(screen.queryByText("inner.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("directory delete button calls onDelete", () => {
|
||||
const onDelete = vi.fn();
|
||||
const dir = makeNode("cache", { isDir: true, path: "/cache" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} onDelete={onDelete} />);
|
||||
const delBtn = screen.getByRole("button", { name: /Delete cache/i });
|
||||
fireEvent.click(delBtn);
|
||||
expect(onDelete).toHaveBeenCalledWith("/cache");
|
||||
});
|
||||
|
||||
it("directory delete button in context menu is disabled when canDelete=false", () => {
|
||||
const dir = makeNode("locked", { isDir: true, path: "/locked" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} canDelete={false} />);
|
||||
// Right-click to open context menu
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
// Query inside the context menu — use role=menuitem (real component uses this)
|
||||
// and verify the disabled attribute (vitest-compatible, no jest-dom needed)
|
||||
const ctxMenu = screen.getByTestId("file-context-menu");
|
||||
const delBtn = ctxMenu.querySelector('button[role="menuitem"]') as HTMLButtonElement | null;
|
||||
expect(delBtn).not.toBeNull();
|
||||
expect(delBtn!.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — context menu", () => {
|
||||
it("right-clicking a file opens the context menu", () => {
|
||||
const file = makeNode("data.json", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByTestId("file-context-menu")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("context menu shows 'Open' and 'Download' for a file", () => {
|
||||
const file = makeNode("report.csv", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByText("Open")).toBeTruthy();
|
||||
expect(screen.getByText("Download")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("context menu shows only 'Delete' for a directory (no Open/Download)", () => {
|
||||
const dir = makeNode("logs", { isDir: true, path: "/logs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByText("Delete")).toBeTruthy();
|
||||
expect(screen.queryByText("Open")).toBeNull();
|
||||
expect(screen.queryByText("Download")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — drag-drop target highlight (PR-D)", () => {
|
||||
it("directory row handles dragOver without crashing", () => {
|
||||
const onDropToTarget = vi.fn();
|
||||
const dir = makeNode("dropdir", { isDir: true, path: "/dropdir" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} onDropToTarget={onDropToTarget} expandedDirs={new Set()} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
expect(row).toBeTruthy();
|
||||
// jsdom's DragEvent is not available; use RTL's createEvent + dispatchEvent
|
||||
// and stub dataTransfer so the handler's e.dataTransfer.dropEffect = "copy"
|
||||
// assignment inside FileTree doesn't throw.
|
||||
const dragOverEvent = createEvent.dragOver(row);
|
||||
Object.defineProperty(dragOverEvent, "dataTransfer", {
|
||||
value: { dropEffect: "none" },
|
||||
});
|
||||
row.dispatchEvent(dragOverEvent);
|
||||
// Component should still show the node without crashing.
|
||||
expect(screen.queryByText("dropdir")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("non-directory rows do not crash when onDropToTarget is provided", () => {
|
||||
const onDropToTarget = vi.fn();
|
||||
const file = makeNode("data.csv", { isDir: false, path: "/data.csv" });
|
||||
// Should render without error even with onDropToTarget (files ignore it)
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} onDropToTarget={onDropToTarget} expandedDirs={new Set()} />);
|
||||
expect(screen.getByText("data.csv")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — nested tree", () => {
|
||||
it("three-level deep tree renders all three levels", () => {
|
||||
const level3 = makeNode("deep.ts", { isDir: false, path: "/a/b/c/deep.ts" });
|
||||
const level2 = makeNode("b", { isDir: true, path: "/a/b", children: [level3] });
|
||||
const level1 = makeNode("a", { isDir: true, path: "/a", children: [level2] });
|
||||
render(<FileTree nodes={[level1]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/a", "/a/b"])} />);
|
||||
expect(screen.getByText("a")).toBeTruthy();
|
||||
expect(screen.getByText("b")).toBeTruthy();
|
||||
expect(screen.getByText("deep.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("only renders expanded paths — /a expanded but /a/b collapsed hides level 3", () => {
|
||||
const level3 = makeNode("secret.ts", { isDir: false, path: "/a/b/secret.ts" });
|
||||
const level2 = makeNode("b", { isDir: true, path: "/a/b", children: [level3] });
|
||||
const level1 = makeNode("a", { isDir: true, path: "/a", children: [level2] });
|
||||
render(<FileTree nodes={[level1]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/a"])} />);
|
||||
// "a" is expanded: shows name + "b" as a collapsed child
|
||||
expect(screen.getByText("a")).toBeTruthy();
|
||||
expect(screen.getByText("▶")).toBeTruthy(); // "b" is collapsed (▶ not ▼)
|
||||
// "secret.ts" is NOT rendered because /a/b is not expanded
|
||||
expect(screen.queryByText("secret.ts")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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,181 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for the /agent-home root selector + per-runtime default-root
|
||||
* + secret-shape denial placeholder (internal#425 Phase 3).
|
||||
*
|
||||
* Separate file so the diff is reviewable as a unit and the existing
|
||||
* FilesToolbar / FileEditor / FilesTab tests don't have to grow
|
||||
* agent-home-specific cases. Once Phase 2b lands, the read-only +
|
||||
* 501-stub assertions here can be tightened (or moved into the main
|
||||
* test file as the agent-home root becomes a first-class affordance).
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
import {
|
||||
FileEditor,
|
||||
SECRET_SHAPE_DENIED_MARKER,
|
||||
} from "../FileEditor";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("internal#425 Phase 3 — /agent-home root selector", () => {
|
||||
it("dropdown includes /agent-home as an option", () => {
|
||||
// Pins the affordance is in the DOM even pre-Phase-2b — the
|
||||
// canvas design freezes today, the backend lands the dispatch
|
||||
// later. Without this, a future refactor that drops the option
|
||||
// would silently regress the RFC's Phase 1 contract (canvas
|
||||
// visibility) without breaking any other test.
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole("combobox", {
|
||||
name: /file root directory/i,
|
||||
}) as HTMLSelectElement;
|
||||
const values = Array.from(select.options).map((o) => o.value);
|
||||
expect(values).toContain("/agent-home");
|
||||
});
|
||||
|
||||
it("dropdown shows /agent-home as the SELECTED root when prop is /agent-home", () => {
|
||||
render(
|
||||
<FilesToolbar
|
||||
root="/agent-home"
|
||||
setRoot={vi.fn()}
|
||||
fileCount={0}
|
||||
onNewFile={vi.fn()}
|
||||
onUpload={vi.fn()}
|
||||
onDownloadAll={vi.fn()}
|
||||
onClearAll={vi.fn()}
|
||||
onRefresh={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const select = screen.getByRole("combobox", {
|
||||
name: /file root directory/i,
|
||||
}) as HTMLSelectElement;
|
||||
expect(select.value).toBe("/agent-home");
|
||||
});
|
||||
});
|
||||
|
||||
describe("internal#425 Phase 3 — secret-shape denial placeholder", () => {
|
||||
// Files API Phase 2b returns SECRET_SHAPE_DENIED_MARKER as the file
|
||||
// body when the file's path or content matched a credential regex.
|
||||
// The editor MUST render the marker as a placeholder, not pump it
|
||||
// through the textarea — that would put the marker (and any future
|
||||
// matched bytes if the backend contract changes) into the DOM
|
||||
// value, clipboard, and inspector.
|
||||
|
||||
it("renders the denial placeholder INSTEAD of the textarea when fileContent is the marker", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="agent/.openclaw/secrets.env"
|
||||
fileContent={SECRET_SHAPE_DENIED_MARKER}
|
||||
editContent={SECRET_SHAPE_DENIED_MARKER}
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/agent-home"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
// Placeholder region present
|
||||
expect(
|
||||
screen.getByRole("region", { name: /file content denied/i }),
|
||||
).toBeTruthy();
|
||||
// Marker text visible (so a debugging operator sees the canonical
|
||||
// contract string without having to dig into the source).
|
||||
expect(screen.getByText(SECRET_SHAPE_DENIED_MARKER)).toBeTruthy();
|
||||
// Critically: NO textarea — the bytes never reach a controlled
|
||||
// input. A regression that re-introduces the textarea path would
|
||||
// make the matched marker (and any future content) selectable +
|
||||
// copyable.
|
||||
expect(screen.queryByRole("textbox")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the textarea normally when fileContent is regular content", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="config.yaml"
|
||||
fileContent="name: openclaw\n"
|
||||
editContent="name: openclaw\n"
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/configs"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("textbox")).toBeTruthy();
|
||||
expect(screen.queryByRole("region", { name: /file content denied/i }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
it("/agent-home renders textarea READ-ONLY for non-denied content", () => {
|
||||
// Phase 2b ships read + delete on /agent-home; write semantics
|
||||
// are decided later. Until then, the canvas presents the editor
|
||||
// as read-only so a user can't type into a buffer that the
|
||||
// backend will refuse to PUT. Without this gate, the user would
|
||||
// edit, hit Save, get a 501, and lose their context for why.
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile=".openclaw/agent-card.json"
|
||||
fileContent='{"name":"openclaw"}'
|
||||
editContent='{"name":"openclaw"}'
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/agent-home"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
it("/configs renders textarea WRITABLE (regression guard for the read-only gate)", () => {
|
||||
render(
|
||||
<FileEditor
|
||||
selectedFile="config.yaml"
|
||||
fileContent="name: x\n"
|
||||
editContent="name: x\n"
|
||||
setEditContent={vi.fn()}
|
||||
loadingFile={false}
|
||||
saving={false}
|
||||
success={null}
|
||||
root="/configs"
|
||||
onSave={vi.fn()}
|
||||
onDownload={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
|
||||
expect(textarea.readOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("internal#425 Phase 3 — marker constant is the canonical string", () => {
|
||||
// The marker string is part of the canvas <-> workspace-server
|
||||
// contract. The workspace-server emits this exact body; the canvas
|
||||
// detects it by exact-equality. A typo on either side would
|
||||
// silently break detection — the canvas would render the literal
|
||||
// string in the textarea instead of the placeholder. Pin the
|
||||
// contract value here.
|
||||
it("matches the contract value '<denied: secret-shape>'", () => {
|
||||
expect(SECRET_SHAPE_DENIED_MARKER).toBe("<denied: secret-shape>");
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
>
|
||||
✕
|
||||
|
||||
@@ -325,7 +325,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRegistry(true)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
aria-expanded="false"
|
||||
aria-controls="plugins-section"
|
||||
>
|
||||
@@ -349,7 +349,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRegistry(!showRegistry)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
aria-expanded={showRegistry}
|
||||
aria-controls="plugins-registry"
|
||||
>
|
||||
@@ -401,7 +401,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
onClick={() => handleUninstall(p.name)}
|
||||
disabled={uninstalling === p.name}
|
||||
className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[11px] text-bad hover:bg-red-900/30 disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[11px] text-bad hover:bg-red-900/30 disabled:opacity-30"
|
||||
>
|
||||
{uninstalling === p.name ? "..." : "Remove"}
|
||||
</button>
|
||||
@@ -449,7 +449,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
onClick={handleInstallCustom}
|
||||
disabled={!customSource.trim() || installing !== null}
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-1 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-1 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
|
||||
>
|
||||
{installing === customSource.trim() ? "Installing..." : "Install"}
|
||||
</button>
|
||||
@@ -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>
|
||||
@@ -538,7 +538,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
onClick={() => handleInstall(p.name)}
|
||||
disabled={installing === p.name}
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
|
||||
>
|
||||
{installing === p.name ? "Installing..." : "Install"}
|
||||
</button>
|
||||
@@ -570,13 +570,13 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setPanelTab("config")}
|
||||
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken"
|
||||
>
|
||||
Open Config
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPanelTab("files")}
|
||||
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken"
|
||||
>
|
||||
Open Files
|
||||
</button>
|
||||
|
||||
@@ -405,7 +405,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
||||
</p>
|
||||
<button
|
||||
onClick={loadInitial}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -610,7 +610,7 @@ function PeerTabButton({
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={onClick}
|
||||
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 ${
|
||||
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap ${
|
||||
active
|
||||
? "border-b-2 border-cyan-500 text-cyan-200"
|
||||
: "border-b-2 border-transparent text-ink-mid hover:text-ink-mid"
|
||||
|
||||
@@ -33,7 +33,7 @@ export function PendingAttachmentPill({
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${file.name}`}
|
||||
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
@@ -63,7 +63,7 @@ export function AttachmentChip({
|
||||
<button
|
||||
onClick={() => onDownload(attachment)}
|
||||
title={`Download ${attachment.name}`}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-colors max-w-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${toneClasses}`}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-colors max-w-full ${toneClasses}`}
|
||||
>
|
||||
<FileGlyph className="shrink-0 opacity-70" />
|
||||
<span className="truncate">{attachment.name}</span>
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -53,10 +53,9 @@ function makeStore(
|
||||
edges: Edge[] = [],
|
||||
selectedNodeId: string | null = null,
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>> = {},
|
||||
liveAnnouncement = "",
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }> = []
|
||||
liveAnnouncement = ""
|
||||
) {
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement, broadcastMessages };
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement };
|
||||
const get = () => state;
|
||||
const set = vi.fn((partial: Record<string, unknown>) => {
|
||||
Object.assign(state, partial);
|
||||
@@ -1014,149 +1013,3 @@ describe("handleCanvasEvent – liveAnnouncement", () => {
|
||||
expect(state.liveAnnouncement ?? "").toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BROADCAST_MESSAGE
|
||||
//
|
||||
// Verifies that incoming org-wide broadcast WebSocket events are captured
|
||||
// in the store's broadcastMessages array and announced via liveAnnouncement
|
||||
// for screen readers. The Go platform already HTML-escaped the content at
|
||||
// broadcast time (OFFSEC-015 fix), so the handler renders it as-is.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("handleCanvasEvent – BROADCAST_MESSAGE", () => {
|
||||
it("appends a broadcast message to broadcastMessages with correct fields", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "All systems go — deploy in 5 minutes",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).toHaveBeenCalledOnce();
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(1);
|
||||
expect(next.broadcastMessages[0].senderId).toBe("ws-ops");
|
||||
expect(next.broadcastMessages[0].sender).toBe("Ops Agent");
|
||||
expect(next.broadcastMessages[0].message).toBe("All systems go — deploy in 5 minutes");
|
||||
expect(next.broadcastMessages[0].id).toBeTruthy(); // crypto.randomUUID() called
|
||||
expect(next.broadcastMessages[0].timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets liveAnnouncement with sender and truncated message", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { liveAnnouncement: string };
|
||||
expect(next.liveAnnouncement).toBe("Broadcast from Ops Agent: Deploy starting now");
|
||||
});
|
||||
|
||||
it("renders sender name as truncated ID when sender field is absent", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].sender).toBe("ws-ops".slice(0, 8)); // fallback: first 8 chars of ID
|
||||
});
|
||||
|
||||
it("is a no-op when message is empty string", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends to existing broadcastMessages without replacing them", () => {
|
||||
const { get, set, state } = makeStore([], [], null, {}, "", [
|
||||
{
|
||||
id: "existing-1",
|
||||
senderId: "ws-old",
|
||||
sender: "Old Agent",
|
||||
message: "Previous broadcast",
|
||||
timestamp: "2026-05-14T12:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "New broadcast" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(2);
|
||||
expect(next.broadcastMessages[0].id).toBe("existing-1");
|
||||
expect(next.broadcastMessages[1].message).toBe("New broadcast");
|
||||
});
|
||||
|
||||
it("handles XSS-like content safely (content is pre-escaped by Go platform)", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
// The Go platform applied html.EscapeString before sending, so the handler
|
||||
// receives literal strings, not raw HTML. This test verifies no panic and
|
||||
// correct storage.
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-evil",
|
||||
payload: {
|
||||
sender_id: "ws-evil",
|
||||
sender: "Evil Sender",
|
||||
message: "<script>alert('xss')</script>",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].message).toBe("<script>alert('xss')</script>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1224,45 +1224,3 @@ describe("moveNode", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCanvasStore – broadcastMessages", () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({ broadcastMessages: [] });
|
||||
});
|
||||
|
||||
it("consumeBroadcastMessages returns and clears all messages", () => {
|
||||
useCanvasStore.setState({
|
||||
broadcastMessages: [
|
||||
{ id: "m1", senderId: "ws-1", sender: "Agent 1", message: "Hello", timestamp: "2026-05-16T00:00:00Z" },
|
||||
{ id: "m2", senderId: "ws-2", sender: "Agent 2", message: "World", timestamp: "2026-05-16T00:01:00Z" },
|
||||
],
|
||||
});
|
||||
const consumed = useCanvasStore.getState().consumeBroadcastMessages();
|
||||
expect(consumed).toHaveLength(2);
|
||||
expect(useCanvasStore.getState().broadcastMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dismissBroadcastMessage removes the targeted message only", () => {
|
||||
useCanvasStore.setState({
|
||||
broadcastMessages: [
|
||||
{ id: "m1", senderId: "ws-1", sender: "Agent 1", message: "Hello", timestamp: "2026-05-16T00:00:00Z" },
|
||||
{ id: "m2", senderId: "ws-2", sender: "Agent 2", message: "World", timestamp: "2026-05-16T00:01:00Z" },
|
||||
{ id: "m3", senderId: "ws-3", sender: "Agent 3", message: "Bye", timestamp: "2026-05-16T00:02:00Z" },
|
||||
],
|
||||
});
|
||||
useCanvasStore.getState().dismissBroadcastMessage("m2");
|
||||
const remaining = useCanvasStore.getState().broadcastMessages;
|
||||
expect(remaining).toHaveLength(2);
|
||||
expect(remaining.map((m) => m.id)).toEqual(["m1", "m3"]);
|
||||
});
|
||||
|
||||
it("dismissBroadcastMessage is idempotent for unknown IDs", () => {
|
||||
useCanvasStore.setState({
|
||||
broadcastMessages: [
|
||||
{ id: "m1", senderId: "ws-1", sender: "Agent 1", message: "Hello", timestamp: "2026-05-16T00:00:00Z" },
|
||||
],
|
||||
});
|
||||
expect(() => useCanvasStore.getState().dismissBroadcastMessage("nonexistent")).not.toThrow();
|
||||
expect(useCanvasStore.getState().broadcastMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user