Compare commits

..

4 Commits

Author SHA1 Message Date
3e38a885a4 fix(ci): use GITHUB_EVENT_BEFORE env var in detect-changes push job
Some checks failed
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 17s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 33s
E2E API Smoke Test / detect-changes (pull_request) Successful in 34s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 32s
Harness Replays / detect-changes (pull_request) Successful in 15s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 21s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 8s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 45s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
publish-runtime-autobump / pr-validate (pull_request) Successful in 47s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 14s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 17s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 19s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m36s
gate-check-v3 / gate-check (pull_request) Successful in 11s
qa-review / approved (pull_request) Successful in 8s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m36s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m36s
sop-checklist-gate / gate (pull_request) Successful in 14s
security-review / approved (pull_request) Successful in 15s
sop-tier-check / tier-check (pull_request) Successful in 16s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m46s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m52s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m17s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m28s
audit-force-merge / audit (pull_request) Failing after 11m52s
mc#917 root fix.

Gitea Actions does not expose github.event.before as a ${{ }} template
expression that resolves in shell scripts for push events — it silently
becomes an empty string. This caused `git cat-file -e ""` to hang
indefinitely on some runner configurations (10m timeout was masking the
failure via continue-on-error: true).

Fix: use GITHUB_EVENT_BEFORE env var (set by the runner for push
events) instead of the broken template expression. Also guard both
`git cat-file -e` calls with `timeout 30` to prevent future hangs if
BASE is ever malformed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 01:24:16 +00:00
9f3948dc3a test(a2a_mcp_server): add 5 tool-branch coverage cases to HTTP transport tests
Some checks are pending
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Blocked by required conditions
Harness Replays / Harness Replays (pull_request) Blocked by required conditions
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Blocked by required conditions
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 16s
CI / Detect changes (pull_request) Successful in 16s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Has been skipped
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 15s
Harness Replays / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 11s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 10s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 44s
publish-runtime-autobump / bump-and-tag (pull_request) Has been skipped
publish-runtime-autobump / pr-validate (pull_request) Successful in 36s
review-check-tests / review-check.sh regression tests (pull_request) Successful in 10s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 1m26s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 14s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 14s
security-review / approved (pull_request) Successful in 9s
qa-review / approved (pull_request) Successful in 9s
gate-check-v3 / gate-check (pull_request) Successful in 9s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 1m18s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 1m31s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m10s
sop-checklist-gate / gate (pull_request) Successful in 10s
sop-tier-check / tier-check (pull_request) Successful in 13s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m28s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Successful in 1m34s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Successful in 1m13s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m23s
Cover remaining elif branches in handle_tool_call:
- send_message_to_user: mixed-type attachments are filtered (line 116)
- wait_for_message: dispatched with timeout_secs argument
- inbox_peek: dispatched with limit argument
- inbox_pop: dispatched with activity_id argument
- chat_history: dispatched with peer_id/limit/before_ts arguments

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:15:26 +00:00
c4deda1035 test(builtin_tools): add 16-case coverage for _redact_secrets (C2, #834)
Bring builtin_tools/security._redact_secrets from 58% to 100% coverage.
Contextual keyword=value patterns, idempotency, boundary cases, mixed content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:15:26 +00:00
0dbda533fb feat(workspace): add HTTP/SSE transport to a2a_mcp_server
Port HTTP/SSE transport (from workspace-runtime PR #16) to the canonical
monorepo source. Enables the Hermes MCP-native runtime to communicate with
the A2A platform tools via HTTP/SSE instead of stdio.

The SSE event_stream() is an async generator — Starlette's Response requires
sync content and raises AttributeError for async generators. Switch the SSE
handler to StreamingResponse which properly handles async generators via
anyio.create_task_group (Starlette 1.0.0).

Adds test_a2a_mcp_server_http.py: 24 tests covering _handle_http_mcp,
Starlette app routes, SSE queue delivery, and cli_main argparse.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 10:15:26 +00:00
240 changed files with 2211 additions and 17870 deletions

View File

@ -1 +0,0 @@
refire:1778784369

View File

@ -203,17 +203,12 @@ def ci_jobs_all(ci_doc: dict) -> set[str]:
def ci_job_names(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 """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 whose `if:` gates on `github.event_name` (those are event-scoped
event-scoped and can legitimately be `skipped` for a given trigger; and can legitimately be `skipped` for a given trigger; if we
if we required them under the sentinel `needs:`, every PR-only job required them under the sentinel `needs:`, every PR-only job
would be `skipped` on push and the sentinel would interpret would be `skipped` on push and the sentinel would interpret
`skipped != success` as failure). RFC §4 spec. `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 Used for F1 (jobs missing from sentinel needs). NOT used for F1b
(typos in needs) see `ci_jobs_all` for that.""" (typos in needs) see `ci_jobs_all` for that."""
jobs = ci_doc.get("jobs") jobs = ci_doc.get("jobs")
@ -226,9 +221,7 @@ def ci_job_names(ci_doc: dict) -> set[str]:
continue continue
if isinstance(v, dict): if isinstance(v, dict):
gate = v.get("if") gate = v.get("if")
if isinstance(gate, str) and ( if isinstance(gate, str) and "github.event_name" in gate:
"github.event_name" in gate or "github.ref" in gate
):
continue continue
names.add(k) names.add(k)
return names return names

View File

@ -47,15 +47,6 @@ REQUIRED_CONTEXTS_RAW = _env(
"sop-checklist / all-items-acked (pull_request)" "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 ("", "") OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST 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()] 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: def status_state(status: dict) -> str:
return str(status.get("status") or status.get("state") or "").lower() return str(status.get("status") or status.get("state") or "").lower()
def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]: 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] = {} latest: dict[str, dict] = {}
for status in reversed(statuses): for status in statuses:
context = status.get("context") context = status.get("context")
if isinstance(context, str): if isinstance(context, str) and context not in latest:
latest[context] = status # overwrite: reverse order → newest wins latest[context] = status
return latest return latest
@ -210,23 +193,16 @@ def evaluate_merge_readiness(
required_contexts: list[str], required_contexts: list[str],
pr_has_current_base: bool, pr_has_current_base: bool,
) -> MergeDecision: ) -> MergeDecision:
# Check push-required contexts explicitly instead of combined state. main_state = str(main_status.get("state") or "").lower()
# Combined state can be "failure" due to non-blocking jobs if main_state != "success":
# (continue-on-error: true) that don't actually gate merges. return MergeDecision(False, "pause", f"main status is {main_state or 'missing'}")
# 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))
if not pr_has_current_base: if not pr_has_current_base:
return MergeDecision(False, "update", "PR head does not contain current main") return MergeDecision(False, "update", "PR head does not contain current main")
# Check explicit required contexts instead of combined state. Combined state pr_state = str(pr_status.get("state") or "").lower()
# can be "failure" due to non-blocking jobs with continue-on-error: true if pr_state != "success":
# (e.g. publish-runtime-autobump/pr-validate, qa-review on stale tokens). return MergeDecision(False, "wait", f"PR combined status is {pr_state or 'missing'}")
# The required_contexts list is the authoritative gate — it includes only
# the checks that actually block merges.
latest = latest_statuses_by_context(pr_status.get("statuses") or []) latest = latest_statuses_by_context(pr_status.get("statuses") or [])
ok, missing_or_bad = required_contexts_green(latest, required_contexts) ok, missing_or_bad = required_contexts_green(latest, required_contexts)
if not ok: if not ok:
@ -244,37 +220,10 @@ def get_branch_head(branch: str) -> str:
def get_combined_status(sha: str) -> dict: def get_combined_status(sha: str) -> dict:
"""Combined status + all individual statuses for `sha`. _, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
if not isinstance(body, dict):
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):
raise ApiError(f"status for {sha} response not object") raise ApiError(f"status for {sha} response not object")
# Fetch full statuses list; 200 covers >99% of real-world runs. return body
# 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
def list_queued_issues() -> list[dict]: def list_queued_issues() -> list[dict]:
@ -345,12 +294,8 @@ def process_once(*, dry_run: bool = False) -> int:
contexts = required_contexts(REQUIRED_CONTEXTS_RAW) contexts = required_contexts(REQUIRED_CONTEXTS_RAW)
main_sha = get_branch_head(WATCH_BRANCH) main_sha = get_branch_head(WATCH_BRANCH)
main_status = get_combined_status(main_sha) main_status = get_combined_status(main_sha)
# Check push-required contexts explicitly instead of combined state. if str(main_status.get("state") or "").lower() != "success":
# See evaluate_merge_readiness for rationale. print(f"::notice::queue paused: {WATCH_BRANCH}@{main_sha[:8]} is not green")
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)}")
return 0 return 0
issue = choose_next_queued_issue( issue = choose_next_queued_issue(
@ -417,21 +362,7 @@ def main() -> int:
parser.add_argument("--dry-run", action="store_true") parser.add_argument("--dry-run", action="store_true")
args = parser.parse_args() args = parser.parse_args()
_require_runtime_env() _require_runtime_env()
try: return process_once(dry_run=args.dry_run)
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
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -29,16 +29,6 @@ Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn):
or `https://github.com/.../releases/download` without a or `https://github.com/.../releases/download` without a
workflow-level `env.GITHUB_SERVER_URL` set to the Gitea instance. workflow-level `env.GITHUB_SERVER_URL` set to the Gitea instance.
Memory: feedback_act_runner_github_server_url. Memory: feedback_act_runner_github_server_url.
7. Production deploy/redeploy workflows may not rely on Gitea
`concurrency.cancel-in-progress: false` for serialization. Gitea
1.22.6 can cancel queued runs despite that setting.
8. Production deploy/redeploy workflows may not dump raw CP responses or
raw `.error` fields into CI logs/summaries.
9. Production deploy/redeploy workflows must expose an operational control:
kill switch for auto deploys or rollback tag for manual deploys.
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 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 validate this lint must mirror real Gitea 1.22.6 YAML semantics, not
@ -228,24 +218,6 @@ def _iter_uses(doc: Any) -> Iterable[str]:
yield step["uses"] 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]: def check_cross_repo_uses(filename: str, doc: Any) -> list[str]:
"""Return per-violation error lines for cross-repo `uses:` references.""" """Return per-violation error lines for cross-repo `uses:` references."""
errors: list[str] = [] errors: list[str] = []
@ -283,23 +255,6 @@ GITHUB_API_REF_RE = re.compile(
) )
PROD_CP_URL_RE = re.compile(r"https://api\.moleculesai\.app\b")
REDEPLOY_FLEET_RE = re.compile(r"\b/cp/admin/tenants/redeploy-fleet\b")
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["']?)
|
(?:\bcat\s+["']?\$HTTP_RESPONSE["']?)
|
(?:\|\s*\.error\b)
"""
)
def _has_workflow_level_server_url(doc: Any) -> bool: def _has_workflow_level_server_url(doc: Any) -> bool:
if not isinstance(doc, dict): if not isinstance(doc, dict):
return False return False
@ -331,107 +286,6 @@ def check_github_server_url_missing(filename: str, doc: Any, raw: str) -> list[s
return warns return warns
# ---------------------------------------------------------------------------
# Rule 7-9 — production CI/CD hardening rules
# ---------------------------------------------------------------------------
def _is_production_redeploy_workflow(raw: str) -> bool:
"""Heuristic production-side-effect detector.
We intentionally key on the production CP host plus the redeploy-fleet
endpoint. Staging workflows call the same endpoint on staging-api and are
governed by looser staging verification policy.
"""
return bool(PROD_CP_URL_RE.search(raw) and REDEPLOY_FLEET_RE.search(raw))
def _iter_concurrency_blocks(doc: Any) -> Iterable[dict[str, Any]]:
if not isinstance(doc, dict):
return
top = doc.get("concurrency")
if isinstance(top, dict):
yield top
jobs = doc.get("jobs")
if not isinstance(jobs, dict):
return
for job in jobs.values():
if isinstance(job, dict) and isinstance(job.get("concurrency"), dict):
yield job["concurrency"]
def check_production_concurrency(filename: str, doc: Any, raw: str) -> list[str]:
errors: list[str] = []
if not _is_production_redeploy_workflow(raw):
return errors
for block in _iter_concurrency_blocks(doc):
if block.get("cancel-in-progress") is False:
errors.append(
f"::error file={filename}::Rule 7 (FATAL): production deploy "
f"workflow uses `concurrency.cancel-in-progress: false`. "
f"Gitea 1.22.6 can cancel queued runs despite that setting, "
f"so this is not a safe production serialization primitive. "
f"Use an external queue/lock or make the deploy idempotent."
)
return errors
def check_production_raw_response_logging(filename: str, raw: str) -> list[str]:
errors: list[str] = []
if not _is_production_redeploy_workflow(raw):
return errors
if RAW_CP_RESPONSE_RE.search(raw):
errors.append(
f"::error file={filename}::Rule 8 (FATAL): production deploy "
f"workflow appears to print a raw production CP response or raw "
f"`.error` field. CI logs are persistent and broad-read. Redact "
f"runtime/SSM error details; print counts, booleans, status "
f"codes, and links to restricted observability instead."
)
return errors
def check_production_operational_control(filename: str, raw: str) -> list[str]:
errors: list[str] = []
if not _is_production_redeploy_workflow(raw):
return errors
has_kill_switch = "PROD_AUTO_DEPLOY_DISABLED" in raw
has_rollback = "PROD_MANUAL_REDEPLOY_TARGET_TAG" in raw
if not (has_kill_switch or has_rollback):
errors.append(
f"::error file={filename}::Rule 9 (FATAL): production deploy "
f"workflow calls redeploy-fleet without an operational control. "
f"Auto deploys need a `PROD_AUTO_DEPLOY_DISABLED` kill switch; "
f"manual deploys need a `PROD_MANUAL_REDEPLOY_TARGET_TAG` "
f"rollback/pin path."
)
return errors
# ---------------------------------------------------------------------------
# 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 # Driver
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -482,10 +336,6 @@ def main(argv: list[str] | None = None) -> int:
fatal_errors.extend(check_workflow_run_event(rel, doc)) fatal_errors.extend(check_workflow_run_event(rel, doc))
fatal_errors.extend(check_name_with_slash(rel, doc)) fatal_errors.extend(check_name_with_slash(rel, doc))
fatal_errors.extend(check_cross_repo_uses(rel, doc)) fatal_errors.extend(check_cross_repo_uses(rel, doc))
fatal_errors.extend(check_production_concurrency(rel, doc, raw))
fatal_errors.extend(check_production_raw_response_logging(rel, raw))
fatal_errors.extend(check_production_operational_control(rel, raw))
fatal_errors.extend(check_docker_info_head_pipefail(rel, doc))
warnings.extend(check_github_server_url_missing(rel, doc, raw)) warnings.extend(check_github_server_url_missing(rel, doc, raw))
# Cross-file checks # Cross-file checks

View File

@ -1,251 +0,0 @@
#!/usr/bin/env python3
"""Production auto-deploy helpers for Gitea Actions.
The workflow keeps network side effects in shell/curl, but centralizes the
release decision shape here so it has unit coverage: disable flag parsing,
target tag selection, CP payload construction, and status-context selection.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from urllib.parse import quote
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
PROD_CP_URL = "https://api.moleculesai.app"
DEFAULT_REQUIRED_CONTEXTS = [
"CI / Platform (Go) (push)",
"CI / Canvas (Next.js) (push)",
"CI / Shellcheck (E2E scripts) (push)",
"CI / Python Lint & Test (push)",
"CI / all-required (push)",
"Secret scan / Scan diff for credential-shaped strings (push)",
]
TERMINAL_FAILURE_STATES = {"failure", "error", "cancelled", "canceled", "skipped"}
def truthy_flag(value: str | None) -> bool:
if value is None:
return False
return value.strip().lower() in TRUE_VALUES
def _int_env(env: dict[str, str], name: str, default: int, minimum: int = 1) -> int:
raw = env.get(name, "")
if not raw:
return default
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer, got {raw!r}") from exc
if value < minimum:
raise ValueError(f"{name} must be >= {minimum}, got {value}")
return value
def build_plan(env: dict[str, str]) -> dict:
sha = env.get("GITHUB_SHA", "").strip()
if not sha:
raise ValueError("GITHUB_SHA is required")
disabled_value = env.get("PROD_AUTO_DEPLOY_DISABLED", "")
if truthy_flag(disabled_value):
return {
"enabled": False,
"sha": sha,
"disabled_reason": f"PROD_AUTO_DEPLOY_DISABLED={disabled_value}",
}
short_sha = sha[:7]
target_tag = env.get("PROD_AUTO_DEPLOY_TARGET_TAG", "").strip() or f"staging-{short_sha}"
canary_slug = env.get("PROD_AUTO_DEPLOY_CANARY_SLUG", "hongming").strip()
body = {
"target_tag": target_tag,
"soak_seconds": _int_env(env, "PROD_AUTO_DEPLOY_SOAK_SECONDS", 60, minimum=0),
"batch_size": _int_env(env, "PROD_AUTO_DEPLOY_BATCH_SIZE", 3),
"dry_run": truthy_flag(env.get("PROD_AUTO_DEPLOY_DRY_RUN", "")),
}
if canary_slug:
body["canary_slug"] = canary_slug
cp_url = env.get("CP_URL", "").strip() or PROD_CP_URL
if cp_url != PROD_CP_URL and not truthy_flag(env.get("PROD_ALLOW_NON_PROD_CP_URL", "")):
raise ValueError(
f"Refusing production deploy to CP_URL={cp_url!r}; "
f"set PROD_ALLOW_NON_PROD_CP_URL=true for an explicit non-prod drill"
)
return {
"enabled": True,
"sha": sha,
"short_sha": short_sha,
"target_tag": target_tag,
"cp_url": cp_url,
"body": body,
}
def latest_status_for_context(statuses: list[dict], context: str) -> dict | None:
"""Return the first matching status.
Gitea's combined-status response is newest-first in practice. The merge
queue relies on the same contract; keeping the selector explicit makes
stale duplicate contexts easy to test.
"""
for status in statuses:
if status.get("context") == context:
return status
return None
def ci_context_state(statuses: list[dict], context: str) -> str:
status = latest_status_for_context(statuses, context)
if not status:
return "missing"
return str(status.get("status") or status.get("state") or "missing").lower()
def context_is_satisfied(state: str) -> bool:
return state == "success"
def context_is_terminal_failure(state: str) -> bool:
return state in TERMINAL_FAILURE_STATES
def required_contexts(env: dict[str, str]) -> list[str]:
raw = env.get("PROD_AUTO_DEPLOY_REQUIRED_CONTEXTS", "")
if not raw.strip():
return DEFAULT_REQUIRED_CONTEXTS
return [line.strip() for line in raw.replace(",", "\n").splitlines() if line.strip()]
def _api_json(url: str, token: str) -> dict:
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", errors="replace")[:500]
raise RuntimeError(f"GET {url} -> HTTP {exc.code}: {body}") from exc
def _api_json_optional(url: str, token: str) -> tuple[int, dict | None]:
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.status, json.loads(resp.read())
except urllib.error.HTTPError as exc:
if exc.code == 404:
return exc.code, None
body = exc.read().decode("utf-8", errors="replace")[:300]
print(f"::warning::GET {url} -> HTTP {exc.code}: {body}", file=sys.stderr)
return exc.code, None
def live_disable_flag(env: dict[str, str]) -> str:
"""Return a live disable value from Gitea variables when readable.
Gitea evaluates `${{ vars.* }}` once when the job starts. This API read is
the emergency re-check immediately before production side effects.
"""
token = env.get("GITEA_TOKEN", "").strip()
if not token:
return ""
host = env.get("GITEA_HOST", "git.moleculesai.app")
repo = env.get("GITHUB_REPOSITORY", "molecule-ai/molecule-core")
variable = quote("PROD_AUTO_DEPLOY_DISABLED", safe="")
url = f"https://{host}/api/v1/repos/{repo}/actions/variables/{variable}"
status, body = _api_json_optional(url, token)
if status != 200 or not isinstance(body, dict):
return ""
return str(body.get("data") or body.get("value") or "")
def assert_not_disabled(env: dict[str, str]) -> None:
plan = build_plan(env)
if not plan.get("enabled"):
raise RuntimeError(plan.get("disabled_reason", "production auto-deploy disabled"))
live_value = live_disable_flag(env)
if truthy_flag(live_value):
raise RuntimeError(f"PROD_AUTO_DEPLOY_DISABLED={live_value} (live Gitea variable)")
def wait_for_ci_context(env: dict[str, str]) -> str:
host = env.get("GITEA_HOST", "git.moleculesai.app")
repo = env.get("GITHUB_REPOSITORY", "molecule-ai/molecule-core")
sha = env.get("GITHUB_SHA", "").strip()
token = env.get("GITEA_TOKEN", "").strip()
contexts = required_contexts(env)
interval = _int_env(env, "CI_STATUS_POLL_INTERVAL_SECONDS", 15)
timeout = _int_env(env, "CI_STATUS_TIMEOUT_SECONDS", 1800)
if not sha:
raise ValueError("GITHUB_SHA is required")
if not token:
raise ValueError("GITEA_TOKEN is required to wait for CI status")
url = f"https://{host}/api/v1/repos/{repo}/commits/{sha}/status"
deadline = time.time() + timeout
last_states: dict[str, str] = {}
while time.time() <= deadline:
body = _api_json(url, token)
statuses = body.get("statuses") or []
states = {context: ci_context_state(statuses, context) for context in contexts}
for context, state in states.items():
if state != last_states.get(context):
print(f"CI context {context!r}: {state}", file=sys.stderr)
last_states = states
failures = [
f"{context}={state}"
for context, state in states.items()
if context_is_terminal_failure(state)
]
if failures:
raise RuntimeError(
"Required CI context failed; refusing production deploy: "
+ ", ".join(failures)
)
if all(context_is_satisfied(state) for state in states.values()):
return "success"
time.sleep(interval)
last = ", ".join(f"{context}={state}" for context, state in last_states.items()) or "none"
raise TimeoutError(f"Timed out waiting {timeout}s for required CI contexts; last_states={last}")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
sub.add_parser("plan", help="print production deploy plan as JSON")
sub.add_parser("assert-enabled", help="fail if production deploy is currently disabled")
sub.add_parser("wait-ci", help="block until required CI context is green")
args = parser.parse_args()
try:
if args.command == "plan":
print(json.dumps(build_plan(dict(os.environ)), sort_keys=True))
return 0
if args.command == "assert-enabled":
assert_not_disabled(dict(os.environ))
return 0
if args.command == "wait-ci":
wait_for_ci_context(dict(os.environ))
return 0
except Exception as exc: # noqa: BLE001 - CLI should render operator-friendly errors.
print(f"::error::{exc}", file=sys.stderr)
return 1
return 2
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -60,7 +60,6 @@
# Optional: # Optional:
# REVIEW_CHECK_DEBUG=1 — per-API-call diagnostic lines # REVIEW_CHECK_DEBUG=1 — per-API-call diagnostic lines
# REVIEW_CHECK_STRICT=1 — also require review.commit_id == pr.head.sha # REVIEW_CHECK_STRICT=1 — also require review.commit_id == pr.head.sha
# DEFAULT_BRANCH=main — branch this gate protects; non-default-base PRs no-op
set -euo pipefail set -euo pipefail
@ -92,7 +91,7 @@ API="https://${GITEA_HOST}/api/v1"
# secret token value in the process table for any process to read via # secret token value in the process table for any process to read via
# /proc/<pid>/cmdline or ps -ef). The curl config file is read by curl # /proc/<pid>/cmdline or ps -ef). The curl config file is read by curl
# itself and never appears in the argv of the curl subprocess. # itself and never appears in the argv of the curl subprocess.
CURL_AUTH_FILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.XXXXXX") CURL_AUTH_FILE=$(mktemp -p /tmp curl-auth.XXXXXX)
chmod 600 "$CURL_AUTH_FILE" chmod 600 "$CURL_AUTH_FILE"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE" printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
@ -101,10 +100,9 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
PR_JSON=$(mktemp) PR_JSON=$(mktemp)
REVIEWS_JSON=$(mktemp) REVIEWS_JSON=$(mktemp)
TEAM_PROBE_TMP=$(mktemp) TEAM_PROBE_TMP=$(mktemp)
NA_STATUSES_TMP="" # declared here so cleanup() always has the var
cleanup() { 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 trap cleanup EXIT
@ -126,60 +124,18 @@ if [ "$HTTP_CODE" != "200" ]; then
fi fi
PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON") PR_AUTHOR=$(jq -r '.user.login // ""' "$PR_JSON")
PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON") PR_HEAD_SHA=$(jq -r '.head.sha // ""' "$PR_JSON")
PR_BASE_REF=$(jq -r '.base.ref // ""' "$PR_JSON")
PR_STATE=$(jq -r '.state // ""' "$PR_JSON") PR_STATE=$(jq -r '.state // ""' "$PR_JSON")
DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}" debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_state=${PR_STATE}"
debug "pr_author=${PR_AUTHOR} pr_head=${PR_HEAD_SHA:0:7} pr_base=${PR_BASE_REF} pr_state=${PR_STATE}"
if [ "$PR_STATE" != "open" ]; then if [ "$PR_STATE" != "open" ]; then
echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)" echo "::notice::PR ${PR_NUMBER} is ${PR_STATE} — exiting 0 (closed PRs do not gate)"
exit 0 exit 0
fi fi
if [ "$PR_BASE_REF" != "$DEFAULT_BRANCH" ]; then
echo "::notice::PR ${PR_NUMBER} targets ${PR_BASE_REF:-<unknown>} not ${DEFAULT_BRANCH}${TEAM}-review gate not applicable"
exit 0
fi
if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then if [ -z "$PR_AUTHOR" ] || [ -z "$PR_HEAD_SHA" ]; then
echo "::error::PR ${PR_NUMBER} missing user.login or head.sha — webhook payload malformed" echo "::error::PR ${PR_NUMBER} missing user.login or head.sha — webhook payload malformed"
exit 1 exit 1
fi 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 --- # --- Fetch all reviews on the PR ---
HTTP_CODE=$(curl -sS -o "$REVIEWS_JSON" -w '%{http_code}' \ HTTP_CODE=$(curl -sS -o "$REVIEWS_JSON" -w '%{http_code}' \
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews") -K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")

View File

@ -1,81 +0,0 @@
#!/usr/bin/env bash
# Re-run review-check.sh for a slash-command refire and post the protected
# pull_request status context to the PR head SHA.
set -euo pipefail
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${GITEA_HOST:?GITEA_HOST required}"
: "${REPO:?REPO required}"
: "${PR_NUMBER:?PR_NUMBER required}"
: "${TEAM:?TEAM required}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
CONTEXT="${TEAM}-review / approved (pull_request)"
TARGET_URL="https://${GITEA_HOST}/${OWNER}/${NAME}/pulls/${PR_NUMBER}"
authfile=$(mktemp)
prfile=$(mktemp)
postfile=$(mktemp)
# shellcheck disable=SC2329 # invoked by EXIT trap
cleanup() {
rm -f "$authfile" "$prfile" "$postfile"
}
trap cleanup EXIT
chmod 600 "$authfile"
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
code=$(curl -sS -o "$prfile" -w '%{http_code}' -K "$authfile" \
"${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
if [ "$code" != "200" ]; then
echo "::error::GET /pulls/${PR_NUMBER} returned HTTP ${code}"
head -c 200 "$prfile" >&2 || true
exit 1
fi
head_sha=$(jq -r '.head.sha // ""' "$prfile")
state=$(jq -r '.state // ""' "$prfile")
if [ -z "$head_sha" ] || [ "$head_sha" = "null" ]; then
echo "::error::Could not resolve PR head SHA for PR ${PR_NUMBER}"
exit 1
fi
if [ "$state" != "open" ]; then
echo "::notice::PR ${PR_NUMBER} is ${state}; ${TEAM}-review refire is a no-op"
exit 0
fi
set +e
bash .gitea/scripts/review-check.sh
rc=$?
set -e
if [ "$rc" -eq 0 ]; then
status_state="success"
description="Refired via /${TEAM}-recheck by ${COMMENT_AUTHOR:-unknown}"
else
status_state="failure"
description="Refired via /${TEAM}-recheck; ${TEAM}-review failed"
fi
body=$(jq -nc \
--arg state "$status_state" \
--arg context "$CONTEXT" \
--arg description "$description" \
--arg target_url "$TARGET_URL" \
'{state:$state, context:$context, description:$description, target_url:$target_url}')
code=$(curl -sS -o "$postfile" -w '%{http_code}' -X POST \
-K "$authfile" -H "Content-Type: application/json" \
-d "$body" \
"${API}/repos/${OWNER}/${NAME}/statuses/${head_sha}")
if [ "$code" != "200" ] && [ "$code" != "201" ]; then
echo "::error::POST /statuses/${head_sha} returned HTTP ${code}"
head -c 200 "$postfile" >&2 || true
exit 1
fi
echo "::notice::posted ${status_state} for context=\"${CONTEXT}\" on sha=${head_sha}"
exit "$rc"

View File

@ -1,11 +1,11 @@
#!/usr/bin/env python3 #!/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 # SOP-checklist item. Posts a commit-status that branch protection
# can require. # can require.
# #
# RFC#351 Step 2 of 6 (implementation MVP). # 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] # - pull_request_target: [opened, edited, synchronize, reopened]
# - issue_comment: [created, edited, deleted] # - issue_comment: [created, edited, deleted]
# #

View File

@ -58,10 +58,9 @@ What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
even if another tick happens before the runner finishes. even if another tick happens before the runner finishes.
What it does NOT do: What it does NOT do:
- Touch ` (pull_request)` contexts unless the exact same - Touch any context NOT ending in ` (push)`. The required-checks on
workflow/job has a successful ` (push)` context on the same main (verified 2026-05-11) all have ` (pull_request)` suffixes;
default-branch SHA. That case is post-merge status pollution, not they CANNOT be reached by this code path.
an unproven PR gate.
- Compensate `error`/`pending` states. Only `failure` the only one - Compensate `error`/`pending` states. Only `failure` the only one
Gitea emits for the hardcoded-suffix bug. Gitea emits for the hardcoded-suffix bug.
- Write to non-default branches. WATCH_BRANCH is sourced from - Write to non-default branches. WATCH_BRANCH is sourced from
@ -92,9 +91,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import socket
import sys import sys
import time
import urllib.error import urllib.error
import urllib.parse import urllib.parse
import urllib.request import urllib.request
@ -121,31 +118,19 @@ WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows")
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "") OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else "" API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
API_TIMEOUT_SEC = int(_env("STATUS_REAPER_API_TIMEOUT_SEC", default="30") or "30")
API_RETRIES = int(_env("STATUS_REAPER_API_RETRIES", default="3") or "3")
API_RETRY_SLEEP_SEC = float(_env("STATUS_REAPER_API_RETRY_SLEEP_SEC", default="2") or "2")
# Compensating-status description prefix. Used as the marker so a human # Compensating-status description prefix. Used as the marker so a human
# auditing commit statuses can tell at a glance that the green was # auditing commit statuses can tell at a glance that the green was
# synthetic, not a real CI pass. Kept stable; downstream tooling # synthetic, not a real CI pass. Kept stable; downstream tooling
# (e.g. main-red-watchdog visual diff) MAY key on it. # (e.g. main-red-watchdog visual diff) MAY key on it.
PUSH_COMPENSATION_DESCRIPTION = ( COMPENSATION_DESCRIPTION = (
"Compensated by status-reaper (workflow has no push: trigger; " "Compensated by status-reaper (workflow has no push: trigger; "
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)" "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 "
".gitea/scripts/status-reaper.py)"
)
# Context suffix the reaper acts on. Gitea hardcodes this for ALL # Context suffix the reaper acts on. Gitea hardcodes this for ALL
# default-branch workflow runs. # default-branch workflow runs.
PUSH_SUFFIX = " (push)" PUSH_SUFFIX = " (push)"
PULL_REQUEST_SUFFIX = " (pull_request)"
def _require_runtime_env() -> None: def _require_runtime_env() -> None:
@ -197,27 +182,13 @@ def api(
data = json.dumps(body).encode("utf-8") data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, method=method, data=data, headers=headers) req = urllib.request.Request(url, method=method, data=data, headers=headers)
attempts = max(API_RETRIES, 1) try:
for attempt in range(1, attempts + 1): with urllib.request.urlopen(req, timeout=30) as resp:
try: raw = resp.read()
with urllib.request.urlopen(req, timeout=API_TIMEOUT_SEC) as resp: status = resp.status
raw = resp.read() except urllib.error.HTTPError as e:
status = resp.status raw = e.read()
break status = e.code
except urllib.error.HTTPError as e:
raw = e.read()
status = e.code
break
except (TimeoutError, socket.timeout, urllib.error.URLError, OSError) as e:
if attempt >= attempts:
raise ApiError(
f"{method} {path} failed after {attempts} attempts: {e}"
) from e
print(
f"::warning::{method} {path} transient API error "
f"(attempt {attempt}/{attempts}): {e}; retrying"
)
time.sleep(API_RETRY_SLEEP_SEC)
if not (200 <= status < 300): if not (200 <= status < 300):
snippet = raw[:500].decode("utf-8", errors="replace") if raw else "" snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
@ -386,38 +357,24 @@ def get_combined_status(sha: str) -> dict:
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Context parsing # Context parsing
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def parse_suffixed_context(context: str, suffix: str) -> tuple[str, str] | None: def parse_push_context(context: str) -> tuple[str, str] | None:
"""Parse `<workflow_name> / <job_name> (<event>)` into """Parse `<workflow_name> / <job_name> (push)` into
(workflow_name, job_name). (workflow_name, job_name).
Returns None if the context doesn't match the shape (caller skips). Returns None if the context doesn't match the shape (caller skips).
Strict: requires the trailing suffix and at least one ` / ` Strict: requires the trailing ` (push)` and at least one ` / `
separator. Anything else is left alone. separator. Anything else is left alone.
""" """
if not context.endswith(suffix): if not context.endswith(PUSH_SUFFIX):
return None return None
head = context[: -len(suffix)] head = context[: -len(PUSH_SUFFIX)] # strip " (push)"
if " / " not in head: if " / " not in head:
# No workflow/job separator — not the bug shape we compensate.
return None return None
workflow_name, job_name = head.split(" / ", 1) workflow_name, job_name = head.split(" / ", 1)
return workflow_name, job_name return workflow_name, job_name
def parse_push_context(context: str) -> tuple[str, str] | None:
"""Parse `<workflow_name> / <job_name> (push)` into
(workflow_name, job_name)."""
return parse_suffixed_context(context, PUSH_SUFFIX)
def push_equivalent_context(context: str) -> str | None:
"""Return the matching `(push)` context for a `(pull_request)` context."""
parsed = parse_suffixed_context(context, PULL_REQUEST_SUFFIX)
if parsed is None:
return None
workflow_name, job_name = parsed
return f"{workflow_name} / {job_name}{PUSH_SUFFIX}"
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Compensating POST # Compensating POST
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@ -426,7 +383,6 @@ def post_compensating_status(
context: str, context: str,
target_url: str | None, target_url: str | None,
*, *,
description: str = PUSH_COMPENSATION_DESCRIPTION,
dry_run: bool = False, dry_run: bool = False,
) -> None: ) -> None:
"""POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the """POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the
@ -438,7 +394,7 @@ def post_compensating_status(
payload: dict[str, Any] = { payload: dict[str, Any] = {
"context": context, "context": context,
"state": "success", "state": "success",
"description": description, "description": COMPENSATION_DESCRIPTION,
} }
# Echo the original target_url when present so a human auditing # Echo the original target_url when present so a human auditing
# the (now-green) compensated status can still reach the run logs # the (now-green) compensated status can still reach the run logs
@ -475,8 +431,7 @@ def reap(
Returns counters for observability: Returns counters for observability:
{compensated, preserved_real_push, preserved_unknown, {compensated, preserved_real_push, preserved_unknown,
preserved_non_failure, preserved_non_push_suffix, preserved_non_failure, preserved_non_push_suffix,
preserved_unparseable, compensated_pr_shadowed_by_push_success, preserved_unparseable,
preserved_pr_without_push_success,
compensated_contexts: [<context>, ...]} compensated_contexts: [<context>, ...]}
`compensated_contexts` is rev2-added so `reap_branch` can build `compensated_contexts` is rev2-added so `reap_branch` can build
@ -489,17 +444,10 @@ def reap(
"preserved_non_failure": 0, "preserved_non_failure": 0,
"preserved_non_push_suffix": 0, "preserved_non_push_suffix": 0,
"preserved_unparseable": 0, "preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"preserved_pr_without_push_success": 0,
"compensated_contexts": [], "compensated_contexts": [],
} }
statuses = combined.get("statuses") or [] statuses = combined.get("statuses") or []
successful_contexts = {
(s.get("context") or "")
for s in statuses
if isinstance(s, dict) and (s.get("status") or s.get("state") or "") == "success"
}
for s in statuses: for s in statuses:
if not isinstance(s, dict): if not isinstance(s, dict):
continue continue
@ -523,31 +471,9 @@ def reap(
counters["preserved_non_failure"] += 1 counters["preserved_non_failure"] += 1
continue continue
# Default-branch `pull_request` contexts can be stale shadows of
# the exact same workflow/job already proven by the successful
# `push` context on the same SHA. Compensate only that narrow
# shape; a missing or failed push equivalent remains a real gate
# signal and is preserved.
push_equivalent = push_equivalent_context(context)
if push_equivalent is not None:
if push_equivalent in successful_contexts:
post_compensating_status(
sha,
context,
s.get("target_url"),
description=PR_SHADOW_COMPENSATION_DESCRIPTION,
dry_run=dry_run,
)
counters["compensated"] += 1
counters["compensated_pr_shadowed_by_push_success"] += 1
counters["compensated_contexts"].append(context)
else:
counters["preserved_pr_without_push_success"] += 1
continue
# Only `(push)`-suffix contexts hit the hardcoded-suffix bug. # Only `(push)`-suffix contexts hit the hardcoded-suffix bug.
# Other failed contexts are preserved unless handled by the # Branch-protection required checks (e.g. `Secret scan / Scan
# pull-request-shadow rule above. # diff (pull_request)`) are NOT reachable from this path.
if not context.endswith(PUSH_SUFFIX): if not context.endswith(PUSH_SUFFIX):
counters["preserved_non_push_suffix"] += 1 counters["preserved_non_push_suffix"] += 1
continue continue
@ -614,10 +540,11 @@ def list_recent_commit_shas(branch: str, limit: int) -> list[str]:
(verified via vendor-truth probe 2026-05-11 against (verified via vendor-truth probe 2026-05-11 against
git.moleculesai.app `feedback_smoke_test_vendor_truth_not_shape_match`). git.moleculesai.app `feedback_smoke_test_vendor_truth_not_shape_match`).
Raises ApiError on non-2xx OR on unexpected response shape. The Raises ApiError on non-2xx OR on unexpected response shape. This is
branch-level caller soft-skips this tick because the next scheduled a HARD halt without the commit list the sweep can't proceed. (The
tick can safely retry the listing. Per-SHA status/write errors remain per-SHA error isolation downstream is a different concern: tolerating
separate and must not be mislabeled as commit-list outages. 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( _, body = api(
"GET", "GET",
@ -658,27 +585,7 @@ def reap_branch(
- compensated_per_sha: {<sha_full>: [<context>, ...]} only - compensated_per_sha: {<sha_full>: [<context>, ...]} only
SHAs that actually got at least one compensation are included SHAs that actually got at least one compensation are included
""" """
try: shas = list_recent_commit_shas(branch, limit)
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",
}
aggregate: dict[str, Any] = { aggregate: dict[str, Any] = {
"scanned_shas": 0, "scanned_shas": 0,
@ -688,8 +595,6 @@ def reap_branch(
"preserved_non_failure": 0, "preserved_non_failure": 0,
"preserved_non_push_suffix": 0, "preserved_non_push_suffix": 0,
"preserved_unparseable": 0, "preserved_unparseable": 0,
"compensated_pr_shadowed_by_push_success": 0,
"preserved_pr_without_push_success": 0,
"compensated_per_sha": {}, "compensated_per_sha": {},
} }
@ -727,8 +632,6 @@ def reap_branch(
"preserved_non_failure", "preserved_non_failure",
"preserved_non_push_suffix", "preserved_non_push_suffix",
"preserved_unparseable", "preserved_unparseable",
"compensated_pr_shadowed_by_push_success",
"preserved_pr_without_push_success",
): ):
aggregate[key] += per_sha[key] aggregate[key] += per_sha[key]

View File

@ -16,7 +16,6 @@ Scenarios:
T7_team_member team membership 204 (member) exit 0 T7_team_member team membership 204 (member) exit 0
T8_team_not_member team membership 404 (not a member) exit 1 T8_team_not_member team membership 404 (not a member) exit 1
T9_team_403 team membership 403 (token not in team) exit 1 T9_team_403 team membership 403 (token not in team) exit 1
T14_non_default_base open PR targeting staging script exits 0 (no-op)
Usage: Usage:
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080 FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
@ -83,14 +82,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
"number": int(pr_num), "number": int(pr_num),
"state": "closed", "state": "closed",
"head": {"sha": "deadbeef0000111122223333444455556666"}, "head": {"sha": "deadbeef0000111122223333444455556666"},
"base": {"ref": "main"},
"user": {"login": "alice"}, "user": {"login": "alice"},
}) })
return self._json(200, { return self._json(200, {
"number": int(pr_num), "number": int(pr_num),
"state": "open", "state": "open",
"head": {"sha": "deadbeef0000111122223333444455556666"}, "head": {"sha": "deadbeef0000111122223333444455556666"},
"base": {"ref": "staging" if sc == "T14_non_default_base" else "main"},
"user": {"login": "alice"}, "user": {"login": "alice"},
}) })

View File

@ -85,10 +85,7 @@ def test_pr_needs_update_when_base_sha_absent_from_commits():
def test_merge_decision_requires_main_green_pr_green_and_current_base(): def test_merge_decision_requires_main_green_pr_green_and_current_base():
required = ["CI / all-required (pull_request)"] required = ["CI / all-required (pull_request)"]
main_status = { main_status = {"state": "success", "statuses": []}
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
}
pr_status = { pr_status = {
"state": "success", "state": "success",
"statuses": [{"context": "CI / all-required (pull_request)", "status": "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(): def test_merge_decision_updates_stale_pr_before_merge():
decision = mq.evaluate_merge_readiness( decision = mq.evaluate_merge_readiness(
main_status={ main_status={"state": "success", "statuses": []},
"state": "success",
"statuses": [{"context": "CI / all-required (push)", "status": "success"}],
},
pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]}, pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]},
required_contexts=["CI / all-required (pull_request)"], required_contexts=["CI / all-required (pull_request)"],
pr_has_current_base=False, pr_has_current_base=False,

View File

@ -1,120 +0,0 @@
import importlib.util
import sys
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "prod-auto-deploy.py"
spec = importlib.util.spec_from_file_location("prod_auto_deploy", SCRIPT)
prod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = prod
spec.loader.exec_module(prod)
def test_truthy_flag_accepts_operator_disable_values():
for value in ("1", "true", "TRUE", "yes", "on", "disabled", "disable"):
assert prod.truthy_flag(value) is True
for value in ("", "0", "false", "no", "off", None):
assert prod.truthy_flag(value) is False
def test_build_plan_defaults_to_staging_sha_target_and_prod_cp():
plan = prod.build_plan(
{
"GITHUB_SHA": "abcdef1234567890",
"PROD_AUTO_DEPLOY_DISABLED": "",
}
)
assert plan["enabled"] is True
assert plan["sha"] == "abcdef1234567890"
assert plan["target_tag"] == "staging-abcdef1"
assert plan["cp_url"] == "https://api.moleculesai.app"
assert plan["body"] == {
"target_tag": "staging-abcdef1",
"canary_slug": "hongming",
"soak_seconds": 60,
"batch_size": 3,
"dry_run": False,
}
def test_build_plan_rejects_non_prod_cp_without_explicit_override():
try:
prod.build_plan(
{
"GITHUB_SHA": "abcdef1234567890",
"CP_URL": "https://staging-api.moleculesai.app",
}
)
except ValueError as exc:
assert "PROD_ALLOW_NON_PROD_CP_URL=true" in str(exc)
else:
raise AssertionError("expected non-prod CP URL rejection")
def test_build_plan_allows_non_prod_cp_only_with_override():
plan = prod.build_plan(
{
"GITHUB_SHA": "abcdef1234567890",
"CP_URL": "https://staging-api.moleculesai.app",
"PROD_ALLOW_NON_PROD_CP_URL": "true",
}
)
assert plan["cp_url"] == "https://staging-api.moleculesai.app"
def test_build_plan_disable_flag_short_circuits_before_credentials():
plan = prod.build_plan(
{
"GITHUB_SHA": "abcdef1234567890",
"PROD_AUTO_DEPLOY_DISABLED": "true",
}
)
assert plan["enabled"] is False
assert plan["disabled_reason"] == "PROD_AUTO_DEPLOY_DISABLED=true"
def test_latest_status_for_context_uses_first_matching_status():
statuses = [
{"context": "CI / all-required (push)", "status": "pending"},
{"context": "CI / all-required (pull_request)", "status": "success"},
{"context": "CI / all-required (push)", "status": "success"},
]
latest = prod.latest_status_for_context(statuses, "CI / all-required (push)")
assert latest == {"context": "CI / all-required (push)", "status": "pending"}
def test_ci_context_state_handles_missing_and_gitea_status_key():
assert prod.ci_context_state([], "CI / all-required (push)") == "missing"
assert (
prod.ci_context_state(
[{"context": "CI / all-required (push)", "status": "success"}],
"CI / all-required (push)",
)
== "success"
)
assert (
prod.ci_context_state(
[{"context": "CI / all-required (push)", "state": "failure"}],
"CI / all-required (push)",
)
== "failure"
)
def test_context_is_satisfied_accepts_only_success():
assert prod.context_is_satisfied("success") is True
for state in ("failure", "error", "cancelled", "canceled", "skipped", "pending", "missing"):
assert prod.context_is_satisfied(state) is False
def test_context_is_terminal_failure_rejects_cancelled_and_skipped():
for state in ("failure", "error", "cancelled", "canceled", "skipped"):
assert prod.context_is_terminal_failure(state) is True
for state in ("pending", "missing", "success"):
assert prod.context_is_terminal_failure(state) is False

View File

@ -15,7 +15,6 @@
# T11 — bash syntax check (bash -n passes) # T11 — bash syntax check (bash -n passes)
# T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded # T12 — jq filter: non-author APPROVED → in candidate list; dismissed → excluded
# T13 — missing required env GITEA_TOKEN → exits 1 with error # T13 — missing required env GITEA_TOKEN → exits 1 with error
# T14 — non-default-base PR exits 0 without requiring review
# #
# Hostile-self-review (per feedback_assert_exact_not_substring): # Hostile-self-review (per feedback_assert_exact_not_substring):
# this test MUST FAIL if the script is absent. Verified by running # this test MUST FAIL if the script is absent. Verified by running
@ -74,7 +73,7 @@ assert_file_mode() {
return return
fi fi
local got_mode local got_mode
got_mode=$(stat -c '%a' "$path" 2>/dev/null || stat -f '%Lp' "$path" 2>/dev/null || echo "000") got_mode=$(stat -c '%a' "$path" 2>/dev/null || echo "000")
if [ "$expected_mode" = "$got_mode" ]; then if [ "$expected_mode" = "$got_mode" ]; then
echo " PASS $label (mode=$got_mode)" echo " PASS $label (mode=$got_mode)"
PASS=$((PASS + 1)) PASS=$((PASS + 1))
@ -195,9 +194,8 @@ for a in "$@"; do
done done
exec /usr/bin/curl "${new_args[@]}" exec /usr/bin/curl "${new_args[@]}"
CURL_SHIM CURL_SHIM
# Now substitute FIXPORT with the actual port number. Use perl rather than # Now substitute FIXPORT with the actual port number
# sed -i so the test runs on both GNU sed and BSD/macOS sed. sed -i "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl"
perl -0pi -e "s/FIXPORT/${FIX_PORT}/g" "$FIXTURE_DIR/bin/curl"
chmod +x "$FIXTURE_DIR/bin/curl" chmod +x "$FIXTURE_DIR/bin/curl"
# Helper: run the script with fixture environment # Helper: run the script with fixture environment
@ -212,7 +210,6 @@ run_review_check() {
GITEA_HOST="fixture.local" \ GITEA_HOST="fixture.local" \
REPO="molecule-ai/molecule-core" \ REPO="molecule-ai/molecule-core" \
PR_NUMBER="999" \ PR_NUMBER="999" \
DEFAULT_BRANCH="main" \
TEAM="qa" \ TEAM="qa" \
TEAM_ID="20" \ TEAM_ID="20" \
REVIEW_CHECK_DEBUG="0" \ REVIEW_CHECK_DEBUG="0" \
@ -256,14 +253,6 @@ T4_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T4 exit code 1 (no candidates)" "1" "$T4_RC" assert_eq "T4 exit code 1 (no candidates)" "1" "$T4_RC"
assert_contains "T4 awaiting non-author APPROVE" "awaiting non-author APPROVE" "$T4_OUT" assert_contains "T4 awaiting non-author APPROVE" "awaiting non-author APPROVE" "$T4_OUT"
# T14 — non-default-base PR should not make the default branch red.
echo
echo "== T14 non-default base PR =="
T14_OUT=$(run_review_check "T14_non_default_base")
T14_RC=$(cat "$FIX_STATE_DIR/last_rc")
assert_eq "T14 exit code 0 (non-default base no-op)" "0" "$T14_RC"
assert_contains "T14 not applicable notice" "gate not applicable" "$T14_OUT"
# T5 — only author reviews → exit 1 # T5 — only author reviews → exit 1
echo echo
echo "== T5 only author reviews ==" echo "== T5 only author reviews =="
@ -307,10 +296,10 @@ echo "== T10 CURL_AUTH_FILE =="
# Verify the token-file logic directly: create a temp file with the # Verify the token-file logic directly: create a temp file with the
# same mktemp pattern, write the header with printf, chmod 600, then assert. # same mktemp pattern, write the header with printf, chmod 600, then assert.
T10_TOKEN="secret-test-token-abc123" T10_TOKEN="secret-test-token-abc123"
T10_AUTHFILE=$(mktemp "${TMPDIR:-/tmp}/curl-auth.test.XXXXXX") T10_AUTHFILE=$(mktemp -p /tmp curl-auth.test.XXXXXX)
chmod 600 "$T10_AUTHFILE" chmod 600 "$T10_AUTHFILE"
printf 'header = "Authorization: token %s"\n' "$T10_TOKEN" > "$T10_AUTHFILE" printf 'header = "Authorization: token %s"\n' "$T10_TOKEN" > "$T10_AUTHFILE"
assert_file_mode "T10a mktemp authfile mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600" assert_file_mode "T10a mktemp -p /tmp mode 600 (CURL_AUTH_FILE pattern)" "$T10_AUTHFILE" "600"
assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-test-token-abc123" assert_file_contains "T10b printf header format (CURL_AUTH_FILE content)" "$T10_AUTHFILE" "Authorization: token secret-test-token-abc123"
assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token ' assert_file_contains "T10c 'header =' curl-config syntax" "$T10_AUTHFILE" 'header = "Authorization: token '
rm -f "$T10_AUTHFILE" rm -f "$T10_AUTHFILE"

View File

@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/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 # Run: python3 .gitea/scripts/tests/test_sop_checklist_gate.py
# or: pytest .gitea/scripts/tests/test_sop_checklist.py # or: pytest .gitea/scripts/tests/test_sop_checklist_gate.py
# #
# RFC#351 Step 2 of 6 — implementation MVP. Tests cover: # RFC#351 Step 2 of 6 — implementation MVP. Tests cover:
# - slug normalization (the 4 example variants in the script header) # - slug normalization (the 4 example variants in the script header)
@ -33,7 +33,7 @@ sys.path.insert(0, PARENT)
import importlib.util # noqa: E402 import importlib.util # noqa: E402
_spec = importlib.util.spec_from_file_location( _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) sop = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sop) # type: ignore[union-attr] _spec.loader.exec_module(sop) # type: ignore[union-attr]
@ -134,22 +134,18 @@ class TestParseDirectives(unittest.TestCase):
def setUp(self): def setUp(self):
self.aliases = _numeric_aliases() 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): 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", "")]) self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
def test_simple_revoke(self): 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", "")]) self.assertEqual(d, [("sop-revoke", "staging-smoke", "")])
def test_ack_with_note(self): def test_ack_with_note(self):
d = self.parse_ack_revoke( d = sop.parse_directives(
"/sop-ack comprehensive-testing LGTM the test covers all edge cases" "/sop-ack comprehensive-testing LGTM the test covers all edge cases",
self.aliases,
) )
self.assertEqual(len(d), 1) self.assertEqual(len(d), 1)
self.assertEqual(d[0][0], "sop-ack") self.assertEqual(d[0][0], "sop-ack")
@ -157,12 +153,13 @@ class TestParseDirectives(unittest.TestCase):
self.assertIn("LGTM", d[0][2]) self.assertIn("LGTM", d[0][2])
def test_numeric_shorthand(self): 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", "")]) self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
def test_revoke_with_reason(self): def test_revoke_with_reason(self):
d = self.parse_ack_revoke( d = sop.parse_directives(
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB" "/sop-revoke comprehensive-testing realized the e2e was mocking the DB",
self.aliases,
) )
self.assertEqual(d[0][0], "sop-revoke") self.assertEqual(d[0][0], "sop-revoke")
self.assertEqual(d[0][1], "comprehensive-testing") self.assertEqual(d[0][1], "comprehensive-testing")
@ -174,7 +171,7 @@ class TestParseDirectives(unittest.TestCase):
"/sop-ack comprehensive-testing\n" "/sop-ack comprehensive-testing\n"
"Will follow up on the doc nit separately." "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(len(d), 1)
self.assertEqual(d[0][1], "comprehensive-testing") self.assertEqual(d[0][1], "comprehensive-testing")
@ -183,7 +180,7 @@ class TestParseDirectives(unittest.TestCase):
"/sop-ack comprehensive-testing\n" "/sop-ack comprehensive-testing\n"
"/sop-ack local-postgres-e2e\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) self.assertEqual(len(d), 2)
slugs = {x[1] for x in d} slugs = {x[1] for x in d}
self.assertEqual(slugs, {"comprehensive-testing", "local-postgres-e2e"}) 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 # A directive embedded mid-line is not honored (prevents review
# comments like "to /sop-ack you need..." from acting as acks). # comments like "to /sop-ack you need..." from acting as acks).
body = "If you want to /sop-ack comprehensive-testing reply in this thread" 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, []) self.assertEqual(d, [])
def test_leading_whitespace_allowed(self): def test_leading_whitespace_allowed(self):
body = " /sop-ack comprehensive-testing" body = " /sop-ack comprehensive-testing"
d = self.parse_ack_revoke(body) d = sop.parse_directives(body, self.aliases)
self.assertEqual(len(d), 1) self.assertEqual(len(d), 1)
def test_empty_body(self): def test_empty_body(self):
self.assertEqual(sop.parse_directives("", self.aliases), ([], [])) self.assertEqual(sop.parse_directives("", self.aliases), [])
self.assertEqual(sop.parse_directives(None, self.aliases), ([], [])) self.assertEqual(sop.parse_directives(None, self.aliases), [])
def test_normalization_applied(self): def test_normalization_applied(self):
# /sop-ack Comprehensive_Testing → canonical comprehensive-testing # /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") self.assertEqual(d[0][1], "comprehensive-testing")

View File

@ -32,7 +32,6 @@ THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)" SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)" WORKFLOW_DIR="$(cd "$THIS_DIR/../../workflows" && pwd)"
WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml" WORKFLOW="$WORKFLOW_DIR/sop-tier-refire.yml"
DISPATCH_WORKFLOW="$WORKFLOW_DIR/review-refire-comments.yml"
SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh" SCRIPT="$SCRIPT_DIR/sop-tier-refire.sh"
PASS=0 PASS=0
@ -88,7 +87,6 @@ assert_file_exists() {
echo echo
echo "== existence ==" echo "== existence =="
assert_file_exists "workflow file exists" "$WORKFLOW" assert_file_exists "workflow file exists" "$WORKFLOW"
assert_file_exists "dispatcher workflow file exists" "$DISPATCH_WORKFLOW"
assert_file_exists "script file exists" "$SCRIPT" assert_file_exists "script file exists" "$SCRIPT"
if [ "$FAIL" -gt 0 ]; then if [ "$FAIL" -gt 0 ]; then
echo 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) 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" assert_eq "T7 workflow parses as YAML" "ok" "$PARSE_OUT"
# The old per-workflow issue_comment listener caused queue storms because # Three required gates in the `if:` expression
# Gitea queues jobs before evaluating job-level `if:`. The script remains,
# but comment-triggered refires route through the single dispatcher.
WORKFLOW_CONTENT=$(cat "$WORKFLOW") WORKFLOW_CONTENT=$(cat "$WORKFLOW")
if printf '%s' "$WORKFLOW_CONTENT" | grep -q '^ issue_comment:'; then assert_contains "T6a workflow if: contains author_association gate" \
echo " FAIL T6a manual fallback workflow must not listen on issue_comment" "github.event.comment.author_association" "$WORKFLOW_CONTENT"
FAIL=$((FAIL + 1)) assert_contains "T6b workflow if: gates on MEMBER/OWNER/COLLABORATOR" \
FAILED_TESTS="${FAILED_TESTS} T6a" '["MEMBER","OWNER","COLLABORATOR"]' "$WORKFLOW_CONTENT"
else assert_contains "T6c workflow if: contains slash-command trigger" \
echo " PASS T6a manual fallback workflow does not listen on issue_comment" "/refire-tier-check" "$WORKFLOW_CONTENT"
PASS=$((PASS + 1)) assert_contains "T6d workflow if: gates on PR-not-issue" \
fi "github.event.issue.pull_request" "$WORKFLOW_CONTENT"
assert_contains "T6b workflow exposes workflow_dispatch" \ assert_contains "T6e workflow listens on issue_comment" \
"workflow_dispatch" "$WORKFLOW_CONTENT" "issue_comment" "$WORKFLOW_CONTENT"
assert_contains "T6c workflow documents unsupported manual inputs" \ assert_contains "T6f workflow requests statuses:write permission" \
"workflow_dispatch inputs" "$WORKFLOW_CONTENT" "statuses: write" "$WORKFLOW_CONTENT"
# Does NOT check out PR HEAD (security) # Does NOT check out PR HEAD (security)
if grep -q 'ref: \${{ github.event.pull_request.head' "$WORKFLOW"; then 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)) FAIL=$((FAIL + 1))
FAILED_TESTS="${FAILED_TESTS} T6d" FAILED_TESTS="${FAILED_TESTS} T6g"
else else
echo " PASS T6d workflow does not check out PR head" echo " PASS T6g workflow does not check out PR head"
PASS=$((PASS + 1)) PASS=$((PASS + 1))
fi 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 # T1-T5 — script behavior against a local Gitea-fixture
echo echo
echo "== T1-T5 script behavior (vs local fixture) ==" echo "== T1-T5 script behavior (vs local fixture) =="

View File

@ -1,169 +0,0 @@
import importlib.util
import json
import pathlib
import urllib.error
ROOT = pathlib.Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "status-reaper.py"
def load_reaper():
spec = importlib.util.spec_from_file_location("status_reaper", SCRIPT)
mod = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(mod)
mod.API = "https://git.example.test/api/v1"
mod.GITEA_TOKEN = "test-token"
mod.API_TIMEOUT_SEC = 1
mod.API_RETRIES = 3
mod.API_RETRY_SLEEP_SEC = 0
return mod
class FakeResponse:
status = 200
def __init__(self, payload):
self.payload = payload
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
def read(self):
return json.dumps(self.payload).encode("utf-8")
def test_api_retries_transient_timeout(monkeypatch):
mod = load_reaper()
calls = {"n": 0}
def fake_urlopen(req, timeout):
calls["n"] += 1
if calls["n"] == 1:
raise TimeoutError("simulated slow Gitea API")
return FakeResponse({"ok": True})
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
status, body = mod.api("GET", "/repos/o/r/commits")
assert status == 200
assert body == {"ok": True}
assert calls["n"] == 2
def test_api_raises_after_retry_budget(monkeypatch):
mod = load_reaper()
def fake_urlopen(req, timeout):
raise urllib.error.URLError("connection reset")
monkeypatch.setattr(mod.urllib.request, "urlopen", fake_urlopen)
try:
mod.api("GET", "/repos/o/r/commits")
except mod.ApiError as exc:
assert "failed after 3 attempts" in str(exc)
else:
raise AssertionError("expected ApiError")
def test_reap_compensates_failed_pr_context_when_push_equivalent_passed(monkeypatch):
mod = load_reaper()
posted = []
def fake_post(sha, context, target_url, *, description="", dry_run=False):
posted.append((sha, context, target_url, description, dry_run))
monkeypatch.setattr(mod, "post_compensating_status", fake_post)
counters = mod.reap(
{"CI": True, "Handlers Postgres Integration": True},
{
"statuses": [
{
"context": "CI / Platform (Go) (pull_request)",
"status": "failure",
"target_url": "https://git.example.test/ci-pr",
},
{
"context": "CI / Platform (Go) (push)",
"status": "success",
},
{
"context": (
"Handlers Postgres Integration / "
"Handlers Postgres Integration (pull_request)"
),
"status": "failure",
"target_url": "https://git.example.test/handlers-pr",
},
{
"context": (
"Handlers Postgres Integration / "
"Handlers Postgres Integration (push)"
),
"status": "success",
},
],
},
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
)
assert counters["compensated_pr_shadowed_by_push_success"] == 2
assert posted == [
(
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
"CI / Platform (Go) (pull_request)",
"https://git.example.test/ci-pr",
mod.PR_SHADOW_COMPENSATION_DESCRIPTION,
False,
),
(
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
"Handlers Postgres Integration / Handlers Postgres Integration (pull_request)",
"https://git.example.test/handlers-pr",
mod.PR_SHADOW_COMPENSATION_DESCRIPTION,
False,
),
]
def test_reap_preserves_failed_pr_context_without_push_success(monkeypatch):
mod = load_reaper()
posted = []
monkeypatch.setattr(
mod,
"post_compensating_status",
lambda sha, context, target_url, *, description="", dry_run=False: posted.append(
context
),
)
counters = mod.reap(
{"CI": True},
{
"statuses": [
{
"context": "CI / Platform (Go) (pull_request)",
"status": "failure",
},
{
"context": "CI / Platform (Go) (push)",
"status": "failure",
},
{
"context": "CI / Shellcheck (pull_request)",
"status": "failure",
},
],
},
"db3b7a93e31adc0cb072a6d177d92dd73275a191",
)
assert counters["preserved_pr_without_push_success"] == 2
assert posted == []

View File

@ -107,39 +107,3 @@ items:
description: >- description: >-
List of feedback memories applicable to this change. Ack from List of feedback memories applicable to this change. Ack from
any engineer who has the same memory access. 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.

View File

@ -43,7 +43,6 @@ permissions:
contents: read contents: read
jobs: jobs:
# bp-exempt: drift visibility gate; CI / all-required remains the required aggregate.
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking # Phase 3 (RFC #219 §1): surface broken workflows without blocking

View File

@ -1,165 +0,0 @@
name: MCP Stdio Transport Regression
# Regression test for molecule-ai-workspace-runtime#61:
# asyncio.connect_read_pipe / connect_write_pipe fail with
# ValueError: "Pipe transport is only for pipes, sockets and character devices"
# when stdout is a regular file (openclaw capture, CI tee, debugging).
#
# This workflow reproduces the exact failure mode and verifies the
# fallback to direct buffer I/O works. It runs on every PR that
# touches the MCP server or this workflow, plus nightly cron.
#
# Why a separate workflow (not folded into ci.yml python-lint):
# - The test needs to spawn the MCP server with stdout redirected
# to a regular file (not a TTY/pipe), which conflicts with
# pytest's own capture mechanism.
# - It exercises the actual process spawn path (python a2a_mcp_server.py)
# not just unit-test mocks — closer to the real openclaw integration.
# - A dedicated workflow surfaces stdio-specific regressions without
# coupling to the broader Python test suite's coverage gate.
on:
pull_request:
branches: [main, staging]
paths:
- 'workspace/a2a_mcp_server.py'
- 'workspace/mcp_cli.py'
- 'workspace/tests/test_a2a_mcp_server.py'
- '.gitea/workflows/ci-mcp-stdio-transport.yml'
push:
branches: [main, staging]
paths:
- 'workspace/a2a_mcp_server.py'
- 'workspace/mcp_cli.py'
- 'workspace/tests/test_a2a_mcp_server.py'
- '.gitea/workflows/ci-mcp-stdio-transport.yml'
schedule:
# Nightly at 04:00 UTC — catches drift from dependency updates
# (e.g. asyncio behavior changes in new Python patch releases).
- cron: '0 4 * * *'
concurrency:
group: mcp-stdio-${{ github.ref }}
cancel-in-progress: true
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# bp-exempt: regression canary for runtime#61; not a merge gate — informational only until promoted to required.
# mc#774: continue-on-error mask — new workflow, flip to false once it's green on ≥3 consecutive main runs.
mcp-stdio-regular-file:
name: MCP stdio with regular-file stdout
runs-on: ubuntu-latest
continue-on-error: true # mc#774
timeout-minutes: 5
env:
WORKSPACE_ID: "00000000-0000-0000-0000-000000000001"
defaults:
run:
working-directory: workspace
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: '3.11'
cache: pip
cache-dependency-path: workspace/requirements.txt
- run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov
- name: Reproduce runtime#61 — stdout as regular file
run: |
set -euo pipefail
echo "=== Reproducing molecule-ai-workspace-runtime#61 ==="
echo ""
echo "Before the fix, this command would fail with:"
echo ' ValueError: Pipe transport is only for pipes, sockets and character devices'
echo ""
# Spawn the MCP server with stdout redirected to a regular file.
# This is exactly what openclaw does when capturing MCP output.
OUTPUT=$(mktemp)
trap 'rm -f "$OUTPUT"' EXIT
# Send initialize request, then tools/list, then exit
{
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
echo '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
} | python a2a_mcp_server.py > "$OUTPUT" 2>&1 || {
RC=$?
echo "FAIL: MCP server exited with code $RC"
echo "--- stdout+stderr ---"
cat "$OUTPUT"
exit 1
}
echo "PASS: MCP server handled regular-file stdout without crashing"
echo ""
echo "--- Output (first 20 lines) ---"
head -20 "$OUTPUT"
echo ""
# Verify we got valid JSON-RPC responses
if grep -q '"result"' "$OUTPUT"; then
echo "PASS: JSON-RPC responses found in output"
else
echo "FAIL: No JSON-RPC responses in output"
cat "$OUTPUT"
exit 1
fi
- name: Reproduce runtime#61 — stdin from regular file
run: |
set -euo pipefail
echo "=== stdin as regular file (CI tee / capture pattern) ==="
INPUT=$(mktemp)
OUTPUT=$(mktemp)
trap 'rm -f "$INPUT" "$OUTPUT"' EXIT
cat > "$INPUT" <<'EOF'
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
EOF
python a2a_mcp_server.py < "$INPUT" > "$OUTPUT" 2>&1 || {
RC=$?
echo "FAIL: MCP server exited with code $RC"
cat "$OUTPUT"
exit 1
}
echo "PASS: MCP server handled regular-file stdin without crashing"
if grep -q '"result"' "$OUTPUT"; then
echo "PASS: JSON-RPC responses found in output"
else
echo "FAIL: No JSON-RPC responses in output"
cat "$OUTPUT"
exit 1
fi
- name: Verify warning is emitted for non-pipe stdio
run: |
set -euo pipefail
echo "=== Verify diagnostic warning ==="
OUTPUT=$(mktemp)
trap 'rm -f "$OUTPUT"' EXIT
{
echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'
} | python a2a_mcp_server.py > "$OUTPUT" 2>&1
# The warning should mention "not a pipe" for operator visibility
if grep -qi "not a pipe" "$OUTPUT"; then
echo "PASS: Diagnostic warning emitted for non-pipe stdio"
else
echo "NOTE: No warning in output (may be suppressed by log level)"
fi
- name: Run unit tests for stdio transport
run: |
set -euo pipefail
echo "=== Running stdio transport unit tests ==="
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion -v --no-cov

View File

@ -107,25 +107,16 @@ jobs:
echo "scripts=true" >> "$GITHUB_OUTPUT" echo "scripts=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# Workflow-only edits are covered by the workflow lint family # Both .github/workflows/ci.yml AND .gitea/workflows/ci.yml count
# and by this workflow's always-present required jobs. Do not fan # as "this workflow changed" — either edit should force-run every
# those edits out into Go/Canvas/Python/shellcheck work; the # downstream job. The Gitea port follows the same shape as the
# downstream jobs still emit their required contexts via no-op # GitHub original so behavior matches when triggered on either
# steps when their surface flag is false. # platform.
# DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo ".gitea/workflows/ci.yml")
# If the diff itself cannot be trusted, fail open by running every echo "platform=$(echo "$DIFF" | grep -qE '^workspace-server/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
# surface instead of silently under-testing the PR. echo "canvas=$(echo "$DIFF" | grep -qE '^canvas/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
if ! DIFF=$(git diff --name-only "$BASE" HEAD 2>/dev/null); then echo "python=$(echo "$DIFF" | grep -qE '^workspace/|^\.gitea/workflows/ci\.yml$|^\.github/workflows/ci\.yml$' && echo true || echo false)" >> "$GITHUB_OUTPUT"
echo "platform=true" >> "$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"
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"
# Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run # Platform (Go) — Go build/vet/test/lint + coverage gates. The always-run
# + per-step gating shape preserves the GitHub-side required-check name # + 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/). # the name match works on PRs that don't touch workspace-server/).
platform-build: platform-build:
name: Platform (Go) name: Platform (Go)
needs: changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
# mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job. # mc#774 (interim): re-mask platform-build pending fix-forward. Phase 4
# Phase 4 (#656) originally flipped this to continue-on-error: false based on # (#656) flipped this to continue-on-error: false based on a Phase-3-masked
# Phase-3-masked "green on main 2026-05-12". Two failure classes then surfaced: # "green on main 2026-05-12" — the prior continue-on-error: true had
# (1) 4x delegation_test.go sqlmock gaps (PR #669 / #634 fix-forward, closed). # been hiding failing tests in workspace-server/internal/handlers/.
# (2) TestMCPHandler_CommitMemory_GlobalScope_Blocked (mcp_test.go:433): # Two distinct failure classes surfaced on 0e5152c3:
# OFFSEC-001 hardening collided with test assertion; tracked in mc#762. # (1) 4x delegation_test.go (lines 1110/1176/1228/1271): helpers
# Fix-forward for (1) landed in PR #669. The mc#762 gap (2) is a separate # expectExecuteDelegationBase/Success/Failed are missing sqlmock
# issue — it does NOT block this flip because the test is already wrapped in # expectations for queries production has issued since ~2026-04-21
# the diagnostic step with its own continue-on-error: true (line 203). # (last_outbound_at UPDATE, lookupDeliveryMode/Runtime SELECTs,
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3. # a2a_receive INSERT activity_logs, recordLedgerStatus writes).
continue-on-error: false # Halt cond #3 applies (regression > 7 days → broader sweep).
# Job-level ceiling. The go test step below runs with a per-step 10m timeout; # (2) 1x mcp_test.go:433 (TestMCPHandler_CommitMemory_GlobalScope_Blocked):
# this cap catches any step that leaks past that. Set well above 10m so # commit 7d1a189f (2026-05-10) hardened mcp.go to scrub err.Error()
# the per-step timeout is the active constraint. # from JSON-RPC responses (OFFSEC-001), but the test asserts the
timeout-minutes: 15 # 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: defaults:
run: run:
working-directory: workspace-server working-directory: workspace-server
steps: steps:
- if: false - if: needs.changes.outputs.platform != 'true'
working-directory: . working-directory: .
run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." 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 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always() - if: needs.changes.outputs.platform == 'true'
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with: with:
go-version: 'stable' go-version: 'stable'
- if: always() - if: needs.changes.outputs.platform == 'true'
run: go mod download run: go mod download
- if: always() - if: needs.changes.outputs.platform == 'true'
run: go build ./cmd/server run: go build ./cmd/server
# CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli # CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli
- if: always() - if: needs.changes.outputs.platform == 'true'
run: go vet ./... run: go vet ./...
- if: always() - if: needs.changes.outputs.platform == 'true'
name: Install golangci-lint name: Install golangci-lint
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 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 name: Run golangci-lint
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./... run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
- if: always() - if: needs.changes.outputs.platform == 'true'
name: Diagnostic — per-package verbose 60s name: Diagnostic — per-package verbose 60s
run: | run: |
set +e set +e
@ -191,15 +192,11 @@ jobs:
echo "::endgroup::" echo "::endgroup::"
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true continue-on-error: true
- if: always() - if: needs.changes.outputs.platform == 'true'
name: Run tests with race detection and coverage name: Run tests with race detection and coverage
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the run: go test -race -coverprofile=coverage.out ./...
# full ./... suite with race detection + coverage. A 10m per-step timeout
# lets the suite complete on cold cache (~5-7m) while failing cleanly
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
- if: always() - if: needs.changes.outputs.platform == 'true'
name: Per-file coverage report name: Per-file coverage report
# Advisory — lists every source file with its coverage so reviewers # Advisory — lists every source file with its coverage so reviewers
# can see at-a-glance where gaps are. Sorted ascending so the worst # 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}' \ END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \
| sort -n | sort -n
- if: always() - if: needs.changes.outputs.platform == 'true'
name: Check coverage thresholds name: Check coverage thresholds
# Enforces two gates from #1823 Layer 1: # Enforces two gates from #1823 Layer 1:
# 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md). # 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md).
@ -301,28 +298,28 @@ jobs:
# siblings — verified empirically on PR #2314). # siblings — verified empirically on PR #2314).
canvas-build: canvas-build:
name: Canvas (Next.js) name: Canvas (Next.js)
needs: changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12. # Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false continue-on-error: false
defaults: defaults:
run: run:
working-directory: canvas working-directory: canvas
steps: steps:
- if: false - if: needs.changes.outputs.canvas != 'true'
working-directory: . working-directory: .
run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." 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 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always() - if: needs.changes.outputs.canvas == 'true'
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: '22' node-version: '22'
- if: always() - if: needs.changes.outputs.canvas == 'true'
run: rm -f package-lock.json && npm install run: rm -f package-lock.json && npm install
- if: always() - if: needs.changes.outputs.canvas == 'true'
run: npm run build run: npm run build
- if: always() - if: needs.changes.outputs.canvas == 'true'
name: Run tests with coverage name: Run tests with coverage
# Coverage instrumentation is configured in canvas/vitest.config.ts # Coverage instrumentation is configured in canvas/vitest.config.ts
# (provider: v8, reporters: text + html + json-summary). Step 2 of # (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. # tracked in #1815) after the team sees what current coverage is.
run: npx vitest run --coverage run: npx vitest run --coverage
- name: Upload coverage summary as artifact - 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 # 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 # the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT
# implement, surfacing as `GHESNotSupportedError: @actions/artifact # implement, surfacing as `GHESNotSupportedError: @actions/artifact
@ -348,15 +345,16 @@ jobs:
# Shellcheck (E2E scripts) — required check, always runs. # Shellcheck (E2E scripts) — required check, always runs.
shellcheck: shellcheck:
name: Shellcheck (E2E scripts) name: Shellcheck (E2E scripts)
needs: changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12. # Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false continue-on-error: false
steps: 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." 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 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 name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh
# shellcheck is pre-installed on ubuntu-latest runners (via apt). # shellcheck is pre-installed on ubuntu-latest runners (via apt).
# infra/scripts/ is included because setup.sh + nuke.sh gate the # infra/scripts/ is included because setup.sh + nuke.sh gate the
@ -367,61 +365,32 @@ jobs:
find tests/e2e infra/scripts -type f -name '*.sh' -print0 \ find tests/e2e infra/scripts -type f -name '*.sh' -print0 \
| xargs -0 shellcheck --severity=warning | xargs -0 shellcheck --severity=warning
- if: always() - if: needs.changes.outputs.scripts == 'true'
name: Lint cleanup-trap hygiene (RFC #2873) name: Lint cleanup-trap hygiene (RFC #2873)
run: bash tests/e2e/lint_cleanup_traps.sh 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) name: Run E2E bash unit tests (no live infra)
run: | run: |
bash tests/e2e/test_model_slug.sh 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
canvas-deploy-reminder: canvas-deploy-reminder:
name: Canvas Deploy Reminder name: Canvas Deploy Reminder
runs-on: ubuntu-latest runs-on: ubuntu-latest
# This job must run on PRs because all-required needs it. The step exits # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
# 0 when it is not a main push, giving branch protection a green no-op continue-on-error: true
# instead of a skipped/missing required dependency. needs: [changes, canvas-build]
needs: 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: steps:
- name: Write deploy reminder to step summary - name: Write deploy reminder to step summary
env: env:
COMMIT_SHA: ${{ github.sha }} COMMIT_SHA: ${{ github.sha }}
CANVAS_CHANGED: "true"
EVENT_NAME: ${{ github.event_name }}
REF_NAME: ${{ github.ref }}
# github.server_url resolves via the workflow-level env override # github.server_url resolves via the workflow-level env override
# to the Gitea instance, so the RUN_URL points at the Gitea run # to the Gitea instance, so the RUN_URL points at the Gitea run
# page (not github.com). See feedback_act_runner_github_server_url. # page (not github.com). See feedback_act_runner_github_server_url.
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: | 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. # Write body to a temp file — avoids backtick escaping in shell.
cat > /tmp/deploy-reminder.md << 'BODY' cat > /tmp/deploy-reminder.md << 'BODY'
## Canvas build passed — deploy required ## Canvas build passed — deploy required
@ -453,6 +422,7 @@ jobs:
# Python Lint & Test — required check, always runs. # Python Lint & Test — required check, always runs.
python-lint: python-lint:
name: Python Lint & Test name: Python Lint & Test
needs: changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12. # Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
continue-on-error: false continue-on-error: false
@ -462,25 +432,25 @@ jobs:
run: run:
working-directory: workspace working-directory: workspace
steps: steps:
- if: false - if: needs.changes.outputs.python != 'true'
working-directory: . working-directory: .
run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection." 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 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- if: always() - if: needs.changes.outputs.python == 'true'
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: '3.11' python-version: '3.11'
cache: pip cache: pip
cache-dependency-path: workspace/requirements.txt 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 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 # Coverage flags + fail-under floor moved into workspace/pytest.ini
# (issue #1817) so local `pytest` and CI use identical config. # (issue #1817) so local `pytest` and CI use identical config.
- if: always() - if: needs.changes.outputs.python == 'true'
run: python -m pytest --tb=short run: python -m pytest --tb=short
- if: always() - if: needs.changes.outputs.python == 'true'
name: Per-file critical-path coverage (MCP / inbox / auth) name: Per-file critical-path coverage (MCP / inbox / auth)
# MCP-critical Python files have a per-file floor on top of the # 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. # 86% total floor in pytest.ini. See issue #2790 for full rationale.
@ -545,104 +515,85 @@ jobs:
# red silently merged through. See internal#286 for the three concrete # red silently merged through. See internal#286 for the three concrete
# tonight-of-2026-05-11 incidents that prompted the emergency bump. # 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 # Three properties of this job each close a failure mode:
# 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.
# #
# canvas-deploy-reminder is intentionally NOT included in all-required.needs. # 1. `if: always()` — runs even when an upstream fails. Without it the
# It is an informational main-push reminder, not a PR quality gate. Keeping # sentinel is `skipped` and protection treats that as missing → merge
# it in this dependency list lets a skipped reminder skip the required # ungated.
# sentinel before the `always()` guard can emit a branch-protection status.
# #
# 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 continue-on-error: false
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 45 timeout-minutes: 1
needs:
- changes
- platform-build
- canvas-build
- shellcheck
- python-lint
if: always()
steps: steps:
- name: Wait for required CI contexts - name: Assert every required dependency succeeded
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 }}
run: | run: |
set -euo pipefail set -euo pipefail
python3 - <<'PY' # `needs.*.result` is one of: success | failure | cancelled | skipped | null.
import json # We assert success per dep (not != failure) — see RFC §2 reasoning above.
import os # Null results are skipped: they come from Phase 3 (continue-on-error: true
import sys # suppresses status) or from jobs still in-flight. The sentinel succeeds
import time # rather than blocking PRs on Phase 3 noise.
import urllib.error results='${{ toJSON(needs) }}'
import urllib.request echo "$results"
echo "$results" | python3 -c '
token = os.environ["GITEA_TOKEN"] import json, sys
api_root = os.environ["API_ROOT"].rstrip("/") ns = json.load(sys.stdin)
repo = os.environ["REPOSITORY"] # Phase 3 masked: jobs with continue-on-error: true may report "failure"
sha = os.environ["COMMIT_SHA"] # Remove when mc#774 handler test failures are resolved.
event = os.environ["EVENT_NAME"] PHASE3_MASKED = {"platform-build"}
required = [ # Exclude null (Phase 3 suppressed / in-flight) from the bad list.
f"CI / Detect changes ({event})", bad = [(k, v.get("result")) for k, v in ns.items()
f"CI / Platform (Go) ({event})", if v.get("result") not in ("success", None, "cancelled", "skipped") and k not in PHASE3_MASKED]
f"CI / Canvas (Next.js) ({event})", if bad:
f"CI / Shellcheck (E2E scripts) ({event})", print(f"FAIL: jobs not green:", file=sys.stderr)
f"CI / Python Lint & Test ({event})", for k, r in bad:
] print(f" - {k}: {r}", file=sys.stderr)
terminal_bad = {"failure", "error"} sys.exit(1)
deadline = time.time() + 40 * 60 pending = [(k, v.get("result")) for k, v in ns.items()
last_summary = None if v.get("result") is None]
cancelled = [(k, v.get("result")) for k, v in ns.items()
def fetch_statuses(): if v.get("result") == "cancelled"]
statuses = [] if pending:
for page in range(1, 6): print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " +
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100" ", ".join(k for k, _ in pending), file=sys.stderr)
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) if cancelled:
with urllib.request.urlopen(req, timeout=10) as resp: print(f"INFO: {len(cancelled)} job(s) masked by continue-on-error: " +
chunk = json.load(resp) ", ".join(k for k, _ in cancelled), file=sys.stderr)
if not chunk: print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)")
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

View File

@ -69,13 +69,6 @@ name: E2E API Smoke Test
# 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when # 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when
# they DO come up. Timeouts are not the bottleneck; not bumped. # 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` # Item explicitly NOT fixed here: failing test `Status back online`
# fails because the platform's langgraph workspace template image # fails because the platform's langgraph workspace template image
# (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns # (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns
@ -290,35 +283,6 @@ jobs:
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV" echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV" echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
echo "Platform host port: ${PLATFORM_PORT}" 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) - name: Start platform (background)
if: needs.detect-changes.outputs.api == 'true' if: needs.detect-changes.outputs.api == 'true'
working-directory: workspace-server working-directory: workspace-server
@ -382,4 +346,3 @@ jobs:
run: | run: |
docker rm -f "$PG_CONTAINER" 2>/dev/null || true docker rm -f "$PG_CONTAINER" 2>/dev/null || true
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true

View File

@ -1,225 +0,0 @@
name: E2E Peer Visibility (literal MCP list_peers)
# WHY A DEDICATED WORKFLOW (not folded into e2e-staging-saas.yml)
# --------------------------------------------------------------
# This is the systemic fix for a real trust failure. Hermes and OpenClaw
# were reported "fleet-verified / cascade-complete" because the *proxy*
# signals were green (registry registration + heartbeat for Hermes; model
# round-trip 200 for OpenClaw). A freshly-provisioned workspace asked on
# canvas "can you see your peers" actually FAILS:
# - Hermes: 401 on the molecule MCP `list_peers` call
# - OpenClaw: native `sessions_list` fallback, sees no platform peers
# Tasks #142/#159 were even marked "completed" under this proxy flaw.
#
# A dedicated workflow (vs extending e2e-staging-saas.yml) because:
# - It must provision MULTIPLE distinct runtimes (hermes, openclaw,
# claude-code) in ONE org and assert each sees the others. The
# full-saas script is single-runtime-per-run (E2E_RUNTIME) and folding
# a multi-runtime matrix into it would conflate concerns and bloat its
# already-45-min run.
# - It needs its own concurrency group so it doesn't fight full-saas /
# canvas for the staging org-creation quota.
# - It needs an independent, non-required status-context name so it can
# be RED today (the in-flight Hermes-401 / OpenClaw-MCP-wiring fixes
# have not landed) WITHOUT wedging unrelated merges — and flipped to
# REQUIRED in one branch-protection edit once it goes green
# (flip-to-required checklist: molecule-core#1296).
#
# THE ASSERTION IS NOT A PROXY. The driving script
# tests/e2e/test_peer_visibility_mcp_staging.sh issues the byte-for-byte
# JSON-RPC `tools/call name=list_peers` envelope to `POST
# /workspaces/:id/mcp` using each workspace's OWN bearer token, through
# the real WorkspaceAuth + MCPRateLimiter middleware chain — the exact
# call mcp_molecule_list_peers makes from a canvas agent. It does NOT
# read a registry row, /health, the heartbeat table, or
# GET /registry/:id/peers.
#
# HONEST GATE — NO continue-on-error. Per feedback_fix_root_not_symptom a
# fake-green mask would defeat the entire purpose. This workflow goes red
# on today's broken behavior and green only when the root-cause fixes
# actually land. It is intentionally NOT in branch_protections — see PR
# body for the required-vs-not decision + flip tracking issue.
#
# Gitea 1.22.6 / act_runner notes honored:
# - No cross-repo `uses:` (feedback_gitea_cross_repo_uses_blocked). The
# actions/checkout SHA is the one e2e-staging-canvas.yml already uses
# successfully (a mirrored SHA — see #1277/PR#1292 root-cause).
# - Per-SHA concurrency, not global (feedback_concurrency_group_per_sha).
# - Workflow-level GITHUB_SERVER_URL pinned
# (feedback_act_runner_github_server_url).
# - pr-validate posts a status under the same check name so a
# workflow-only PR is not silently statusless and the context is
# flip-to-required-ready (mirrors e2e-staging-saas.yml's proven shape;
# real EC2-provisioning E2E is push/dispatch/cron only — it is 30+ min
# and cannot run per-PR-update).
on:
push:
branches: [main]
paths:
- 'workspace-server/internal/handlers/mcp.go'
- 'workspace-server/internal/handlers/mcp_tools.go'
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'workspace/a2a_mcp_server.py'
- 'workspace/platform_tools/registry.py'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
pull_request:
branches: [main]
paths:
- 'workspace-server/internal/handlers/mcp.go'
- 'workspace-server/internal/handlers/mcp_tools.go'
- 'workspace-server/internal/middleware/**'
- 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace.go'
- 'workspace/a2a_mcp_server.py'
- 'workspace/platform_tools/registry.py'
- 'tests/e2e/test_peer_visibility_mcp_staging.sh'
- '.gitea/workflows/e2e-peer-visibility.yml'
workflow_dispatch:
schedule:
# 07:30 UTC daily — catches AMI / template-hermes / template-openclaw
# drift even on quiet days. Offset 30m from e2e-staging-saas (07:00)
# so the two don't collide on the staging org-creation quota.
- cron: '30 7 * * *'
concurrency:
# Per-SHA (feedback_concurrency_group_per_sha). A single global group
# would let a queued staging/main push behind a PR run get cancelled,
# leaving any gate that reads "completed run at SHA" stuck.
group: e2e-peer-visibility-${{ github.event.pull_request.head.sha || github.sha }}
cancel-in-progress: false
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
jobs:
# PR path: post a real status under the required-ready check name so a
# workflow-only PR is never silently statusless. The actual EC2 E2E is
# push/dispatch/cron only (30+ min). This is NOT a fake-green mask of
# the real assertion — it validates the driving script's bash syntax
# and inline-python so a broken test script fails at PR time.
pr-validate:
name: E2E Peer Visibility
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
timeout-minutes: 5
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Validate driving script
run: |
bash -n tests/e2e/test_peer_visibility_mcp_staging.sh
echo "test_peer_visibility_mcp_staging.sh — bash syntax OK"
echo "Real fresh-provision MCP list_peers E2E runs on push to"
echo "main / workflow_dispatch / daily cron (30+ min EC2 boot)."
# Real gate: provisions a throwaway org + sibling-per-runtime, drives
# the LITERAL list_peers MCP call per runtime, asserts 200 + expected
# peer set, then scoped teardown. push(main)/dispatch/cron only.
peer-visibility:
name: E2E Peer Visibility
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
timeout-minutes: 60
env:
MOLECULE_CP_URL: https://staging-api.moleculesai.app
MOLECULE_ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
# LLM provider key so each runtime can authenticate at boot.
# Priority MiniMax → direct-Anthropic → OpenAI matches
# test_staging_full_saas.sh's secrets-injection chain.
E2E_MINIMAX_API_KEY: ${{ secrets.MOLECULE_STAGING_MINIMAX_API_KEY }}
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
PV_RUNTIMES: "hermes openclaw claude-code"
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify admin token present
run: |
if [ -z "$MOLECULE_ADMIN_TOKEN" ]; then
echo "::error::CP_STAGING_ADMIN_API_TOKEN secret not set (Railway staging CP_ADMIN_API_TOKEN)"
exit 2
fi
echo "Admin token present"
- name: Verify an LLM key present
run: |
if [ -z "${E2E_MINIMAX_API_KEY:-}" ] && [ -z "${E2E_ANTHROPIC_API_KEY:-}" ] && [ -z "${E2E_OPENAI_API_KEY:-}" ]; then
echo "::error::No LLM provider key set — workspaces fail at boot with 'No provider API key found'. Set MOLECULE_STAGING_MINIMAX_API_KEY (or ANTHROPIC / OPENAI)."
exit 2
fi
echo "LLM key present"
- name: CP staging health preflight
run: |
code=$(curl -sS -o /dev/null -w "%{http_code}" --max-time 10 "$MOLECULE_CP_URL/health")
if [ "$code" != "200" ]; then
echo "::error::Staging CP unhealthy (HTTP $code) — infra, not a workspace bug. Failing loud per feedback_fix_root_not_symptom."
exit 1
fi
echo "Staging CP healthy"
- name: Run fresh-provision peer-visibility E2E (literal MCP list_peers)
run: bash tests/e2e/test_peer_visibility_mcp_staging.sh
# Belt-and-braces scoped teardown: the script installs an EXIT/INT/
# TERM trap, but if the runner itself is cancelled the trap may not
# fire. This always() step deletes ONLY the e2e-pv-<run_id> org this
# run created — never a cluster-wide sweep
# (feedback_never_run_cluster_cleanup_tests_on_live_platform). The
# admin DELETE is idempotent so double-invoking is safe;
# sweep-stale-e2e-orgs is the final net (slug starts with 'e2e-').
- name: Teardown safety net (runs on cancel/failure)
if: always()
env:
ADMIN_TOKEN: ${{ secrets.CP_STAGING_ADMIN_API_TOKEN }}
run: |
set +e
orgs=$(curl -sS "$MOLECULE_CP_URL/cp/admin/orgs?limit=500" \
-H "Authorization: Bearer $ADMIN_TOKEN" 2>/dev/null \
| python3 -c "
import json, sys, os, datetime
run_id = os.environ.get('GITHUB_RUN_ID', '')
try:
d = json.load(sys.stdin)
except Exception:
print(''); sys.exit(0)
# ONLY sweep slugs from THIS run. e2e-pv-<YYYYMMDD>-<run_id>-...
# Sweep today AND yesterday's UTC date so a midnight-crossing run
# still matches its own slug (same bug class as the saas/canvas
# safety nets).
today = datetime.date.today()
yest = today - datetime.timedelta(days=1)
dates = (today.strftime('%Y%m%d'), yest.strftime('%Y%m%d'))
if run_id:
prefixes = tuple(f'e2e-pv-{dt}-{run_id}-' for dt in dates)
else:
prefixes = tuple(f'e2e-pv-{dt}-' for dt in dates)
orgs = d if isinstance(d, list) else d.get('orgs', [])
cands = [o['slug'] for o in orgs
if any(o.get('slug','').startswith(p) for p in prefixes)
and o.get('instance_status') not in ('purged',)]
print('\n'.join(cands))
" 2>/dev/null)
for slug in $orgs; do
echo "Safety-net teardown: $slug"
set +e
curl -sS -o /tmp/pv-cleanup.out -w "%{http_code}" \
-X DELETE "$MOLECULE_CP_URL/cp/admin/tenants/$slug" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"confirm\":\"$slug\"}" >/tmp/pv-cleanup.code
set -e
code=$(cat /tmp/pv-cleanup.code 2>/dev/null || echo "000")
if [ "$code" = "200" ] || [ "$code" = "204" ]; then
echo "[teardown] deleted $slug (HTTP $code)"
else
echo "::warning::pv teardown for $slug returned HTTP $code — sweep-stale-e2e-orgs will catch it within MAX_AGE_MINUTES. Body: $(head -c 300 /tmp/pv-cleanup.out 2>/dev/null)"
fi
done
exit 0

View File

@ -44,7 +44,6 @@ env:
GITHUB_SERVER_URL: https://git.moleculesai.app GITHUB_SERVER_URL: https://git.moleculesai.app
jobs: jobs:
# bp-exempt: PR advisory bot; merge blocking is enforced by CI status and branch protection.
gate-check: gate-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
@ -64,7 +63,6 @@ jobs:
if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != '' if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != ''
env: env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }} POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }}
run: | run: |
@ -79,45 +77,28 @@ jobs:
if: github.event_name == 'schedule' if: github.event_name == 'schedule'
env: env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
run: | run: |
set -euo pipefail set -euo pipefail
# Fetch all open PRs and run gate-check on each. This scheduled # Fetch all open PRs and run gate-check on each
# refresher is advisory; a transient Gitea list timeout must not turn # socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
# main red. PR-specific gate-check runs still use normal failure # gate_check.py uses timeout=15 on every urlopen call; this catches the
# semantics. # inline Python polling loop too (issue #603).
pr_numbers=$(python3 <<'PY' pr_numbers=$(python3 <<'PY'
import json import json
import os import os
import socket import socket
import sys
import time
import urllib.error
import urllib.request import urllib.request
socket.setdefaulttimeout(30) socket.setdefaulttimeout(15)
token = os.environ["GITEA_TOKEN"] token = os.environ["GITEA_TOKEN"]
repo = os.environ["REPO"] repo = os.environ["REPO"]
url = f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100" req = urllib.request.Request(
last_error = None f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100",
for attempt in range(1, 4): headers={"Authorization": f"token {token}", "Accept": "application/json"},
req = urllib.request.Request( )
url, with urllib.request.urlopen(req) as r:
headers={"Authorization": f"token {token}", "Accept": "application/json"}, prs = json.loads(r.read())
)
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)
for pr in prs: for pr in prs:
print(pr["number"]) print(pr["number"])
PY PY

View File

@ -48,9 +48,4 @@ jobs:
REQUIRED_CONTEXTS: >- REQUIRED_CONTEXTS: >-
CI / all-required (pull_request), CI / all-required (pull_request),
sop-checklist / all-items-acked (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 run: python3 .gitea/scripts/gitea-merge-queue.py

View File

@ -86,33 +86,22 @@ jobs:
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
# A full-history checkout can exceed the runner's quiet/startup fetch-depth: 0
# 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
- id: filter - id: filter
# Inline replacement for dorny/paths-filter — see e2e-api.yml. # Inline replacement for dorny/paths-filter — see e2e-api.yml.
run: | run: |
# Gitea Actions evaluates github.event.before to empty string in shell BASE="${GITHUB_BASE_REF:-${{ github.event.before }}}"
# scripts. Use GITHUB_EVENT_BEFORE shell env var instead (Gitea
# correctly populates it for push events). PR case uses template var.
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}" BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then
BASE="$GITHUB_EVENT_BEFORE"
fi fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
echo "handlers=true" >> "$GITHUB_OUTPUT" echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi
# timeout 30 guards against the case where BASE points to a ref that if ! git cat-file -e "$BASE" 2>/dev/null; then
# git can resolve but cat-file hangs (rare on corrupted objects).
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi 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" echo "handlers=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi

View File

@ -60,7 +60,6 @@ env:
GITHUB_SERVER_URL: https://git.moleculesai.app GITHUB_SERVER_URL: https://git.moleculesai.app
jobs: jobs:
# bp-exempt: change detector only; downstream Harness Replays is the meaningful gate.
detect-changes: detect-changes:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking. # Phase 3 (RFC #219 §1): surface broken workflows without blocking.
@ -133,14 +132,7 @@ jobs:
RESP=$(curl -sS --fail --max-time 30 \ RESP=$(curl -sS --fail --max-time 30 \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Accept: application/json" \ -H "Accept: application/json" \
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD") || { "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD")
# If Gitea's Compare API is slow/unavailable, choose the conservative
# behavior: run the harness instead of failing the detector and polluting
# main with a red non-gate context.
echo "run=true" >> "$GITHUB_OUTPUT"
echo "debug=compare-api-unavailable base=$BASE head=$HEAD" >> "$GITHUB_OUTPUT"
exit 0
}
DIFF_FILES=$(echo "$RESP" | bash .gitea/scripts/compare-api-diff-files.py 2>/dev/null || true) DIFF_FILES=$(echo "$RESP" | bash .gitea/scripts/compare-api-diff-files.py 2>/dev/null || true)
echo "debug=diff-base=$BASE diff-files=$DIFF_FILES" >> "$GITHUB_OUTPUT" echo "debug=diff-base=$BASE diff-files=$DIFF_FILES" >> "$GITHUB_OUTPUT"
@ -158,7 +150,6 @@ jobs:
# matches e2e-api.yml — see that workflow's comment for why a # matches e2e-api.yml — see that workflow's comment for why a
# job-level `if: false` would block branch protection via the # job-level `if: false` would block branch protection via the
# SKIPPED-in-set bug. # SKIPPED-in-set bug.
# bp-exempt: path-filtered replay suite; CI / all-required is the branch-protection aggregate.
harness-replays: harness-replays:
needs: detect-changes needs: detect-changes
name: Harness Replays name: Harness Replays

View File

@ -89,11 +89,10 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# bp-exempt: meta-lint for masked jobs; tracked separately until masks are burned down.
lint: lint:
name: lint-continue-on-error-tracking name: lint-continue-on-error-tracking
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 10
# Phase 3 (RFC #219 §1): surface masked defects without blocking # Phase 3 (RFC #219 §1): surface masked defects without blocking
# PRs. Pre-existing continue-on-error: true directives on main # PRs. Pre-existing continue-on-error: true directives on main
# all violate this lint at first — intentional. Flip to false # all violate this lint at first — intentional. Flip to false

View File

@ -84,7 +84,6 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# bp-exempt: meta-lint advisory during mask burn-down; CI / all-required gates merges.
scan: scan:
name: lint-mask-pr-atomicity name: lint-mask-pr-atomicity
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -69,7 +69,6 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# bp-exempt: meta-lint advisory; CI / all-required is the required aggregate.
lint: lint:
name: lint-required-no-paths name: lint-required-no-paths
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -46,7 +46,6 @@ env:
GITHUB_SERVER_URL: https://git.moleculesai.app GITHUB_SERVER_URL: https://git.moleculesai.app
jobs: jobs:
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
build-and-push: build-and-push:
name: Build & push canvas image name: Build & push canvas image
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored. # REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.

View File

@ -53,7 +53,6 @@ jobs:
# Operational failures (PyPI unreachable, missing DISPATCH_TOKEN) are # Operational failures (PyPI unreachable, missing DISPATCH_TOKEN) are
# surfaced via continue-on-error: true rather than blocking the merge. # surfaced via continue-on-error: true rather than blocking the merge.
# The actual bump work happens on the main/staging push after merge. # The actual bump work happens on the main/staging push after merge.
# bp-exempt: advisory validation for runtime publication; not a branch-protection gate.
pr-validate: pr-validate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
@ -80,7 +79,6 @@ jobs:
# Actual bump-and-tag: runs on main/staging pushes, posts real success/failure. # Actual bump-and-tag: runs on main/staging pushes, posts real success/failure.
# No continue-on-error — operational failures here trip the main-red # No continue-on-error — operational failures here trip the main-red
# watchdog, which is the desired signal for infrastructure degradation. # watchdog, which is the desired signal for infrastructure degradation.
# bp-exempt: post-merge tag publication side effect; CI / all-required gates source changes.
bump-and-tag: bump-and-tag:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Only fire on push events (main/staging after PR merge). Pull_request # Only fire on push events (main/staging after PR merge). Pull_request

View File

@ -18,13 +18,6 @@ name: publish-workspace-server-image
# :staging-<sha> — per-commit digest, stable for canary verify # :staging-<sha> — per-commit digest, stable for canary verify
# :staging-latest — tracks most recent build on this branch # :staging-latest — tracks most recent build on this branch
# #
# Production auto-deploy:
# After both platform and tenant images are pushed, deploy-production waits
# for strict required push contexts on the same SHA to go green, then
# calls the production CP redeploy-fleet endpoint with target_tag=
# staging-<sha>. Set repo variable or secret PROD_AUTO_DEPLOY_DISABLED=true
# to stop production rollout while keeping image publishing enabled.
#
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/* # ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN # Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
# #
@ -37,12 +30,23 @@ name: publish-workspace-server-image
on: on:
push: push:
branches: [main] branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
- 'manifest.json'
- 'scripts/**'
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch: workflow_dispatch:
# No `concurrency:` block here. Gitea 1.22.6 can cancel queued runs despite # Serialize per-branch so two rapid main pushes don't race the same
# `cancel-in-progress: false`; that is not acceptable for a workflow with a # :staging-latest tag retag. Allow parallel runs as they produce
# production deploy job. Per-SHA image tags are immutable, and staging-latest is # different :staging-<sha> tags and last-write-wins on :staging-latest.
# best-effort last-writer-wins metadata. #
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
concurrency:
group: publish-workspace-server-image-${{ github.ref }}
cancel-in-progress: false
permissions: permissions:
contents: read contents: read
@ -59,24 +63,20 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Health check: verify Docker daemon is accessible before attempting any - name: Diagnose Docker daemon access
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing where `docker build`
# fails deep in the process with a cryptic ECR auth error.
- name: Verify Docker daemon access
run: | run: |
set -euo pipefail set -euo pipefail
echo "::group::Docker daemon health check" echo "::group::Docker daemon diagnosis"
echo "Runner: ${HOSTNAME:-unknown}" echo "Runner: ${HOSTNAME:-unknown}"
docker_info="$(docker info 2>&1)" || { echo "--- Socket info ---"
echo "::error::Docker daemon is not accessible at /var/run/docker.sock" ls -la /var/run/docker.sock 2>/dev/null || echo "/var/run/docker.sock: not found"
echo "::error::Runner: ${HOSTNAME:-unknown}" stat /var/run/docker.sock 2>/dev/null || true
printf '%s\n' "${docker_info}" echo "--- User info ---"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+" id
exit 1 echo "--- docker version ---"
} docker version 2>&1 || true
printf '%s\n' "${docker_info}" | sed -n '1,5p' echo "--- docker info (full) ---"
echo "Docker daemon OK" docker info 2>&1 || echo "docker info failed: exit $?"
echo "::endgroup::" echo "::endgroup::"
# Pre-clone manifest deps before docker build. # Pre-clone manifest deps before docker build.
@ -175,173 +175,3 @@ jobs:
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \ --tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \ --tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
--push . --push .
# bp-exempt: production deploy side-effect; merge is gated by CI / all-required and this job waits for push CI before acting.
deploy-production:
name: Production auto-deploy
needs: build-and-push
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
runs-on: ubuntu-latest
timeout-minutes: 75
env:
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
GITEA_HOST: git.moleculesai.app
GITEA_TOKEN: ${{ secrets.PROD_AUTO_DEPLOY_CONTROL_TOKEN || secrets.AUTO_SYNC_TOKEN }}
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
PROD_AUTO_DEPLOY_CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
PROD_AUTO_DEPLOY_SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
PROD_AUTO_DEPLOY_BATCH_SIZE: ${{ vars.PROD_AUTO_DEPLOY_BATCH_SIZE || '3' }}
PROD_AUTO_DEPLOY_DRY_RUN: ${{ vars.PROD_AUTO_DEPLOY_DRY_RUN || '' }}
PROD_ALLOW_NON_PROD_CP_URL: ${{ vars.PROD_ALLOW_NON_PROD_CP_URL || '' }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Build deploy plan
id: plan
run: |
set -euo pipefail
python3 .gitea/scripts/prod-auto-deploy.py plan > "$RUNNER_TEMP/prod-auto-deploy-plan.json"
jq . "$RUNNER_TEMP/prod-auto-deploy-plan.json"
enabled="$(jq -r '.enabled' "$RUNNER_TEMP/prod-auto-deploy-plan.json")"
echo "enabled=$enabled" >> "$GITHUB_OUTPUT"
if [ "$enabled" != "true" ]; then
reason="$(jq -r '.disabled_reason' "$RUNNER_TEMP/prod-auto-deploy-plan.json")"
echo "::notice::Production auto-deploy disabled: $reason"
{
echo "## Production auto-deploy skipped"
echo ""
echo "Reason: \`$reason\`"
} >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
echo "::error::CP_ADMIN_API_TOKEN secret is required for production auto-deploy."
exit 1
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::AUTO_SYNC_TOKEN secret is required so production deploy can wait for green CI."
exit 1
fi
- name: Self-test production deploy helper
if: ${{ steps.plan.outputs.enabled == 'true' }}
run: |
set -euo pipefail
python3 -m pip install --quiet 'pytest==9.0.2' 'PyYAML==6.0.2'
python3 -m pytest .gitea/scripts/tests/test_prod_auto_deploy.py -q
python3 .gitea/scripts/lint-workflow-yaml.py --workflow-dir .gitea/workflows
- name: Wait for green main CI on this SHA
if: ${{ steps.plan.outputs.enabled == 'true' }}
run: |
set -euo pipefail
python3 .gitea/scripts/prod-auto-deploy.py wait-ci
- name: Call production CP redeploy-fleet
if: ${{ steps.plan.outputs.enabled == 'true' }}
run: |
set -euo pipefail
python3 .gitea/scripts/prod-auto-deploy.py assert-enabled
PLAN="$RUNNER_TEMP/prod-auto-deploy-plan.json"
TARGET_TAG="$(jq -r '.target_tag' "$PLAN")"
BODY="$(jq -c '.body' "$PLAN")"
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " target_tag: $TARGET_TAG"
echo " body: $BODY"
HTTP_RESPONSE="$RUNNER_TEMP/prod-redeploy-response.json"
HTTP_CODE_FILE="$RUNNER_TEMP/prod-redeploy-http-code.txt"
set +e
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
-m 1200 \
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
-d "$BODY" > "$HTTP_CODE_FILE"
set -e
HTTP_CODE="$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")"
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE"
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
{
echo "## Production auto-deploy"
echo ""
echo "**Commit:** \`${GITHUB_SHA:0:7}\`"
echo "**Target tag:** \`$TARGET_TAG\`"
echo "**HTTP:** $HTTP_CODE"
echo ""
echo "### Per-tenant result"
echo ""
echo "| Slug | Phase | SSM Status | Exit | Healthz | Error present |"
echo "|------|-------|------------|------|---------|---------------|"
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true
} >> "$GITHUB_STEP_SUMMARY"
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
exit 1
fi
OK="$(jq -r '.ok' "$HTTP_RESPONSE")"
if [ "$OK" != "true" ]; then
echo "::error::redeploy-fleet reported ok=false; production rollout halted."
exit 1
fi
- name: Verify reachable tenants report this SHA
if: ${{ steps.plan.outputs.enabled == 'true' }}
env:
TENANT_DOMAIN: moleculesai.app
run: |
set -euo pipefail
RESP="$RUNNER_TEMP/prod-redeploy-response.json"
mapfile -t SLUGS < <(jq -r '.results[]? | .slug' "$RESP")
if [ ${#SLUGS[@]} -eq 0 ]; then
echo "::error::No tenants returned from redeploy-fleet; refusing to mark production deploy verified."
exit 1
fi
STALE_COUNT=0
UNREACHABLE_COUNT=0
UNHEALTHY_COUNT=0
for slug in "${SLUGS[@]}"; do
healthz_ok="$(jq -r --arg slug "$slug" '.results[]? | select(.slug == $slug) | .healthz_ok' "$RESP" | tail -1)"
if [ "$healthz_ok" != "true" ]; then
echo "::error::$slug did not report healthz_ok=true in redeploy-fleet response."
UNHEALTHY_COUNT=$((UNHEALTHY_COUNT + 1))
continue
fi
url="https://${slug}.${TENANT_DOMAIN}/buildinfo"
body="$(curl -sS --max-time 30 --retry 3 --retry-delay 5 --retry-connrefused "$url" || true)"
actual="$(echo "$body" | jq -r '.git_sha // ""' 2>/dev/null || echo "")"
if [ -z "$actual" ]; then
echo "::error::$slug did not return /buildinfo after deploy."
UNREACHABLE_COUNT=$((UNREACHABLE_COUNT + 1))
continue
fi
if [ "$actual" != "$GITHUB_SHA" ]; then
echo "::error::$slug is stale: actual=${actual:0:7}, expected=${GITHUB_SHA:0:7}"
STALE_COUNT=$((STALE_COUNT + 1))
else
echo "$slug: ${actual:0:7}"
fi
done
{
echo ""
echo "### Buildinfo verification"
echo ""
echo "Expected SHA: \`${GITHUB_SHA:0:7}\`"
echo "Verified tenants: ${#SLUGS[@]}"
echo "Stale tenants: $STALE_COUNT"
echo "Unhealthy tenants: $UNHEALTHY_COUNT"
echo "Unreachable tenants: $UNREACHABLE_COUNT"
} >> "$GITHUB_STEP_SUMMARY"
if [ "$STALE_COUNT" -gt 0 ] || [ "$UNHEALTHY_COUNT" -gt 0 ] || [ "$UNREACHABLE_COUNT" -gt 0 ]; then
exit 1
fi

View File

@ -9,10 +9,10 @@
# Triggers on: # Triggers on:
# - `pull_request_target`: opened, synchronize, reopened # - `pull_request_target`: opened, synchronize, reopened
# → initial status posts when PR opens / re-pushes # → initial status posts when PR opens / re-pushes
# - comment refires are handled by `review-refire-comments.yml` # - `issue_comment`: /qa-recheck slash-command on the PR
# → a single issue_comment dispatcher prevents every SOP/review # → manual re-fire after a QA reviewer clicks APPROVE
# comment from enqueueing separate qa/security/tier jobs on # (Gitea 1.22.6 doesn't re-fire on pull_request_review, per
# Gitea 1.22.6 before job-level `if:` can skip them. # go-gitea/gitea#33700 + feedback_pull_request_review_no_refire)
# Workflow name = `qa-review` ; job name = `approved`. # Workflow name = `qa-review` ; job name = `approved`.
# The job's own pass/fail conclusion publishes the status context # The job's own pass/fail conclusion publishes the status context
# `qa-review / approved (<event>)` — NO `POST /statuses` call → NO # `qa-review / approved (<event>)` — NO `POST /statuses` call → NO
@ -85,20 +85,27 @@ name: qa-review
on: on:
pull_request_target: pull_request_target:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: read
jobs: jobs:
# bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required.
approved: approved:
# Gate the job: # Gate the job:
# - On pull_request_target events: always run. # - On pull_request_target events: always run.
# Comment-triggered refires live in review-refire-comments.yml. Keeping # - On issue_comment events: only when it's a PR comment and the body
# this workflow PR-only avoids comment-triggered queue storms. # 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: | 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 runs-on: ubuntu-latest
steps: steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate) - name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
@ -112,7 +119,7 @@ jobs:
# no comment.user.login so the step is a no-op skip there. # no comment.user.login so the step is a no-op skip there.
if: github.event_name == 'issue_comment' if: github.event_name == 'issue_comment'
env: env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
login="${{ github.event.comment.user.login }}" login="${{ github.event.comment.user.login }}"
@ -143,14 +150,13 @@ jobs:
- name: Evaluate qa-review - name: Evaluate qa-review
env: 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 GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
# PR number lives in different places per event: # PR number lives in different places per event:
# pull_request_target → github.event.pull_request.number # pull_request_target → github.event.pull_request.number
# issue_comment → github.event.issue.number # issue_comment → github.event.issue.number
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: qa TEAM: qa
TEAM_ID: '20' TEAM_ID: '20'
REVIEW_CHECK_DEBUG: '0' REVIEW_CHECK_DEBUG: '0'

View File

@ -9,17 +9,19 @@ name: redeploy-tenants-on-main
# - Workflow-level env.GITHUB_SERVER_URL pinned per # - Workflow-level env.GITHUB_SERVER_URL pinned per
# feedback_act_runner_github_server_url. # feedback_act_runner_github_server_url.
# - `continue-on-error: true` on each job (RFC §1 contract). # - `continue-on-error: true` on each job (RFC §1 contract).
# - Dropped unsupported `workflow_run` (task #81). # - ~~**Gitea workflow_run trigger limitation**~~ FIXED: replaced with
# - Later changed to manual-only after publish-workspace-server-image.yml # push+paths filter per this PR. Gitea 1.22.6 does not support
# gained an integrated ordered production deploy job. # `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 # Why this workflow exists: publish-workspace-server-image builds and
# the ordered build -> push -> production auto-deploy sequence in one workflow. # pushes a new platform-tenant :<sha> to ECR on every merge to main,
# A separate push-triggered redeploy workflow races before the new ECR image # but running tenants pulled their image once at boot and never re-pull.
# exists and can paint main red with a false deployment failure. # Users see stale code indefinitely.
# #
# This workflow closes the gap by calling the control-plane admin # This workflow closes the gap by calling the control-plane admin
# endpoint that performs a canary-first, batched, health-gated rolling # endpoint that performs a canary-first, batched, health-gated rolling
@ -32,58 +34,62 @@ name: redeploy-tenants-on-main
# Gitea suspension migration. The staging-verify.yml promote step now # Gitea suspension migration. The staging-verify.yml promote step now
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap). # uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
# #
# Runtime ordering for automatic deploys now lives in # Runtime ordering:
# publish-workspace-server-image.yml: # 1. publish-workspace-server-image completes → new :staging-<sha> in ECR.
# 1. build-and-push creates new :staging-<sha> images in ECR. # 2. This workflow fires via workflow_run, calls redeploy-fleet with
# 2. deploy-production waits for required push contexts on that SHA. # target_tag=staging-<sha>. No CDN propagation wait needed —
# 3. deploy-production calls redeploy-fleet canary-first. # 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 # Rollback path: re-run this workflow with a specific SHA pinned via
# variable or secret, run workflow_dispatch, then unset it after the # the workflow_dispatch input. That calls redeploy-fleet with
# rollback. That calls redeploy-fleet with target_tag=<value>, # target_tag=<sha>, re-pulling the older image on every tenant.
# re-pulling the pinned image on every tenant.
on: on:
push:
branches: [main]
paths:
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch: workflow_dispatch:
permissions: permissions:
contents: read contents: read
# No write scopes needed — the workflow hits an external CP endpoint, # No write scopes needed — the workflow hits an external CP endpoint,
# not the GitHub API. # not the GitHub API.
# Serialize manual redeploys so two operator-triggered rollbacks do not # Serialize redeploys so two rapid main pushes' redeploys don't overlap
# overlap and cause confusing per-tenant SSM state. # 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 # cancel-in-progress: false → aborting a half-rolled-out fleet would
# cancels queued runs regardless of this setting, so it provides no # leave tenants stuck on whatever image they happened to be on when
# actual protection. Each redeploy-fleet call is idempotent (canary-first # cancelled. Better to finish the in-flight rollout before starting
# + batched + health-gated) so a cancelled predecessor is recovered # the next one.
# automatically by the next run.
concurrency: concurrency:
group: redeploy-tenants-on-main group: redeploy-tenants-on-main
cancel-in-progress: false
env: env:
GITHUB_SERVER_URL: https://git.moleculesai.app GITHUB_SERVER_URL: https://git.moleculesai.app
jobs: jobs:
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
redeploy: 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 runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking. # 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. # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true continue-on-error: true
timeout-minutes: 25 timeout-minutes: 25
env:
# Rule 9 fix: keep the same operational kill switch surface as the
# integrated auto-deploy workflow.
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
steps: steps:
- name: Kill-switch guard
# Rule 9 fix: exit fast if kill switch is set. No redeploy happens.
if: env.PROD_AUTO_DEPLOY_DISABLED == 'true'
run: |
echo "::notice::Production auto-deploy disabled (PROD_AUTO_DEPLOY_DISABLED=true). Skipping redeploy."
echo "To re-enable: unset the repo variable or set it to false."
- name: Note on ECR propagation - name: Note on ECR propagation
# ECR image manifests are consistent immediately after push — no # ECR image manifests are consistent immediately after push — no
# CDN cache to wait for. The old GHCR-based workflow had a 30s # CDN cache to wait for. The old GHCR-based workflow had a 30s
@ -97,16 +103,21 @@ jobs:
# tag) → used verbatim. Lets ops pin `latest` for emergency # tag) → used verbatim. Lets ops pin `latest` for emergency
# rollback to last canary-verified digest, or pin a specific # rollback to last canary-verified digest, or pin a specific
# `staging-<sha>` to roll back to a known-good build. # `staging-<sha>` to roll back to a known-good build.
# 2. Default → `staging-<short_head_sha>` for manual reruns from # 2. Default → `staging-<short_head_sha>`. The just-published
# the current default-branch SHA. # 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: env:
PROD_MANUAL_REDEPLOY_TARGET_TAG: ${{ vars.PROD_MANUAL_REDEPLOY_TARGET_TAG || secrets.PROD_MANUAL_REDEPLOY_TARGET_TAG || '' }} INPUT_TAG: ${{ inputs.target_tag }}
HEAD_SHA: ${{ github.sha }} HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -n "${PROD_MANUAL_REDEPLOY_TARGET_TAG:-}" ]; then if [ -n "${INPUT_TAG:-}" ]; then
echo "target_tag=$PROD_MANUAL_REDEPLOY_TARGET_TAG" >> "$GITHUB_OUTPUT" echo "target_tag=$INPUT_TAG" >> "$GITHUB_OUTPUT"
echo "Using operator-pinned tag from PROD_MANUAL_REDEPLOY_TARGET_TAG." echo "Using operator-pinned tag: $INPUT_TAG"
else else
SHORT="${HEAD_SHA:0:7}" SHORT="${HEAD_SHA:0:7}"
echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT" echo "target_tag=staging-$SHORT" >> "$GITHUB_OUTPUT"
@ -122,26 +133,13 @@ jobs:
CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }} CP_URL: ${{ vars.CP_URL || 'https://api.moleculesai.app' }}
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }} CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }} TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
CANARY_SLUG: ${{ vars.PROD_REDEPLOY_CANARY_SLUG || secrets.PROD_REDEPLOY_CANARY_SLUG || '' }} CANARY_SLUG: ${{ inputs.canary_slug || 'hongming' }}
SOAK_SECONDS: ${{ vars.PROD_REDEPLOY_SOAK_SECONDS || secrets.PROD_REDEPLOY_SOAK_SECONDS || '' }} SOAK_SECONDS: ${{ inputs.soak_seconds || '60' }}
BATCH_SIZE: ${{ vars.PROD_REDEPLOY_BATCH_SIZE || secrets.PROD_REDEPLOY_BATCH_SIZE || '' }} BATCH_SIZE: ${{ inputs.batch_size || '3' }}
DRY_RUN: ${{ vars.PROD_REDEPLOY_DRY_RUN || secrets.PROD_REDEPLOY_DRY_RUN || '' }} DRY_RUN: ${{ inputs.dry_run || false }}
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
run: | run: |
set -euo pipefail 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 if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then
echo "::error::CP_ADMIN_API_TOKEN secret not set — skipping redeploy" 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." echo "::notice::Set CP_ADMIN_API_TOKEN in repo secrets to enable auto-redeploy."
@ -163,7 +161,7 @@ jobs:
}') }')
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet" 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_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp) HTTP_CODE_FILE=$(mktemp)
@ -191,9 +189,7 @@ jobs:
[ -z "$HTTP_CODE" ] && HTTP_CODE="000" [ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE" echo "HTTP $HTTP_CODE"
# Rule 8 fix: redact raw CP response from CI logs. Print only cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
# safe fields: ok boolean, result count, error presence (no content).
jq '{ok, result_count: (.results | length), has_errors: (.results | any(.error != null))}' "$HTTP_RESPONSE" || echo "(jq parse failed)"
# Pretty-print per-tenant results in the job summary so # Pretty-print per-tenant results in the job summary so
# ops can see which tenants were redeployed without drilling # ops can see which tenants were redeployed without drilling
@ -209,11 +205,9 @@ jobs:
echo "" echo ""
echo "### Per-tenant result" echo "### Per-tenant result"
echo "" echo ""
echo '| Slug | Phase | SSM Status | Exit | Healthz | Errors |' echo '| Slug | Phase | SSM Status | Exit | Healthz | Error |'
echo '|------|-------|------------|------|---------|-------|' echo '|------|-------|------------|------|---------|-------|'
# Rule 8 fix: .error field redacted from CI logs/summary. Print only jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error // "-") |"' "$HTTP_RESPONSE" || true
# presence boolean so ops know whether to look deeper.
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \(.error != null) |"' "$HTTP_RESPONSE" || true
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
if [ "$HTTP_CODE" != "200" ]; then if [ "$HTTP_CODE" != "200" ]; then
@ -252,11 +246,13 @@ jobs:
# fail the workflow, which is what `ok=true` should have # fail the workflow, which is what `ok=true` should have
# guaranteed all along. # guaranteed all along.
# #
# When the redeploy is triggered manually with a specific tag # When the redeploy was triggered by workflow_dispatch with a
# (target_tag != "latest"), the expected SHA may not equal # specific tag (target_tag != "latest"), the expected SHA may
# ${{ github.sha }}. # 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: env:
EXPECTED_SHA: ${{ github.sha }} EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
TARGET_TAG: ${{ steps.tag.outputs.target_tag }} TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
# Tenant subdomain template — slugs from the response are # Tenant subdomain template — slugs from the response are
# appended. Production CP issues `<slug>.moleculesai.app`; # appended. Production CP issues `<slug>.moleculesai.app`;
@ -270,10 +266,10 @@ jobs:
if [ "$TARGET_TAG" != "latest" ] \ if [ "$TARGET_TAG" != "latest" ] \
&& [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \ && [ "$TARGET_TAG" != "$EXPECTED_SHA" ] \
&& [ "$TARGET_TAG" != "staging-$EXPECTED_SHORT" ]; then && [ "$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 # SHA — operator is rolling back / pinning. Skip the
# verification because we don't have the expected SHA in # 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 # manifest, which is a follow-up). Failing-open here is
# safe: the operator chose the tag deliberately. # safe: the operator chose the tag deliberately.
# #

View File

@ -73,7 +73,6 @@ env:
GITHUB_SERVER_URL: https://git.moleculesai.app GITHUB_SERVER_URL: https://git.moleculesai.app
jobs: jobs:
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
redeploy: redeploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking. # Phase 3 (RFC #219 §1): surface broken workflows without blocking.

View File

@ -41,7 +41,6 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
jobs: jobs:
# bp-exempt: review tooling regression suite; CI / all-required is the required aggregate.
test: test:
name: review-check.sh regression tests name: review-check.sh regression tests
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@ -1,113 +0,0 @@
# Consolidated comment dispatcher for manual review/tier refires.
#
# Gitea 1.22 queues one run per workflow subscribed to `issue_comment` before
# evaluating job-level `if:`. SOP-heavy PRs therefore created queue storms when
# qa-review, security-review, sop-checklist, and sop-tier-refire all
# listened to comments. This workflow is the single non-SOP comment subscriber:
# ordinary comments no-op quickly; slash commands post the required status
# contexts to the PR head SHA.
name: review-refire-comments
on:
issue_comment:
types: [created]
permissions:
contents: read
pull-requests: read
statuses: write
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.issue.number || github.ref }}
cancel-in-progress: true
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Classify comment
id: classify
env:
COMMENT_BODY: ${{ github.event.comment.body }}
IS_PR: ${{ github.event.issue.pull_request != null }}
run: |
set -euo pipefail
{
echo "run_qa=false"
echo "run_security=false"
echo "run_tier=false"
} >> "$GITHUB_OUTPUT"
if [ "$IS_PR" != "true" ]; then
echo "::notice::not a PR comment; no-op"
exit 0
fi
first_line=$(printf '%s\n' "$COMMENT_BODY" | sed -n '1p')
case "$first_line" in
/qa-recheck*)
echo "run_qa=true" >> "$GITHUB_OUTPUT"
;;
/security-recheck*)
echo "run_security=true" >> "$GITHUB_OUTPUT"
;;
/refire-tier-check*)
echo "run_tier=true" >> "$GITHUB_OUTPUT"
;;
*)
echo "::notice::no supported review refire slash command; no-op"
;;
esac
- name: Check out BASE ref for trusted scripts
if: |
steps.classify.outputs.run_qa == 'true' ||
steps.classify.outputs.run_security == 'true' ||
steps.classify.outputs.run_tier == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
- name: Refire qa-review status
if: steps.classify.outputs.run_qa == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: qa
TEAM_ID: '20'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire security-review status
if: steps.classify.outputs.run_security == 'true'
env:
GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: security
TEAM_ID: '21'
REVIEW_CHECK_DEBUG: '0'
REVIEW_CHECK_STRICT: '0'
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
run: |
set -euo pipefail
.gitea/scripts/review-refire-status.sh
- name: Refire sop-tier-check status
if: steps.classify.outputs.run_tier == 'true'
env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.issue.number }}
COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
SOP_DEBUG: '0'
run: bash .gitea/scripts/sop-tier-refire.sh

View File

@ -67,17 +67,15 @@ jobs:
# previous push SHA, then matches against the wheel-relevant # previous push SHA, then matches against the wheel-relevant
# path set. # path set.
# #
# NOTE: Gitea Actions does not expose github.event.before as a # Root fix (mc#917): Gitea Actions does not expose github.event.before
# shell environment variable. The ${{ github.event.before }} template # as a ${{ }} template-expression that resolves in shell scripts for
# expression works inside YAML run: blocks but is evaluated to an # push events (it becomes empty string). The env var GITHUB_EVENT_BEFORE
# empty string for push events, making the ${VAR:-fallback} always # IS set by the runner for push events. Guard git cat-file with
# use the fallback. Use GITHUB_EVENT_BEFORE instead — it IS set in # `timeout 30` to prevent indefinite hangs on malformed BASE values.
# the runner's shell environment for push events. if [ "${{ github.event_name }}" = "pull_request" ] && [ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}" BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "$GITHUB_EVENT_BEFORE" ]; then else
BASE="$GITHUB_EVENT_BEFORE" BASE="${GITHUB_EVENT_BEFORE:-}"
fi fi
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$'; then
# New branch or no previous SHA: treat as wheel-relevant. # New branch or no previous SHA: treat as wheel-relevant.
@ -88,6 +86,7 @@ jobs:
git fetch --depth=1 origin "$BASE" 2>/dev/null || true git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi fi
if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then if ! timeout 30 git cat-file -e "$BASE" 2>/dev/null; then
echo "::notice::BASE=$BASE not in local clone (shallow fetch or pruned ref)"
echo "wheel=true" >> "$GITHUB_OUTPUT" echo "wheel=true" >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi fi

View File

@ -12,18 +12,22 @@ name: security-review
on: on:
pull_request_target: pull_request_target:
types: [opened, synchronize, reopened] types: [opened, synchronize, reopened]
issue_comment:
types: [created]
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: read
jobs: jobs:
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
approved: approved:
# Comment-triggered refires live in review-refire-comments.yml. Keeping # See qa-review.yml header for full A1-α / A1.1 (v1.3 — informational
# this workflow PR-only avoids comment-triggered queue storms. # log only, NOT a gate) / A4 / A5 design rationale.
if: | 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 runs-on: ubuntu-latest
steps: steps:
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate) - name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
@ -32,7 +36,7 @@ jobs:
# so re-running on a non-collaborator comment is harmless. # so re-running on a non-collaborator comment is harmless.
if: github.event_name == 'issue_comment' if: github.event_name == 'issue_comment'
env: env:
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
run: | run: |
set -euo pipefail set -euo pipefail
login="${{ github.event.comment.user.login }}" login="${{ github.event.comment.user.login }}"
@ -57,11 +61,10 @@ jobs:
- name: Evaluate security-review - name: Evaluate security-review
env: 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 GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
TEAM: security TEAM: security
TEAM_ID: '21' TEAM_ID: '21'
REVIEW_CHECK_DEBUG: '0' REVIEW_CHECK_DEBUG: '0'

View File

@ -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). # RFC#351 Step 2 of 6 (implementation MVP).
# #
@ -65,15 +65,7 @@
# membership, compute, post status). Re-running on any event is safe — # membership, compute, post status). Re-running on any event is safe —
# the new status overwrites the previous one for the same context. # the new status overwrites the previous one for the same context.
name: sop-checklist name: sop-checklist-gate
# 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)
on: on:
pull_request_target: pull_request_target:
@ -91,7 +83,7 @@ permissions:
statuses: write statuses: write
jobs: jobs:
all-items-acked: gate:
# Run on pull_request_target events always. On issue_comment events, # Run on pull_request_target events always. On issue_comment events,
# only when the comment is on a PR (issue_comment fires for issues # only when the comment is on a PR (issue_comment fires for issues
# too) and the body contains one of the slash-commands. # too) and the body contains one of the slash-commands.
@ -100,8 +92,7 @@ jobs:
(github.event_name == 'issue_comment' && (github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null && github.event.issue.pull_request != null &&
(contains(github.event.comment.body, '/sop-ack') || (contains(github.event.comment.body, '/sop-ack') ||
contains(github.event.comment.body, '/sop-revoke') || contains(github.event.comment.body, '/sop-revoke')))
contains(github.event.comment.body, '/sop-n/a')))
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out BASE ref (trust boundary — never PR-head) - 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. # qa-review.yml so the script source is always trusted.
ref: ${{ github.event.repository.default_branch }} ref: ${{ github.event.repository.default_branch }}
- name: Run sop-checklist - name: Run sop-checklist-gate
env: env:
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }} GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
@ -122,7 +113,7 @@ jobs:
REPO_NAME: ${{ github.event.repository.name }} REPO_NAME: ${{ github.event.repository.name }}
run: | run: |
set -euo pipefail set -euo pipefail
python3 .gitea/scripts/sop-checklist.py \ python3 .gitea/scripts/sop-checklist-gate.py \
--owner "$OWNER" \ --owner "$OWNER" \
--repo "$REPO_NAME" \ --repo "$REPO_NAME" \
--pr "$PR_NUMBER" \ --pr "$PR_NUMBER" \

View File

@ -28,16 +28,15 @@
# #
# Environment variables: # Environment variables:
# SOP_DEBUG=1 — per-API-call diagnostic lines. Default: off. # SOP_DEBUG=1 — per-API-call diagnostic lines. Default: off.
# SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Intended for # SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Grace window
# emergency use only; burn-in window closed # for PRs in-flight when AND-composition deployed.
# 2026-05-17 (internal#189 Phase 1). # Burn-in: remove after 2026-05-17 (7-day window).
# #
# BURN-IN CLOSED 2026-05-17 (internal#189 Phase 1): The 7-day burn-in # BURN-IN NOTE (internal#189 Phase 1): continue-on-error: true is set on
# window closed. continue-on-error: true has been removed from the # the tier-check job below. This prevents AND-composition from blocking
# tier-check job; AND-composition is now fully enforced. If you need # PRs during the 7-day burn-in. After 2026-05-17:
# to temporarily re-introduce a mask, file a tracker and follow the # 1. Remove `continue-on-error: true` from this job block.
# mc#774 protocol (Tier 2e lint requires a current tracker within # 2. Update this BURN-IN NOTE comment to mark the window closed.
# 2 lines of any continue-on-error: true).
name: sop-tier-check name: sop-tier-check
@ -61,13 +60,13 @@ on:
pull_request_review: pull_request_review:
types: [submitted, dismissed, edited] types: [submitted, dismissed, edited]
concurrency:
group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs: jobs:
tier-check: tier-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# BURN-IN: continue-on-error prevents AND-composition from blocking
# PRs during the 7-day window. Remove after 2026-05-17 (mc#774).
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
continue-on-error: true
permissions: permissions:
contents: read contents: read
pull-requests: read pull-requests: read

View File

@ -1,4 +1,4 @@
# sop-tier-refire — manual fallback for sop-tier-check refire. # sop-tier-refire — issue_comment-triggered refire of sop-tier-check.
# #
# Closes internal#292. Gitea 1.22.6 doesn't refire workflows on the # 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` # `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` # 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`). # but the audit trail keeps growing; see `feedback_never_admin_merge_bypass`).
# #
# Comment-triggered refires now live in `review-refire-comments.yml`. Gitea # Workaround pattern from `feedback_pull_request_review_no_refire`:
# queues issue_comment workflows before evaluating job-level `if:`, so having # `issue_comment` events DO fire reliably on 1.22.6. When a repo
# qa-review, security-review, sop-checklist, and sop-tier-refire all subscribe # MEMBER/OWNER/COLLABORATOR comments `/refire-tier-check` on a PR, this
# to every comment caused queue storms on SOP-heavy PRs. This workflow is a # workflow re-runs the sop-tier-check logic and POSTs the resulting
# non-automatic breadcrumb only; Gitea 1.22.6 does not support # status to the PR head SHA directly. No empty commit, no git history
# workflow_dispatch inputs, so real refires must use `/refire-tier-check`. # bloat, no cascade re-fire of every other workflow on the PR.
# #
# SECURITY MODEL: # SECURITY MODEL:
# #
@ -37,16 +37,43 @@
# Rate-limit: a 1s pre-sleep + a "skip if status posted in last 30s" # 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. # 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: on:
workflow_dispatch: issue_comment:
types: [created]
jobs: jobs:
refire: 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 runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
statuses: write
steps: steps:
- name: Explain supported refire path - name: Check out base branch (for the script)
run: | uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
echo "::error::Gitea 1.22.6 does not support workflow_dispatch inputs here; comment /refire-tier-check on the PR instead." with:
exit 1 # 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

View File

@ -82,7 +82,6 @@ env:
GITHUB_SERVER_URL: https://git.moleculesai.app GITHUB_SERVER_URL: https://git.moleculesai.app
jobs: jobs:
# bp-exempt: post-merge staging verification side effect; CI / all-required gates merges.
staging-smoke: staging-smoke:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Phase 3 (RFC #219 §1): surface broken workflows without blocking. # Phase 3 (RFC #219 §1): surface broken workflows without blocking.
@ -191,7 +190,6 @@ jobs:
echo "assertions in the staging-smoke step log above." echo "assertions in the staging-smoke step log above."
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
# bp-exempt: post-merge image promotion side effect; staging-smoke controls promotion.
promote-to-latest: promote-to-latest:
# On green, calls the CP redeploy-fleet endpoint with target_tag= # On green, calls the CP redeploy-fleet endpoint with target_tag=
# staging-<sha> to promote the verified ECR image. This is the same # staging-<sha> to promote the verified ECR image. This is the same

View File

@ -84,7 +84,7 @@ permissions:
jobs: jobs:
reap: reap:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 8 timeout-minutes: 3
steps: steps:
- name: Check out repo at default-branch HEAD - name: Check out repo at default-branch HEAD
# BASE checkout per `feedback_pull_request_target_workflow_from_base`. # BASE checkout per `feedback_pull_request_target_workflow_from_base`.
@ -118,7 +118,4 @@ jobs:
REPO: ${{ github.repository }} REPO: ${{ github.repository }}
WATCH_BRANCH: ${{ github.event.repository.default_branch }} WATCH_BRANCH: ${{ github.event.repository.default_branch }}
WORKFLOWS_DIR: .gitea/workflows WORKFLOWS_DIR: .gitea/workflows
STATUS_REAPER_API_RETRIES: "4"
STATUS_REAPER_API_TIMEOUT_SEC: "20"
STATUS_REAPER_API_RETRY_SLEEP_SEC: "2"
run: python3 .gitea/scripts/status-reaper.py run: python3 .gitea/scripts/status-reaper.py

View File

@ -1 +1 @@
staging trigger 2026-05-14T17:35:02Z staging trigger

View File

@ -1 +0,0 @@
trigger

View File

@ -327,7 +327,7 @@ function OrgCTA({ org }: { org: Org }) {
return ( return (
<a <a
href={href} 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 Open
</a> </a>
@ -337,7 +337,7 @@ function OrgCTA({ org }: { org: Org }) {
return ( return (
<a <a
href={`/pricing?org=${encodeURIComponent(org.slug)}`} 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 Complete payment
</a> </a>

View File

@ -16,8 +16,6 @@ interface PendingApproval {
export function ApprovalBanner() { export function ApprovalBanner() {
const [approvals, setApprovals] = useState<PendingApproval[]>([]); const [approvals, setApprovals] = useState<PendingApproval[]>([]);
// Guards double-click / double-keypress during in-flight POST.
const [pendingApprovalId, setPendingApprovalId] = useState<string | null>(null);
// Single endpoint — no N+1 per-workspace polling // Single endpoint — no N+1 per-workspace polling
const pollApprovals = useCallback(async () => { const pollApprovals = useCallback(async () => {
@ -37,8 +35,6 @@ export function ApprovalBanner() {
}, [pollApprovals]); }, [pollApprovals]);
const handleDecide = async (approval: PendingApproval, decision: "approved" | "denied") => { const handleDecide = async (approval: PendingApproval, decision: "approved" | "denied") => {
if (pendingApprovalId !== null) return; // guard double-submit
setPendingApprovalId(approval.id);
try { try {
await api.post(`/workspaces/${approval.workspace_id}/approvals/${approval.id}/decide`, { await api.post(`/workspaces/${approval.workspace_id}/approvals/${approval.id}/decide`, {
decision, decision,
@ -48,8 +44,6 @@ export function ApprovalBanner() {
setApprovals((prev) => prev.filter((a) => a.id !== approval.id)); setApprovals((prev) => prev.filter((a) => a.id !== approval.id));
} catch { } catch {
showToast("Failed to submit decision", "error"); showToast("Failed to submit decision", "error");
} finally {
setPendingApprovalId(null);
} }
}; };
@ -78,25 +72,22 @@ export function ApprovalBanner() {
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<button <button
type="button" type="button"
disabled={pendingApprovalId !== null}
onClick={() => handleDecide(approval, "approved")} onClick={() => handleDecide(approval, "approved")}
aria-disabled={pendingApprovalId !== null} // Hover DARKER not lighter — emerald-500 on white text
// Hover goes DARKER — emerald-600 on white text is 3.3:1 (WCAG AA FAIL). // drops contrast vs emerald-700.
// emerald-700 is 4.6:1 (WCAG AA PASS). Hover darkens to emerald-600. className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
className="px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 disabled:cursor-not-allowed text-xs rounded-lg text-white font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-emerald-400/70"
> >
{pendingApprovalId === approval.id ? "…" : "Approve"} Approve
</button> </button>
<button <button
type="button" type="button"
disabled={pendingApprovalId !== null}
onClick={() => handleDecide(approval, "denied")} onClick={() => handleDecide(approval, "denied")}
aria-disabled={pendingApprovalId !== null} // Was a no-op hover (`bg-surface-card hover:bg-surface-card`).
// `text-ink` (not text-ink-mid) for WCAG AA contrast on bg-surface-card. // Lift to surface-elevated on hover so the button visibly
// text-ink-mid on zinc-800 fails AA at ~3:1; text-ink passes at ~7:1. // responds before a destructive deny.
className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-ink disabled:opacity-40 disabled:cursor-not-allowed text-xs rounded-lg font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70" className="px-3 py-1.5 bg-surface-card hover:bg-surface-elevated hover:text-ink text-xs rounded-lg text-ink-mid transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-amber-950 focus-visible:ring-amber-400/70"
> >
{pendingApprovalId === approval.id ? "…" : "Deny"} Deny
</button> </button>
</div> </div>
</div> </div>

View File

@ -8,17 +8,11 @@ import type { AuditEntry, AuditResponse } from "@/types/audit";
type EventFilter = "all" | AuditEntry["event_type"]; 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 }> = { 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" }, delegation: { text: "text-accent", 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" }, decision: { text: "text-violet-400", 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" }, gate: { text: "text-yellow-400", 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" }, hitl: { text: "text-orange-400", bg: "bg-orange-950/40", border: "border-orange-800/40" },
}; };
const FILTERS: { id: EventFilter; label: string }[] = [ const FILTERS: { id: EventFilter; label: string }[] = [
@ -170,10 +164,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{/* Error banner */} {/* Error banner */}
{error && ( {error && (
<div <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">
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"
>
{error} {error}
</div> </div>
)} )}
@ -251,6 +242,7 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
{/* Event-type badge */} {/* Event-type badge */}
<span <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}`} 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} {entry.event_type}
</span> </span>

View File

@ -100,8 +100,8 @@ export function BatchActionBar() {
aria-label="Batch workspace actions" 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" 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 */} {/* Selection count badge */}
<span className="text-[12px] font-semibold text-white bg-zinc-700 px-2.5 py-0.5 rounded-full tabular-nums"> <span className="text-[12px] font-semibold text-white bg-accent-strong/80 px-2.5 py-0.5 rounded-full tabular-nums">
{count} selected {count} selected
</span> </span>
@ -112,7 +112,7 @@ export function BatchActionBar() {
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => setPending("restart")} 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> <span aria-hidden="true"></span>
Restart All Restart All
@ -122,7 +122,7 @@ export function BatchActionBar() {
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => setPending("pause")} 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> <span aria-hidden="true"></span>
Pause All Pause All
@ -132,7 +132,7 @@ export function BatchActionBar() {
type="button" type="button"
disabled={busy} disabled={busy}
onClick={() => setPending("delete")} 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> <span aria-hidden="true"></span>
Delete All Delete All

View File

@ -96,9 +96,9 @@ export function ConfirmDialog({
// readable in both light and dark themes. // readable in both light and dark themes.
const confirmColors = const confirmColors =
confirmVariant === "danger" confirmVariant === "danger"
? "bg-red-700 hover:bg-red-600 text-white" ? "bg-red-600 hover:bg-red-700 text-white"
: confirmVariant === "warning" : confirmVariant === "warning"
? "bg-amber-800 hover:bg-amber-700 text-white" ? "bg-amber-600 hover:bg-amber-700 text-white"
: "bg-accent hover:bg-accent-strong text-white"; : "bg-accent hover:bg-accent-strong text-white";
// Render via Portal so the fixed-position dialog escapes any containing block // Render via Portal so the fixed-position dialog escapes any containing block

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { showToast } from "./Toaster"; import { showToast } from "./Toaster";
@ -23,17 +23,9 @@ export function ContextMenu() {
const setPanelTab = useCanvasStore((s) => s.setPanelTab); const setPanelTab = useCanvasStore((s) => s.setPanelTab);
const nestNode = useCanvasStore((s) => s.nestNode); const nestNode = useCanvasStore((s) => s.nestNode);
const contextNodeId = contextMenu?.nodeId ?? null; const contextNodeId = contextMenu?.nodeId ?? null;
// Select the full nodes array (stable reference across unrelated store const hasChildren = useCanvasStore((s) =>
// updates) and derive children via useMemo. Filtering inside the contextNodeId ? s.nodes.some((n) => n.data.parentId === contextNodeId) : false
// selector returned a new array every call, which Zustand's
// useSyncExternalStore saw as "snapshot changed" → schedule
// re-render → loop → React error #185. See canvas-store-snapshots.
const nodes = useCanvasStore((s) => s.nodes);
const children = useMemo(
() => (contextNodeId ? nodes.filter((n) => n.data.parentId === contextNodeId) : []),
[nodes, contextNodeId],
); );
const hasChildren = children.length > 0;
const setPendingDelete = useCanvasStore((s) => s.setPendingDelete); const setPendingDelete = useCanvasStore((s) => s.setPendingDelete);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
@ -197,9 +189,10 @@ export function ContextMenu() {
// it survives ContextMenu unmount. Closing the menu here avoids the // it survives ContextMenu unmount. Closing the menu here avoids the
// prior race where the portal dialog's Confirm click was treated as // prior race where the portal dialog's Confirm click was treated as
// "outside" by the menu's outside-click handler. // "outside" by the menu's outside-click handler.
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: children.map(c => ({ id: c.id, name: c.data.name })) }); const childNodes = useCanvasStore.getState().nodes.filter((n) => n.data.parentId === contextMenu.nodeId);
setPendingDelete({ id: contextMenu.nodeId, name: contextMenu.nodeData.name, hasChildren, children: childNodes.map(c => ({ id: c.id, name: c.data.name })) });
closeContextMenu(); closeContextMenu();
}, [contextMenu, setPendingDelete, closeContextMenu, children, hasChildren]); }, [contextMenu, setPendingDelete, closeContextMenu]);
const handleViewDetails = useCallback(() => { const handleViewDetails = useCallback(() => {
if (!contextMenu) return; if (!contextMenu) return;
@ -318,7 +311,7 @@ export function ContextMenu() {
aria-hidden="true" aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`} 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>
</div> </div>

View File

@ -31,25 +31,17 @@ export function extractMessageText(body: Record<string, unknown> | null): string
if (text) return text; if (text) return text;
// Response: result.parts[].text or result.parts[].root.text // Response: result.parts[].text or result.parts[].root.text
// Use the first part that has a direct text field; within that part,
// prefer direct text over root.text. Subsequent parts' root.text fields
// are ignored when a direct text exists in an earlier part.
const result = body.result as Record<string, unknown> | undefined; const result = body.result as Record<string, unknown> | undefined;
const rParts = (result?.parts || []) as Array<Record<string, unknown>>; const rParts = (result?.parts || []) as Array<Record<string, unknown>>;
const firstPartWithText = rParts.find( const rText = rParts
(p) => typeof p.text === "string" && (p.text as string) !== "" .map((p) => {
); if (p.text) return p.text as string;
if (firstPartWithText) { const root = p.root as Record<string, unknown> | undefined;
return firstPartWithText.text as string; return (root?.text as string) || "";
} })
// No direct text found; use root.text from the first part (if present). .filter(Boolean)
const firstPart = rParts[0]; .join("\n");
if (firstPart) { if (rText) return rText;
const root = firstPart.root as Record<string, unknown> | undefined;
if (typeof root?.text === "string" && root.text !== "") {
return root.text as string;
}
}
if (typeof body.result === "string") return body.result; if (typeof body.result === "string") return body.result;
} catch { /* ignore */ } } catch { /* ignore */ }
@ -187,7 +179,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
isError isError
? "bg-red-950/50 text-bad" ? "bg-red-950/50 text-bad"
: isSend : isSend
? "bg-cyan-950 text-cyan-300" ? "bg-cyan-950/50 text-cyan-400"
: isReceive : isReceive
? "bg-blue-950/50 text-accent" ? "bg-blue-950/50 text-accent"
: "bg-surface-card text-ink-mid" : "bg-surface-card text-ink-mid"
@ -251,7 +243,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Error */} {/* Error */}
{isError && entry.error_detail && ( {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)} {entry.error_detail.slice(0, 200)}
</div> </div>
)} )}
@ -272,7 +264,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
)} )}
{responseText && ( {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="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"> <div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">
{responseText.slice(0, 2000)} {responseText.slice(0, 2000)}
{responseText.length > 2000 && ( {responseText.length > 2000 && (

View File

@ -80,7 +80,6 @@ export function CreateWorkspaceButton() {
// isExternal is true the template / model / hermes-provider fields are // isExternal is true the template / model / hermes-provider fields are
// hidden (they're meaningless for BYO-compute agents). // hidden (they're meaningless for BYO-compute agents).
const [isExternal, setIsExternal] = useState(false); const [isExternal, setIsExternal] = useState(false);
const [externalRuntime, setExternalRuntime] = useState("external");
const [externalConnection, setExternalConnection] = const [externalConnection, setExternalConnection] =
useState<ExternalConnectionInfo | null>(null); useState<ExternalConnectionInfo | null>(null);
@ -224,7 +223,6 @@ export function CreateWorkspaceButton() {
setBudgetLimit(""); setBudgetLimit("");
setError(null); setError(null);
setHermesProvider("anthropic"); setHermesProvider("anthropic");
setExternalRuntime("external");
setHermesApiKey(""); setHermesApiKey("");
setHermesModel(""); setHermesModel("");
api api
@ -284,7 +282,7 @@ export function CreateWorkspaceButton() {
// Runtime=external flips the backend into awaiting-agent mode: // Runtime=external flips the backend into awaiting-agent mode:
// no container provisioning, token minted, connection payload // no container provisioning, token minted, connection payload
// returned in the response for the modal below. // returned in the response for the modal below.
...(isExternal ? { runtime: externalRuntime } : {}), ...(isExternal ? { runtime: "external" } : {}),
...(!isExternal && isHermes && provider ...(!isExternal && isHermes && provider
? { ? {
secrets: { [provider.envVar]: hermesApiKey.trim() }, secrets: { [provider.envVar]: hermesApiKey.trim() },
@ -384,23 +382,6 @@ export function CreateWorkspaceButton() {
</div> </div>
</label> </label>
{isExternal && (
<div>
<label className="text-[11px] text-ink-mid block mb-1">
External Runtime
</label>
<select
value={externalRuntime}
onChange={(e) => setExternalRuntime(e.target.value)}
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
>
<option value="external">Generic External</option>
<option value="kimi">Kimi CLI</option>
<option value="kimi-cli">Kimi CLI (alt)</option>
</select>
</div>
)}
{!isExternal && ( {!isExternal && (
<InputField <InputField
label="Template" label="Template"

View File

@ -126,8 +126,8 @@ export function DeleteCascadeConfirmDialog({
{/* Cascade warning */} {/* Cascade warning */}
<div className="rounded border border-red-900/40 bg-red-950/20 px-3 py-2.5 mb-4"> <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"> <p className="text-[12px] text-bad/80 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. Deleting will cascade <strong className="text-red-200">all child workspaces and their data will be permanently removed.</strong> This cannot be undone.
</p> </p>
</div> </div>
@ -164,13 +164,13 @@ export function DeleteCascadeConfirmDialog({
type="button" type="button"
onClick={onConfirm} onClick={onConfirm}
disabled={!checked} disabled={!checked}
// Hover goes DARKER, not lighter — bg-red-600 on white text // Hover goes DARKER, not lighter — bg-red-500 on white text
// drops contrast below AA. Same trap fixed in ConfirmDialog. // drops contrast below AA vs bg-red-700. Same trap fixed in
// focus-visible ring matches the canvas chrome. // 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 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 ${checked
? "bg-red-700 hover:bg-red-600 text-white cursor-pointer" ? "bg-red-600 hover:bg-red-700 text-white cursor-pointer"
: "bg-red-900/30 text-red-400 cursor-not-allowed" : "bg-red-900/30 text-bad/40 cursor-not-allowed"
}`} }`}
> >
Delete All Delete All

View File

@ -51,7 +51,7 @@ export class ErrorBoundary extends React.Component<
render() { render() {
if (this.state.hasError) { if (this.state.hasError) {
return ( 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="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"> <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 <svg
@ -76,7 +76,7 @@ export class ErrorBoundary extends React.Component<
<p className="text-sm text-ink-mid mb-1"> <p className="text-sm text-ink-mid mb-1">
An unexpected error occurred while rendering the application. An unexpected error occurred while rendering the application.
</p> </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"} {this.state.error?.message ?? "Unknown error"}
</p> </p>
<div className="flex items-center justify-center gap-3"> <div className="flex items-center justify-center gap-3">

View File

@ -18,7 +18,7 @@
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import * as Dialog from "@radix-ui/react-dialog"; import * as Dialog from "@radix-ui/react-dialog";
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "kimi" | "fields"; type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "fields";
export interface ExternalConnectionInfo { export interface ExternalConnectionInfo {
workspace_id: string; workspace_id: string;
@ -58,10 +58,6 @@ export interface ExternalConnectionInfo {
// openclaw gateway on loopback. Outbound-tools-only today; push // openclaw gateway on loopback. Outbound-tools-only today; push
// parity on an external openclaw needs a sessions.steer bridge. // parity on an external openclaw needs a sessions.steer bridge.
openclaw_snippet?: string; openclaw_snippet?: string;
// Kimi CLI setup snippet — self-contained Python heartbeat script
// that keeps a Kimi workspace online in poll mode. Optional for
// backward compat with platforms that haven't shipped the Kimi tab.
kimi_snippet?: string;
} }
interface Props { interface Props {
@ -154,11 +150,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
'WORKSPACE_TOKEN="<paste from create response>"', 'WORKSPACE_TOKEN="<paste from create response>"',
`WORKSPACE_TOKEN="${info.auth_token}"`, `WORKSPACE_TOKEN="${info.auth_token}"`,
); );
// Kimi snippet carries the placeholder inside the shell heredoc.
const filledKimi = info.kimi_snippet?.replace(
'MOLECULE_WORKSPACE_TOKEN=<paste from create response>',
`MOLECULE_WORKSPACE_TOKEN=${info.auth_token}`,
);
return ( return (
<Dialog.Root open onOpenChange={(o) => !o && onClose()}> <Dialog.Root open onOpenChange={(o) => !o && onClose()}>
@ -198,7 +189,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
if (filledHermes) tabs.push("hermes"); if (filledHermes) tabs.push("hermes");
if (filledCodex) tabs.push("codex"); if (filledCodex) tabs.push("codex");
if (filledOpenClaw) tabs.push("openclaw"); if (filledOpenClaw) tabs.push("openclaw");
if (filledKimi) tabs.push("kimi");
tabs.push("curl", "fields"); tabs.push("curl", "fields");
return tabs; return tabs;
})().map((t) => ( })().map((t) => (
@ -222,8 +212,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
? "Codex" ? "Codex"
: t === "openclaw" : t === "openclaw"
? "OpenClaw" ? "OpenClaw"
: t === "kimi"
? "Kimi"
: t === "python" : t === "python"
? "Python SDK" ? "Python SDK"
: t === "mcp" : t === "mcp"
@ -300,15 +288,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
onCopy={() => copy(filledOpenClaw, "openclaw")} onCopy={() => copy(filledOpenClaw, "openclaw")}
/> />
)} )}
{tab === "kimi" && filledKimi && (
<SnippetBlock
value={filledKimi}
label="Kimi CLI — self-contained Python bridge. Registers, heartbeats, polls for canvas messages, and echoes replies back. NAT-safe (no public URL). Run in a background terminal or via launchd."
copyKey="kimi"
copied={copiedKey === "kimi"}
onCopy={() => copy(filledKimi, "kimi")}
/>
)}
{tab === "fields" && ( {tab === "fields" && (
<div className="space-y-2"> <div className="space-y-2">
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} /> <Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
@ -360,7 +339,7 @@ function SnippetBlock({
<button <button
type="button" type="button"
onClick={onCopy} 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"} {copied ? "Copied!" : "Copy"}
</button> </button>

View File

@ -451,7 +451,7 @@ function ProviderPickerModal({
<button <button
onClick={() => handleSaveKey(index)} onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving} 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"} {entry.saving ? "..." : "Save"}
</button> </button>
@ -492,7 +492,7 @@ function ProviderPickerModal({
!selectorValue.providerId || !selectorValue.providerId ||
(showModelInput && model.trim() === "") (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"} {allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
</button> </button>

View File

@ -308,7 +308,7 @@ export function OrgImportPreflightModal({
type="button" type="button"
onClick={onProceed} onClick={onProceed}
disabled={!canProceed} disabled={!canProceed}
className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-ink-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" className="px-4 py-1.5 text-[11px] font-semibold rounded bg-accent hover:bg-accent-strong text-white disabled:bg-surface-card disabled:text-white-soft disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
> >
Import Import
</button> </button>

View File

@ -117,7 +117,7 @@ function PlanCard({
<ul className="mt-6 flex-1 space-y-2 text-sm text-ink-mid"> <ul className="mt-6 flex-1 space-y-2 text-sm text-ink-mid">
{plan.features.map((f) => ( {plan.features.map((f) => (
<li key={f} className="flex items-start"> <li key={f} className="flex items-start">
<span className="mr-2 text-accent" aria-hidden="true"> <span className="mr-2 text-accent" aria-hidden>
</span> </span>
{f} {f}

View File

@ -420,7 +420,7 @@ export function ProviderModelSelector({
spellCheck={false} spellCheck={false}
autoComplete="off" autoComplete="off"
data-testid="model-input" 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"> <p className="text-[9px] text-ink-mid mt-1 leading-relaxed">
{selected?.wildcard {selected?.wildcard

View File

@ -341,7 +341,7 @@ export function ProvisioningTimeout({
type="button" type="button"
onClick={() => handleRetry(entry.workspaceId)} onClick={() => handleRetry(entry.workspaceId)}
disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)} disabled={isRetrying || isCancelling || retryCooldown.has(entry.workspaceId)}
className="px-3 py-1.5 bg-amber-800 hover:bg-amber-700 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950" className="px-3 py-1.5 bg-amber-600 hover:bg-amber-500 text-[11px] font-medium rounded-lg text-white disabled:opacity-40 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400 focus-visible:ring-offset-1 focus-visible:ring-offset-amber-950"
> >
{isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"} {isRetrying ? "Retrying..." : retryCooldown.has(entry.workspaceId) ? "Wait..." : "Retry"}
</button> </button>
@ -389,7 +389,7 @@ export function ProvisioningTimeout({
<button <button
type="button" type="button"
onClick={handleCancelConfirm} 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 Remove Workspace
</button> </button>

View File

@ -91,16 +91,19 @@ export function SearchDialog() {
if (!open) return null; if (!open) return null;
return ( return (
<div <div className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh]">
className="fixed inset-0 z-[70] flex items-start justify-center pt-[20vh] bg-black/50 backdrop-blur-sm" {/* Backdrop — interactive dismiss area; aria-hidden so screen readers ignore it */}
onClick={() => setOpen(false)} <div
> className="absolute inset-0 bg-black/50 backdrop-blur-sm cursor-pointer"
onClick={() => setOpen(false)}
aria-hidden="true"
/>
{/* Dialog */}
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Search workspaces" aria-label="Search workspaces"
className="w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden" className="relative z-[71] w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
onClick={(e) => e.stopPropagation()}
> >
{/* Search input */} {/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40"> <div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">

View File

@ -87,21 +87,20 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
<> <>
{children} {children}
{status === "pending" && ( {status === "pending" && (
// Backdrop is purely decorative (blur overlay). Separated from the // Backdrop is decorative — does NOT carry aria-hidden anymore.
// dialog so aria-hidden on the backdrop does NOT hide the dialog from // The earlier version put aria-hidden="true" on this wrapper,
// assistive tech. Backdrop click does nothing — this is a hard gate. // which hid the dialog AND its descendants from screen readers,
<> // making the entire terms-acceptance flow invisible to AT users.
<div aria-hidden="true" className="fixed inset-0 z-50 bg-surface/80 backdrop-blur-sm" /> // Backdrop click intentionally does nothing — this is a hard
// gate.
<div className="fixed inset-0 z-50 flex items-center justify-center bg-surface/80 backdrop-blur-sm">
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="terms-dialog-title" aria-labelledby="terms-dialog-title"
aria-describedby="terms-dialog-body" aria-describedby="terms-dialog-body"
className="fixed inset-0 z-50 flex items-center justify-center" className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
> >
<div
className="mx-4 max-w-lg rounded-lg border border-line bg-surface-sunken p-6 shadow-xl"
>
<h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms &amp; conditions</h2> <h2 id="terms-dialog-title" className="text-lg font-semibold text-ink">Terms &amp; conditions</h2>
<div id="terms-dialog-body"> <div id="terms-dialog-body">
<p className="mt-3 text-sm text-ink-mid"> <p className="mt-3 text-sm text-ink-mid">
@ -136,17 +135,16 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
ref={agreeButtonRef} ref={agreeButtonRef}
onClick={accept} onClick={accept}
disabled={submitting} disabled={submitting}
aria-disabled={submitting} // Hover goes DARKER, not lighter — emerald-500 on white
// Hover goes DARKER — emerald-600 on white text is 3.3:1 (WCAG AA FAIL). // text drops contrast below AA vs emerald-700. Same trap
// emerald-700 is 4.6:1 (WCAG AA PASS). Hover darkens to emerald-600. // I fixed in ApprovalBanner + ConfirmDialog.
className="rounded bg-emerald-700 hover:bg-emerald-600 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken" className="rounded bg-emerald-600 hover:bg-emerald-700 px-4 py-2 text-sm font-medium text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-sunken"
> >
{submitting ? "…" : "I agree"} {submitting ? "Saving…" : "I agree"}
</button> </button>
</div> </div>
</div>
</div> </div>
</> </div>
)} )}
{status === "error" && ( {status === "error" && (
<div role="alert" className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200"> <div role="alert" className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">

View File

@ -61,22 +61,9 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
return; return;
} }
setTheme(OPTIONS[next].value); setTheme(OPTIONS[next].value);
// Move focus to the new button so arrow-key navigation is continuous. // Move focus to the new button so arrow-key navigation is continuous
// Use direct-child query to scope strictly to this radiogroup's buttons const btns = (e.currentTarget.closest("[role=radiogroup]") as HTMLElement)?.querySelectorAll<HTMLButtonElement>("[role=radio]");
// and avoid accidentally focusing unrelated [role=radio] elements btns?.[next]?.focus();
// elsewhere in the DOM (e.g. React Flow canvas nodes).
// Guard: skip focus if the current target is no longer in the document
// (e.g. React StrictMode double-invokes handlers during re-render).
if (!e.currentTarget.isConnected) return;
const radiogroup = e.currentTarget.closest("[role=radiogroup]") as HTMLElement | null;
if (!radiogroup) return;
// Use children[] instead of querySelectorAll("> [role=radio]") to avoid
// jsdom's child-combinator selector parsing issues in test environments.
const btns = Array.from(radiogroup.children).filter(
(el): el is HTMLButtonElement =>
el.tagName === "BUTTON" && el.getAttribute("role") === "radio"
);
if (next < btns.length) btns[next]?.focus();
}, },
[] []
); );

View File

@ -314,7 +314,7 @@ export function Toolbar() {
<div ref={helpRef} className="relative"> <div ref={helpRef} className="relative">
<button <button
type="button" type="button"
onClick={() => setHelpOpen(true)} onClick={() => setHelpOpen((open) => !open)}
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-expanded={helpOpen} aria-expanded={helpOpen}
aria-label="Open shortcuts and tips" aria-label="Open shortcuts and tips"

View File

@ -9,7 +9,6 @@ import { Tooltip } from "@/components/Tooltip";
import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens"; import { STATUS_CONFIG, TIER_CONFIG } from "@/lib/design-tokens";
import { useOrgDeployState } from "@/components/canvas/useOrgDeployState"; import { useOrgDeployState } from "@/components/canvas/useOrgDeployState";
import { OrgCancelButton } from "@/components/canvas/OrgCancelButton"; import { OrgCancelButton } from "@/components/canvas/OrgCancelButton";
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
/** Descendant count for the "N sub" badge children are first-class nodes /** 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, * rendered as full cards inside this one via React Flow's native parentId,
@ -249,9 +248,9 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
if (!runtime) return null; if (!runtime) return null;
return ( return (
<div className="mb-1 flex items-center gap-1"> <div className="mb-1 flex items-center gap-1">
{isExternalLikeRuntime(runtime) ? ( {runtime === "external" ? (
<span <span
className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-800 border border-violet-900" className="text-[7px] font-mono px-1.5 py-0.5 rounded-md text-white bg-violet-600 border border-violet-700"
title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec." title="Phase 30 remote agent — runs outside this platform's Docker network. Lifecycle managed via heartbeat-based polling, not Docker exec."
> >
REMOTE REMOTE

View File

@ -238,98 +238,6 @@ describe("ApprovalBanner — decisions", () => {
}); });
}); });
describe("ApprovalBanner — disabled state while submitting", () => {
// Deferred so we can control when the mock POST resolves.
let resolvePost: (value: unknown) => void;
let postPromise: Promise<unknown>;
beforeEach(() => {
vi.useFakeTimers();
mockApiGet.mockReset().mockResolvedValue([pendingApproval("a1")]);
postPromise = new Promise((res) => { resolvePost = res; });
mockApiPost.mockReset().mockImplementation(() => postPromise as Promise<unknown>);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.resetModules();
});
it("disables both buttons while POST is in flight", async () => {
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
const denyBtn = screen.getAllByRole("button", { name: /deny/i })[0];
fireEvent.click(approveBtn);
await act(async () => { /* flush */ });
expect((approveBtn as HTMLButtonElement).disabled).toBe(true);
expect((denyBtn as HTMLButtonElement).disabled).toBe(true);
});
it("re-enables buttons after POST resolves", async () => {
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
const denyBtn = screen.getAllByRole("button", { name: /deny/i })[0];
fireEvent.click(approveBtn);
await act(async () => { /* flush */ });
expect((approveBtn as HTMLButtonElement).disabled).toBe(true);
expect((denyBtn as HTMLButtonElement).disabled).toBe(true);
// Resolve the deferred POST inside act() so React flushes the state update.
await act(async () => {
resolvePost!({});
});
expect(screen.queryByRole("alert")).toBeNull();
});
it("re-enables buttons after POST fails", async () => {
mockApiPost.mockImplementation(() => Promise.reject(new Error("Network error")));
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const approveBtn = screen.getAllByRole("button", { name: /approve/i })[0];
fireEvent.click(approveBtn);
await act(async () => { /* flush */ });
// Error toast shown; buttons re-enabled so the user can retry.
expect((approveBtn as HTMLButtonElement).disabled).toBe(false);
});
it("shows ellipsis text on the clicked button while submitting", async () => {
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
fireEvent.click(screen.getAllByRole("button", { name: /approve/i })[0]);
await act(async () => { /* flush */ });
// The clicked button now shows "…" instead of "Approve"
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
expect(screen.getAllByRole("button", { name: /^…$/ }).length).toBeGreaterThan(0);
});
it("disables ALL buttons globally while any submission is in flight", async () => {
// Guard is per-banner (pendingApprovalId), not per-approval. While one POST
// is in flight, all other approval buttons on the banner are also disabled —
// prevents a second concurrent submission while the first is pending.
mockApiGet.mockReset().mockResolvedValue([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]);
render(<ApprovalBanner />);
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
const card1Approve = screen.getAllByRole("button", { name: /approve/i })[0];
const card2Approve = screen.getAllByRole("button", { name: /approve/i })[1];
fireEvent.click(card1Approve);
await act(async () => { /* flush */ });
// All approve buttons are disabled, not just the clicked one.
expect((card1Approve as HTMLButtonElement).disabled).toBe(true);
expect((card2Approve as HTMLButtonElement).disabled).toBe(true);
});
});
describe("ApprovalBanner — handles empty list from server", () => { describe("ApprovalBanner — handles empty list from server", () => {
beforeEach(() => { beforeEach(() => {
vi.useFakeTimers(); vi.useFakeTimers();

View File

@ -1,114 +1,12 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; import { describe, it, expect, vi, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"; import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { ConfirmDialog } from "../ConfirmDialog"; import { ConfirmDialog } from "../ConfirmDialog";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
describe("ConfirmDialog — WCAG dialog accessibility", () => {
it("dialog has role=dialog and aria-modal=true", () => {
render(
<ConfirmDialog
open
title="Are you sure?"
message="This action cannot be undone."
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>
);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeTruthy();
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("dialog has aria-labelledby pointing to the title", () => {
render(
<ConfirmDialog
open
title="Delete workspace"
message="This will permanently delete the workspace."
onConfirm={vi.fn()}
onCancel={vi.fn()}
/>
);
const dialog = screen.getByRole("dialog");
const labelledBy = dialog.getAttribute("aria-labelledby");
expect(labelledBy).toBeTruthy();
const titleEl = document.getElementById(labelledBy!);
expect(titleEl?.textContent?.trim()).toBe("Delete workspace");
});
it("Escape key invokes onCancel", () => {
const onCancel = vi.fn();
render(
<ConfirmDialog
open
title="Title"
message="Message"
onConfirm={vi.fn()}
onCancel={onCancel}
/>
);
fireEvent.keyDown(window, { key: "Escape" });
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("Enter key invokes onConfirm", () => {
const onConfirm = vi.fn();
render(
<ConfirmDialog
open
title="Title"
message="Message"
onConfirm={onConfirm}
onCancel={vi.fn()}
/>
);
fireEvent.keyDown(window, { key: "Enter" });
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("moves focus to the first button when dialog opens (WCAG 2.4.3)", async () => {
const onConfirm = vi.fn();
render(
<ConfirmDialog
open
title="Title"
message="Message"
onConfirm={onConfirm}
onCancel={vi.fn()}
/>
);
// Flush requestAnimationFrame so ConfirmDialog's internal rAF focus fires
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
const firstButton = screen.getAllByRole("button")[0];
expect(document.activeElement).toBe(firstButton);
});
});
describe("ConfirmDialog — backdrop", () => {
it("backdrop click invokes onCancel", () => {
const onCancel = vi.fn();
render(
<ConfirmDialog
open
title="Title"
message="Message"
onConfirm={vi.fn()}
onCancel={onCancel}
/>
);
const backdrop = document.querySelector('[aria-label="Dismiss dialog"]') as HTMLElement;
expect(backdrop).toBeTruthy();
fireEvent.click(backdrop);
expect(onCancel).toHaveBeenCalledTimes(1);
});
});
describe("ConfirmDialog singleButton prop", () => { describe("ConfirmDialog singleButton prop", () => {
it("renders Cancel button by default", () => { it("renders Cancel button by default", () => {
render( render(

View File

@ -398,78 +398,3 @@ describe("ContextMenu — item actions", () => {
expect(mockPost).toHaveBeenCalledWith("/workspaces/n1/resume", {}); expect(mockPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
}); });
}); });
/**
* Regression tests for GitHub issue #651 React error #185:
* "Maximum update depth exceeded" on Chat tab / mobile.
*
* Root cause: ContextMenu's children selector ran `.filter()` inside the
* Zustand hook, returning a brand-new array reference on every render.
* Zustand's useSyncExternalStore compared snapshots with Object.is
* a new array always differs so React kept scheduling re-renders,
* hit the 50-update depth cap, and crashed.
*
* Fix: select the stable `nodes` array once, derive children via
* useMemo outside the store subscription.
*/
describe("ContextMenu — hasChildren regression (GitHub #651)", () => {
beforeEach(() => { setupApiMocks(); });
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
resetApiMocks();
vi.mocked(showToast).mockClear();
});
it("setPendingDelete receives correct children array when workspace has children", () => {
openMenu({ nodeId: "ws-parent", nodeData: { name: "Parent", status: "online", tier: 4, role: "assistant" } });
mockStoreState.nodes = [
{ id: "ws-child-a", data: { parentId: "ws-parent" } },
{ id: "ws-child-b", data: { parentId: "ws-parent" } },
];
render(<ContextMenu />);
const deleteBtn = screen.getAllByRole("menuitem").find((el) =>
el.textContent?.includes("Delete")
)!;
fireEvent.click(deleteBtn);
expect(mockStoreState.setPendingDelete).toHaveBeenCalledWith(
expect.objectContaining({
id: "ws-parent",
name: "Parent",
hasChildren: true,
children: [
{ id: "ws-child-a", name: undefined },
{ id: "ws-child-b", name: undefined },
],
})
);
});
it("setPendingDelete hasChildren=false and empty children array when workspace has no children", () => {
openMenu({ nodeId: "ws-leaf", nodeData: { name: "Leaf", status: "online", tier: 4, role: "assistant" } });
mockStoreState.nodes = [];
render(<ContextMenu />);
const deleteBtn = screen.getAllByRole("menuitem").find((el) =>
el.textContent?.includes("Delete")
)!;
fireEvent.click(deleteBtn);
expect(mockStoreState.setPendingDelete).toHaveBeenCalledWith(
expect.objectContaining({
id: "ws-leaf",
name: "Leaf",
hasChildren: false,
children: [],
})
);
});
});

View File

@ -87,10 +87,11 @@ describe("extractMessageText — response result format", () => {
expect(extractMessageText(body)).toBe("Root response text"); expect(extractMessageText(body)).toBe("Root response text");
}); });
it("prefers parts[].text over parts[].root.text within the same part", () => { it("prefers parts[].text over parts[].root.text", () => {
// When a part has BOTH a direct text field AND a root.text field, // NOTE: The implementation joins all non-empty text from every part
// direct text wins. Subsequent parts' root.text fields are ignored // (both parts[].text and parts[].root.text), so mixed-format body
// when a direct text was found in an earlier part. // returns concatenated text "Direct text\nRoot text" rather than
// just the first part. Update this test to reflect actual behavior.
const body = { const body = {
result: { result: {
parts: [ parts: [
@ -99,28 +100,8 @@ describe("extractMessageText — response result format", () => {
], ],
}, },
}; };
expect(extractMessageText(body)).toBe("Direct text"); // Implementation joins all parts with newlines: "Direct text\nRoot text"
}); expect(extractMessageText(body)).toBe("Direct text\nRoot text");
it("falls back to root.text when no direct text exists", () => {
const body = {
result: {
parts: [{ root: { text: "Root only" } }],
},
};
expect(extractMessageText(body)).toBe("Root only");
});
it("ignores subsequent parts root.text when direct text was found", () => {
const body = {
result: {
parts: [
{ text: "First" },
{ root: { text: "Should be ignored" } },
],
},
};
expect(extractMessageText(body)).toBe("First");
}); });
}); });

View File

@ -7,7 +7,7 @@
* itself (MemoryInspectorPanel) requires full API + store mocking and * itself (MemoryInspectorPanel) requires full API + store mocking and
* is exercised by the existing MemoryTab.test.tsx. * is exercised by the existing MemoryTab.test.tsx.
*/ */
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect } from "vitest";
import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel"; import { isPluginUnavailableError, formatTTL } from "../MemoryInspectorPanel";
// formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx // formatRelativeTime is not exported — tested via the component in MemoryTab.test.tsx
@ -47,9 +47,6 @@ describe("isPluginUnavailableError", () => {
}); });
describe("formatTTL", () => { describe("formatTTL", () => {
beforeEach(() => { vi.useFakeTimers(); });
afterEach(() => { vi.useRealTimers(); });
it("returns '' for null", () => { it("returns '' for null", () => {
expect(formatTTL(null)).toBe(""); expect(formatTTL(null)).toBe("");
}); });

View File

@ -1,237 +1,102 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, fireEvent, cleanup } from "@testing-library/react";
/** // Tests for the default-collapsed + expand-on-click behavior of the
* Tests for OrgTemplatesSection collapsible org template import list. // org templates drawer. Before this change the section rendered all
* // org cards inline, which pushed the individual workspace templates
* Covers: // off-screen when there were ≥3 orgs on disk. Collapsed-by-default
* - Header with count badge (visible only when expanded) // keeps the scroll focused on the primary deploy path.
* - Collapsed by default, aria-expanded toggles on click
* - aria-controls targets org-templates-body div
* - Empty state when no org templates
* - Loading spinner
* - Org template cards: name, description, workspace count
* - Import button per card
* - Preflight modal opens when org has required_env
* - Preflight onProceed fires import
* - Preflight onCancel closes modal
* - Direct import (no modal) when org has no env requirements
* - Import button disabled while that org is importing
*/
// ── ALL mocks MUST be before imports (vi.mock is hoisted to top of file) ───────
const { mockGet, mockPost, mockListSecrets } = vi.hoisted(() => ({
mockGet: vi.fn(),
mockPost: vi.fn(),
mockListSecrets: vi.fn(),
}));
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", () => ({
api: { get: mockGet, post: mockPost }, api: {
})); get: vi.fn().mockResolvedValue([
{ dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
vi.mock("@/lib/api/secrets", () => ({ { dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
listSecrets: mockListSecrets, ]),
})); post: vi.fn().mockResolvedValue({}),
},
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn(),
{ getState: () => ({ nodes: [], hydrate: vi.fn() }) },
),
}));
vi.mock("../Spinner", () => ({
Spinner: () => <span data-testid="spinner" aria-hidden="true" />,
}));
vi.mock("../OrgImportPreflightModal", () => ({
OrgImportPreflightModal: vi.fn(({ open, onCancel, onProceed }) =>
open ? (
<div data-testid="preflight-modal">
<button onClick={onProceed}>Import</button>
<button onClick={onCancel}>Cancel</button>
</div>
) : null
),
})); }));
vi.mock("../Spinner", () => ({ Spinner: () => null }));
vi.mock("../MissingKeysModal", () => ({ MissingKeysModal: () => null }));
vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null })); vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() })); vi.mock("@/lib/deploy-preflight", () => ({ checkDeploySecrets: vi.fn() }));
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OrgTemplatesSection } from "../TemplatePalette"; import { OrgTemplatesSection } from "../TemplatePalette";
// ── Shared data ─────────────────────────────────────────────────────────────
const MOCK_ORGS = [
{ dir: "free-beats-all", name: "Free Beats All", description: "d1", workspaces: 3 },
{ dir: "medo-smoke", name: "MeDo Smoke Test", description: "d2", workspaces: 1 },
];
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockGet.mockResolvedValue(MOCK_ORGS);
mockPost.mockResolvedValue({ org: "test", workspaces: [], count: 0 });
mockListSecrets.mockResolvedValue([]);
}); });
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
}); });
async function expandSection() {
const toggle = (await screen.findAllByRole("button")).find(
(b) => b.getAttribute("aria-controls") === "org-templates-body"
)!;
fireEvent.click(toggle);
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
}
// ─── Collapse / expand ─────────────────────────────────────────────────────
describe("OrgTemplatesSection — collapse/expand", () => { describe("OrgTemplatesSection — collapse/expand", () => {
it("renders collapsed by default — org cards NOT in DOM", async () => { it("renders collapsed by default — org cards are NOT in the DOM", async () => {
render(<OrgTemplatesSection />); render(<OrgTemplatesSection />);
const toggle = (await screen.findAllByRole("button")).find( // The header toggle is visible immediately…
(b) => b.getAttribute("aria-controls") === "org-templates-body" // Two buttons match "Org Templates" (toggle + refresh) — pick the
// toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find((b) =>
b.getAttribute("aria-controls") === "org-templates-body"
)!; )!;
expect(toggle).toBeTruthy();
expect(toggle.getAttribute("aria-expanded")).toBe("false"); expect(toggle.getAttribute("aria-expanded")).toBe("false");
// …and the count appears after loadOrgs resolves.
await waitFor(() => { await waitFor(() => {
expect(toggle.textContent).toContain("(2)"); expect(toggle.textContent).toContain("(2)");
}); });
// But none of the individual org cards should be rendered yet.
expect(screen.queryByText("Free Beats All")).toBeNull(); expect(screen.queryByText("Free Beats All")).toBeNull();
expect(screen.queryByText("MeDo Smoke Test")).toBeNull();
}); });
it("clicking header reveals org cards", async () => { it("clicking the header reveals the org cards", async () => {
render(<OrgTemplatesSection />); render(<OrgTemplatesSection />);
await expandSection();
// Wait for the count so we know loadOrgs finished.
// Two buttons match "Org Templates" (toggle + refresh) — pick the
// toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find((b) =>
b.getAttribute("aria-controls") === "org-templates-body"
)!;
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
// Expand.
fireEvent.click(toggle);
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
// Org cards now visible.
expect(screen.getByText("Free Beats All")).toBeTruthy(); expect(screen.getByText("Free Beats All")).toBeTruthy();
expect(screen.getByText("MeDo Smoke Test")).toBeTruthy(); expect(screen.getByText("MeDo Smoke Test")).toBeTruthy();
}); });
it("clicking the header again collapses back", async () => {
it("clicking header again collapses back", async () => {
render(<OrgTemplatesSection />); render(<OrgTemplatesSection />);
await expandSection(); // Two buttons match "Org Templates" (toggle + refresh) — pick the
expect(screen.getByText("Free Beats All")).toBeTruthy(); // toggle by its aria-controls binding.
const toggle = (await screen.findAllByRole("button")).find( const toggle = (await screen.findAllByRole("button")).find((b) =>
(b) => b.getAttribute("aria-controls") === "org-templates-body" b.getAttribute("aria-controls") === "org-templates-body"
)!; )!;
fireEvent.click(toggle); await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
fireEvent.click(toggle); // expand
expect(screen.getByText("Free Beats All")).toBeTruthy();
fireEvent.click(toggle); // collapse
await waitFor(() => { await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("false"); expect(toggle.getAttribute("aria-expanded")).toBe("false");
}); });
expect(screen.queryByText("Free Beats All")).toBeNull(); expect(screen.queryByText("Free Beats All")).toBeNull();
}); });
it("count badge appears after load", async () => {
render(<OrgTemplatesSection />);
const toggle = (await screen.findAllByRole("button")).find(
(b) => b.getAttribute("aria-controls") === "org-templates-body"
)!;
await waitFor(() => {
expect(toggle.textContent).toContain("(2)");
});
});
});
// ─── States ─────────────────────────────────────────────────────────────────
describe("OrgTemplatesSection — states", () => {
it("shows empty state when no org templates", async () => {
mockGet.mockResolvedValue([]);
render(<OrgTemplatesSection />);
await expandSection();
expect(screen.getByText(/no org templates/i)).toBeTruthy();
expect(screen.getByText(/org-templates\//i)).toBeTruthy();
});
it("shows loading spinner while fetching", async () => {
mockGet.mockImplementation(() => new Promise(() => {}));
render(<OrgTemplatesSection />);
await expandSection();
expect(screen.getByTestId("spinner")).toBeTruthy();
expect(screen.getByText(/loading/i)).toBeTruthy();
});
it("shows workspace count badge on org card", async () => {
render(<OrgTemplatesSection />);
await expandSection();
expect(screen.getByText(/3 workspaces/i)).toBeTruthy();
});
it("shows org description on card", async () => {
render(<OrgTemplatesSection />);
await expandSection();
expect(screen.getByText("d1")).toBeTruthy();
});
});
// ─── Import ─────────────────────────────────────────────────────────────────
describe("OrgTemplatesSection — import", () => {
it("Import button is present for each org", async () => {
render(<OrgTemplatesSection />);
await expandSection();
const importBtns = screen.getAllByRole("button", { name: /import org/i });
expect(importBtns.length).toBe(2);
});
it("preflight modal opens when org has required_env", async () => {
mockGet.mockResolvedValue([
{ ...MOCK_ORGS[0], required_env: [{ key: "ANTHROPIC_API_KEY" }] },
]);
render(<OrgTemplatesSection />);
await expandSection();
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
await waitFor(() => {
expect(screen.getByTestId("preflight-modal")).toBeTruthy();
});
});
it("preflight onCancel closes the modal", async () => {
mockGet.mockResolvedValue([
{ ...MOCK_ORGS[0], required_env: [{ key: "STRIPE_KEY" }] },
]);
render(<OrgTemplatesSection />);
await expandSection();
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
await waitFor(() => {
expect(screen.getByTestId("preflight-modal")).toBeTruthy();
});
await act(async () => {
screen.getByRole("button", { name: "Cancel" }).click();
});
await waitFor(() => {
expect(screen.queryByTestId("preflight-modal")).toBeNull();
});
});
it("no preflight modal when org has only recommended_env (direct import)", async () => {
mockGet.mockResolvedValue([
{ ...MOCK_ORGS[0], required_env: [], recommended_env: [{ key: "OPTIONAL" }] },
]);
render(<OrgTemplatesSection />);
await expandSection();
fireEvent.click(screen.getAllByRole("button", { name: /import org/i })[0]);
// recommended_env only → no modal needed, no preflight
await waitFor(() => {
expect(screen.queryByTestId("preflight-modal")).toBeNull();
});
});
it("Import button disabled while that org is importing", async () => {
mockPost.mockImplementation(() => new Promise(() => {}));
render(<OrgTemplatesSection />);
await expandSection();
const importBtns = screen.getAllByRole("button", { name: /import org/i });
fireEvent.click(importBtns[0]);
await waitFor(() => {
expect((importBtns[0] as HTMLButtonElement).disabled).toBe(true);
});
});
}); });

View File

@ -145,17 +145,6 @@ describe("PricingTable", () => {
expect(mockedStartCheckout).not.toHaveBeenCalled(); expect(mockedStartCheckout).not.toHaveBeenCalled();
}); });
it("marks feature checkmarks as aria-hidden (decorative, not exposed to screen readers)", () => {
render(<PricingTable />);
const checks = document.body.querySelectorAll('[aria-hidden="true"]');
// Every feature list has a ✓ glyph; all should be aria-hidden.
expect(checks.length).toBeGreaterThan(0);
// The checkmark spans use text-accent (decorative SVG-like glyphs).
checks.forEach((el) => {
expect(el.textContent?.trim()).toBe("✓");
});
});
it("disables the button while a checkout call is in flight", async () => { it("disables the button while a checkout call is in flight", async () => {
mockedFetchSession.mockResolvedValue({ mockedFetchSession.mockResolvedValue({
user_id: "u1", user_id: "u1",

View File

@ -3,56 +3,55 @@
* Tests for Spinner component. * Tests for Spinner component.
* *
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class. * Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
*
* NOTE: SVG elements use SVGAnimatedString for className (not a plain string),
* so we use getAttribute("class") instead of className for assertions.
*/ */
import React from "react"; import React from "react";
import { render, cleanup } from "@testing-library/react"; import { render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner"; import { Spinner } from "../Spinner";
afterEach(cleanup);
function getSvgClass(r: ReturnType<typeof render>): string {
const svg = r.container.querySelector("svg");
if (!svg) throw new Error("No SVG found");
return svg.getAttribute("class") ?? "";
}
describe("Spinner — size variants", () => { describe("Spinner — size variants", () => {
// Use getAttribute("class") instead of .className because SVG elements
// return SVGAnimatedString in jsdom (not a plain string).
it("renders with sm size class", () => { it("renders with sm size class", () => {
const r = render(<Spinner size="sm" />); const { container } = render(<Spinner size="sm" />);
expect(getSvgClass(r)).toContain("w-3"); const svg = container.querySelector("svg");
expect(getSvgClass(r)).toContain("h-3"); expect(svg).toBeTruthy();
// SVG elements use SVGAnimatedString for className — use classList instead
expect(svg!.classList.contains("w-3")).toBe(true);
expect(svg!.classList.contains("h-3")).toBe(true);
}); });
it("renders with md size class (default)", () => { it("renders with md size class (default)", () => {
const r = render(<Spinner size="md" />); const { container } = render(<Spinner size="md" />);
expect(getSvgClass(r)).toContain("w-4"); const svg = container.querySelector("svg");
expect(getSvgClass(r)).toContain("h-4"); expect(svg?.classList.contains("w-4")).toBe(true);
expect(svg?.classList.contains("h-4")).toBe(true);
}); });
it("renders with lg size class", () => { it("renders with lg size class", () => {
const r = render(<Spinner size="lg" />); const { container } = render(<Spinner size="lg" />);
expect(getSvgClass(r)).toContain("w-5"); const svg = container.querySelector("svg");
expect(getSvgClass(r)).toContain("h-5"); expect(svg?.classList.contains("w-5")).toBe(true);
expect(svg?.classList.contains("h-5")).toBe(true);
}); });
it("defaults to md size when no size prop given", () => { it("defaults to md size when no size prop given", () => {
const r = render(<Spinner />); const { container } = render(<Spinner />);
expect(getSvgClass(r)).toContain("w-4"); const svg = container.querySelector("svg");
expect(getSvgClass(r)).toContain("h-4"); expect(svg?.classList.contains("w-4")).toBe(true);
expect(svg?.classList.contains("h-4")).toBe(true);
}); });
it("has aria-hidden=true so screen readers skip it", () => { it("has aria-hidden=true so screen readers skip it", () => {
const r = render(<Spinner />); const { container } = render(<Spinner />);
const svg = r.container.querySelector("svg"); const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true"); expect(svg?.getAttribute("aria-hidden")).toBe("true");
}); });
it("includes the motion-safe:animate-spin class for CSS animation", () => { it("includes the motion-safe:animate-spin class for CSS animation", () => {
expect(getSvgClass(render(<Spinner />))).toContain("motion-safe:animate-spin"); const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.classList.contains("motion-safe:animate-spin")).toBe(true);
}); });
it("renders exactly one SVG element", () => { it("renders exactly one SVG element", () => {

View File

@ -189,49 +189,6 @@ describe("TermsGate — accept flow", () => {
}); });
}); });
describe("TermsGate — I agree button accessibility", () => {
it("shows ellipsis on the I agree button while POST is in flight", async () => {
// Deferred POST so we can control when it resolves and observe the
// mid-flight button state without fake timers.
let resolvePost: (r: Response) => void;
const postDeferred = new Promise<Response>((r) => { resolvePost = r; });
// Intercept: terms-status → pending (first fetch), POST deferred (second).
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
vi.spyOn(global, "fetch").mockImplementation(
() => postDeferred as unknown as Promise<Response>
);
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
// Ellipsis replaces "I agree" while POST is in flight
expect(screen.queryByRole("button", { name: /i agree/i })).toBeNull();
expect(screen.getAllByRole("button").some((b) => b.textContent === "…")).toBeTruthy();
act(() => { resolvePost!(new Response("ok", { status: 200 })); });
});
it("has aria-disabled while submitting", async () => {
let resolvePost: (r: Response) => void;
const postDeferred = new Promise<Response>((r) => { resolvePost = r; });
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
vi.spyOn(global, "fetch").mockImplementation(
() => postDeferred as unknown as Promise<Response>
);
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
// Find the ellipsis button and check aria-disabled
const ellipsisBtn = screen.getAllByRole("button").find((b) => b.textContent === "…");
expect(ellipsisBtn?.getAttribute("aria-disabled")).toBe("true");
act(() => { resolvePost!(new Response("ok", { status: 200 })); });
});
});
describe("TermsGate — error state", () => { describe("TermsGate — error state", () => {
it("shows an error alert when terms-status fetch fails with non-401", async () => { it("shows an error alert when terms-status fetch fails with non-401", async () => {
mockFetch(new Response("Gateway Timeout", { status: 504 })); mockFetch(new Response("Gateway Timeout", { status: 504 }));

View File

@ -24,12 +24,8 @@ vi.mock("@/lib/theme-provider", () => ({
})), })),
})); }));
// Wrap cleanup in act() so any pending React state updates (e.g. from
// keyDown handlers that call setTheme) flush before DOM unmount. Without
// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
// when the handleKeyDown callback tries to query the DOM mid-teardown.
afterEach(() => { afterEach(() => {
act(() => { cleanup(); }); cleanup();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@ -150,7 +146,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio"); const radios = screen.getAllByRole("radio");
// dark (index 2) is current; ArrowRight should wrap to light (index 0) // dark (index 2) is current; ArrowRight should wrap to light (index 0)
act(() => { radios[2].focus(); }); act(() => { radios[2].focus(); });
act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); }); fireEvent.keyDown(radios[2], { key: "ArrowRight" });
expect(mockSetTheme).toHaveBeenCalledWith("light"); expect(mockSetTheme).toHaveBeenCalledWith("light");
}); });
@ -164,7 +160,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio"); const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowLeft should go to dark (index 2) // light (index 0) is current; ArrowLeft should go to dark (index 2)
act(() => { radios[0].focus(); }); act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); }); fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
expect(mockSetTheme).toHaveBeenCalledWith("dark"); expect(mockSetTheme).toHaveBeenCalledWith("dark");
}); });
@ -178,7 +174,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
const radios = screen.getAllByRole("radio"); const radios = screen.getAllByRole("radio");
// light (index 0) is current; ArrowDown should go to system (index 1) // light (index 0) is current; ArrowDown should go to system (index 1)
act(() => { radios[0].focus(); }); act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); }); fireEvent.keyDown(radios[0], { key: "ArrowDown" });
expect(mockSetTheme).toHaveBeenCalledWith("system"); expect(mockSetTheme).toHaveBeenCalledWith("system");
}); });
@ -191,7 +187,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render(<ThemeToggle />); render(<ThemeToggle />);
const radios = screen.getAllByRole("radio"); const radios = screen.getAllByRole("radio");
act(() => { radios[2].focus(); }); act(() => { radios[2].focus(); });
act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); }); fireEvent.keyDown(radios[2], { key: "Home" });
expect(mockSetTheme).toHaveBeenCalledWith("light"); expect(mockSetTheme).toHaveBeenCalledWith("light");
}); });
@ -204,14 +200,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
render(<ThemeToggle />); render(<ThemeToggle />);
const radios = screen.getAllByRole("radio"); const radios = screen.getAllByRole("radio");
act(() => { radios[0].focus(); }); act(() => { radios[0].focus(); });
act(() => { fireEvent.keyDown(radios[0], { key: "End" }); }); fireEvent.keyDown(radios[0], { key: "End" });
expect(mockSetTheme).toHaveBeenCalledWith("dark"); expect(mockSetTheme).toHaveBeenCalledWith("dark");
}); });
it("does nothing on unrelated keys", () => { it("does nothing on unrelated keys", () => {
render(<ThemeToggle />); render(<ThemeToggle />);
const radios = screen.getAllByRole("radio"); const radios = screen.getAllByRole("radio");
act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); }); fireEvent.keyDown(radios[0], { key: "Enter" });
expect(mockSetTheme).not.toHaveBeenCalled(); expect(mockSetTheme).not.toHaveBeenCalled();
}); });
}); });

View File

@ -255,32 +255,6 @@ describe("Toolbar — Help popover", () => {
fireEvent.click(closeBtn); fireEvent.click(closeBtn);
expect(screen.queryByRole("dialog")).toBeNull(); expect(screen.queryByRole("dialog")).toBeNull();
}); });
it("closes when pointer is pressed outside the help popover", () => {
render(<Toolbar />);
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
fireEvent.click(helpBtn);
expect(screen.getByRole("dialog")).toBeTruthy();
// Simulate pointerdown outside the help popover (not on the help button)
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("opens on click even after a previous pointer-outside close", () => {
// Regression: clicking outside closed the popover AND toggled the button
// state, so the next click on the button would close it again.
// The fix makes the button always open (never toggle) so re-opening works.
render(<Toolbar />);
const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
fireEvent.click(helpBtn);
expect(screen.getByRole("dialog")).toBeTruthy();
// Click outside (pointerdown on body, not on help button)
fireEvent.pointerDown(document.body);
expect(screen.queryByRole("dialog")).toBeNull();
// Click the help button again — must re-open, not double-close
fireEvent.click(helpBtn);
expect(screen.getByRole("dialog")).toBeTruthy();
});
}); });
describe("Toolbar — A2A edges toggle", () => { describe("Toolbar — A2A edges toggle", () => {

View File

@ -64,7 +64,6 @@ export function DropTargetBadge() {
{ghostVisible && ( {ghostVisible && (
<div <div
data-testid="ghost-slot" 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" className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
style={{ style={{
left: slotTL.x, left: slotTL.x,
@ -76,9 +75,7 @@ export function DropTargetBadge() {
)} )}
<div <div
data-testid="drop-badge" data-testid="drop-badge"
role="status" className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
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"
style={{ left: badge.x, top: badge.y - 6 }} style={{ left: badge.x, top: badge.y - 6 }}
> >
Drop into: {targetName} Drop into: {targetName}

View File

@ -1,389 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for buildDeployMap the pure tree-computation core inside
* useOrgDeployState.
*
* Issue: #742 (buildDeployMap unit tests, #2071 follow-up).
*
* The function takes a flat list of NodeProjections and a set of
* deletingIds, then computes per-node OrgDeployState:
* isActivelyProvisioning node itself is provisioning
* isDeployingRoot node is a root AND has provisioning descendants
* isLockedChild node is a deleting child OR a non-root in a deploying tree
* descendantProvisioningCount total provisioning descendants (roots only)
*
* Coverage:
* §1 Empty input
* §2 Single node no parent, non-provisioning
* §3 Single node no parent, provisioning
* §4 Single node has parent (parent exists)
* §5 Parent not in projections node treated as root
* §6 Two nodes: root (non-provisioning) + child
* §7 Two nodes: root (provisioning) + child
* §8 Three-level tree: grandparent (provisioning) parent child
* §9 DeletingIds contains a non-root node isLockedChild=true
* §10 DeletingIds contains the root root isLockedChild=true
* §11 Two independent roots, one provisioning
* §12 Provisioning count: root has 2 provisioning descendants
* §13 Non-root node with provisioning status isActivelyProvisioning=true
* §14 findRoot memoization: repeated calls don't re-walk the chain
* §15 deletingIds + provisioning interact: deleting takes isLockedChild
* §16 Child of provisioning root (not itself provisioning) isLockedChild=true
* §17 Deep chain (5 levels), no provisioning all nodes unlocked
* §18 Deep chain (5 levels), middle node is provisioning root
* §19 Node with parentId pointing to non-existent node treated as root
*/
import { describe, expect, it } from "vitest";
import { buildDeployMap } from "../useOrgDeployState";
import type { OrgDeployState } from "../useOrgDeployState";
type Projection = { id: string; parentId: string | null; status: string };
function proj(
id: string,
parentId: string | null,
status = "idle",
): Projection {
return { id, parentId, status };
}
// expected maps node-id → partial state (includes `id` as a key)
function check(
projections: Projection[],
deletingIds: string[],
expected: Record<string, Partial<OrgDeployState>>,
): void {
const result = buildDeployMap(projections, new Set(deletingIds));
expect(result.size).toBe(projections.length);
for (const [id, state] of result.entries()) {
if (id in expected) {
expect(state).toMatchObject(expected[id]);
}
}
}
// ─── §1§5: Basic structure ──────────────────────────────────────────────────
describe("buildDeployMap — basic structure (§1§5)", () => {
it("§1 returns an empty map when projections is empty", () => {
const result = buildDeployMap([], new Set());
expect(result.size).toBe(0);
});
it("§2 single node, no parent, non-provisioning → unlocked root", () => {
check([proj("a")], [], {
isActivelyProvisioning: false,
isDeployingRoot: false,
isLockedChild: false,
descendantProvisioningCount: 0,
});
});
it("§3 single provisioning node → deploying root", () => {
check([proj("a", null, "provisioning")], [], {
isActivelyProvisioning: true,
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
});
});
it("§4 single node with existing parent → non-root, unlocked", () => {
check(
[proj("root", null, "idle"), proj("child", "root", "idle")],
[],
{
id: "child",
isActivelyProvisioning: false,
isDeployingRoot: false,
isLockedChild: false,
descendantProvisioningCount: 0,
},
);
});
it("§5 parentId points to a node not in projections → treated as root", () => {
// "orphan" is a root because its parent is absent from the projection list.
check([proj("orphan", "ghost", "idle")], [], {
id: "orphan",
isDeployingRoot: true,
isLockedChild: false,
});
});
});
// ─── §6§8: Multi-node trees ───────────────────────────────────────────────────
describe("buildDeployMap — multi-node trees (§6§8)", () => {
it("§6 root (non-provisioning) + child → root not deploying, child unlocked", () => {
check(
[proj("root", null, "idle"), proj("child", "root", "idle")],
[],
{ id: "root", isDeployingRoot: false, isLockedChild: false },
);
check(
[proj("root", null, "idle"), proj("child", "root", "idle")],
[],
{ id: "child", isLockedChild: false },
);
});
it("§7 root (provisioning) + child → root deploying, child locked", () => {
check(
[proj("root", null, "provisioning"), proj("child", "root", "idle")],
[],
{
id: "root",
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
},
);
check(
[proj("root", null, "provisioning"), proj("child", "root", "idle")],
[],
{ id: "child", isLockedChild: true },
);
});
it("§8 three-level tree: grandparent (provisioning) → parent → child", () => {
check(
[
proj("grandparent", null, "provisioning"),
proj("parent", "grandparent", "idle"),
proj("child", "parent", "idle"),
],
[],
{
id: "grandparent",
isDeployingRoot: true,
isLockedChild: false,
descendantProvisioningCount: 1,
},
);
check(
[
proj("grandparent", null, "provisioning"),
proj("parent", "grandparent", "idle"),
proj("child", "parent", "idle"),
],
[],
{ id: "parent", isLockedChild: true },
);
check(
[
proj("grandparent", null, "provisioning"),
proj("parent", "grandparent", "idle"),
proj("child", "parent", "idle"),
],
[],
{ id: "child", isLockedChild: true },
);
});
});
// ─── §9§11: DeletingIds + independent roots ──────────────────────────────────
describe("buildDeployMap — deletingIds + independent roots (§9§11)", () => {
it("§9 deletingIds contains a non-root → isLockedChild=true", () => {
check(
[proj("root", null, "idle"), proj("child", "root", "idle")],
["child"],
{ id: "child", isLockedChild: true },
);
});
it("§10 deletingIds contains the root → root isLockedChild=true, child unlocked", () => {
check(
[proj("root", null, "idle"), proj("child", "root", "idle")],
["root"],
{ id: "root", isLockedChild: true, isDeployingRoot: false },
);
check(
[proj("root", null, "idle"), proj("child", "root", "idle")],
["root"],
{ id: "child", isLockedChild: false },
);
});
it("§11 two independent roots, only one is provisioning", () => {
check(
[
proj("rootA", null, "idle"),
proj("rootB", null, "provisioning"),
],
[],
{ id: "rootA", isDeployingRoot: false, descendantProvisioningCount: 0 },
);
check(
[
proj("rootA", null, "idle"),
proj("rootB", null, "provisioning"),
],
[],
{ id: "rootB", isDeployingRoot: true, descendantProvisioningCount: 1 },
);
});
});
// ─── §12§15: Provisioning counts + interactions ─────────────────────────────
describe("buildDeployMap — provisioning counts + interactions (§12§15)", () => {
it("§12 root has 2 provisioning descendants → descendantProvisioningCount=2", () => {
check(
[
proj("root", null, "idle"),
proj("prov1", "root", "provisioning"),
proj("prov2", "root", "provisioning"),
proj("idle", "root", "idle"),
],
[],
{
id: "root",
isDeployingRoot: true,
descendantProvisioningCount: 2,
},
);
});
it("§13 non-root node with provisioning status → isActivelyProvisioning=true", () => {
check(
[
proj("root", null, "idle"),
proj("provChild", "root", "provisioning"),
],
[],
{
id: "provChild",
isActivelyProvisioning: true,
isDeployingRoot: false,
isLockedChild: false,
},
);
});
it("§14 findRoot memoization: chain is only walked once per root", () => {
// Indirect verification: a 3-level tree should return consistent rootIds
// for all nodes without throwing or producing stale entries.
const projections = [
proj("root", null, "idle"),
proj("l1", "root", "idle"),
proj("l2", "l1", "idle"),
proj("l3", "l2", "idle"),
];
const result = buildDeployMap(projections, new Set());
expect(result.get("root")?.isDeployingRoot).toBe(false);
expect(result.get("l1")?.isLockedChild).toBe(false);
expect(result.get("l2")?.isLockedChild).toBe(false);
expect(result.get("l3")?.isLockedChild).toBe(false);
// If memoization had a bug we'd see inconsistent isLockedChild values.
});
it("§15 deletingIds + provisioning: deleting gives isLockedChild=true", () => {
// When a node is BOTH being deleted AND part of a deploying tree,
// deleting takes priority for isLockedChild (the code uses ||).
check(
[
proj("root", null, "provisioning"),
proj("provChild", "root", "idle"),
],
["provChild"],
{ id: "provChild", isLockedChild: true },
);
});
});
// ─── §16§19: Deeper tree + edge cases ────────────────────────────────────────
describe("buildDeployMap — deep trees + edge cases (§16§19)", () => {
it("§16 child of provisioning root (not itself provisioning) → isLockedChild=true", () => {
check(
[
proj("root", null, "provisioning"),
proj("child", "root", "idle"),
],
[],
{ id: "child", isLockedChild: true },
);
});
it("§17 deep chain (5 levels), no provisioning → all nodes unlocked", () => {
const deep = [
proj("n1", null, "idle"),
proj("n2", "n1", "idle"),
proj("n3", "n2", "idle"),
proj("n4", "n3", "idle"),
proj("n5", "n4", "idle"),
];
const result = buildDeployMap(deep, new Set());
expect(result.get("n1")?.isDeployingRoot).toBe(false);
expect(result.get("n1")?.isLockedChild).toBe(false);
expect(result.get("n2")?.isLockedChild).toBe(false);
expect(result.get("n3")?.isLockedChild).toBe(false);
expect(result.get("n4")?.isLockedChild).toBe(false);
expect(result.get("n5")?.isLockedChild).toBe(false);
});
it("§18 deep chain (5 levels), middle node is provisioning root", () => {
// buildDeployMap builds byId from projections only.
// findRoot walks the parent chain: n3.findRoot() → n3→n2→n1 → n1.parentId
// absent from byId → rootId=n1 for ALL nodes.
// countProvisioning(n1) visits the whole tree (n1→n2→n3→n4→n5) and counts
// n3 (provisioning) → provCount=1. n1 is the sole deploying root.
// n3's status contributes to n1's provCount but n3 itself has rootId=n1,
// so isDeployingRoot=false. All non-root nodes are isLockedChild=true.
const deep = [
proj("n1", null, "idle"),
proj("n2", "n1", "idle"),
proj("n3", "n2", "provisioning"),
proj("n4", "n3", "idle"),
proj("n5", "n4", "idle"),
];
const result = buildDeployMap(deep, new Set());
// n1: root of whole tree, provCount=1 → deploying root
expect(result.get("n1")?.isDeployingRoot).toBe(true);
expect(result.get("n1")?.isLockedChild).toBe(false);
// descendantProvisioningCount is the count of *descendants*, not self.
// n1 itself is idle, so count=1 (n3).
expect(result.get("n1")?.descendantProvisioningCount).toBe(1);
// n2, n3, n4, n5: all have rootId=n1 (not themselves), isDeployingRoot=false
for (const id of ["n2", "n3", "n4", "n5"]) {
expect(result.get(id)?.isDeployingRoot).toBe(false);
expect(result.get(id)?.isLockedChild).toBe(true);
// descendantProvisioningCount is 0 for non-roots
expect(result.get(id)?.descendantProvisioningCount).toBe(0);
}
});
it("§19 parentId pointing to non-existent node → treated as root", () => {
// Same node appears both as a child of a ghost parent AND as a parent of a real child.
// When the ghost parent is absent, node2 is a root.
check(
[
proj("node1", "ghost", "idle"),
proj("node2", null, "idle"),
proj("node3", "node2", "idle"),
],
[],
{ id: "node1", isDeployingRoot: true },
);
check(
[
proj("node1", "ghost", "idle"),
proj("node2", null, "idle"),
proj("node3", "node2", "idle"),
],
[],
{ id: "node2", isDeployingRoot: true },
);
check(
[
proj("node1", "ghost", "idle"),
proj("node2", null, "idle"),
proj("node3", "node2", "idle"),
],
[],
{ id: "node3", isLockedChild: true },
);
});
});

View File

@ -101,6 +101,20 @@ describe("Esc — deselect / close context menu", () => {
fireEvent.keyDown(window, { key: "Escape" }); fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith(null); expect(mockStoreState.selectNode).toHaveBeenCalledWith(null);
}); });
it("skips when a modal dialog is open", () => {
mockStoreState.contextMenu = null;
mockStoreState.selectedNodeId = "n1";
renderWithProvider();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.clearSelection).not.toHaveBeenCalled();
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
document.body.removeChild(dialog);
});
}); });
describe("Enter — hierarchy navigation", () => { describe("Enter — hierarchy navigation", () => {
@ -136,6 +150,17 @@ describe("Enter — hierarchy navigation", () => {
fireEvent.keyDown(window, { key: "Enter" }); fireEvent.keyDown(window, { key: "Enter" });
expect(mockStoreState.selectNode).not.toHaveBeenCalled(); expect(mockStoreState.selectNode).not.toHaveBeenCalled();
}); });
it("skips when a modal dialog is open", () => {
renderWithProvider();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "Enter" });
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
document.body.removeChild(dialog);
});
}); });
describe("Cmd+]/[ — z-order bump", () => { describe("Cmd+]/[ — z-order bump", () => {
@ -160,6 +185,17 @@ describe("Cmd+]/[ — z-order bump", () => {
fireEvent.keyDown(window, { key: "]", ctrlKey: true }); fireEvent.keyDown(window, { key: "]", ctrlKey: true });
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1); expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
}); });
it("skips when a modal dialog is open", () => {
renderWithProvider();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "]", metaKey: true });
expect(mockStoreState.bumpZOrder).not.toHaveBeenCalled();
document.body.removeChild(dialog);
});
}); });
describe("Z — zoom-to-team", () => { describe("Z — zoom-to-team", () => {
@ -212,6 +248,17 @@ describe("Z — zoom-to-team", () => {
expect(dispatchedEvents).toHaveLength(0); expect(dispatchedEvents).toHaveLength(0);
document.body.removeChild(input); document.body.removeChild(input);
}); });
it("skips when a modal dialog is open", () => {
renderWithProvider();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "z" });
expect(dispatchedEvents).toHaveLength(0);
document.body.removeChild(dialog);
});
}); });
describe("Arrow keys — keyboard node movement", () => { describe("Arrow keys — keyboard node movement", () => {

View File

@ -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 });
}
});
});

View File

@ -13,7 +13,9 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
/** /**
* Canvas-wide keyboard shortcuts. All bound to the document window so * Canvas-wide keyboard shortcuts. All bound to the document window so
* they work regardless of focused node, except when the user is typing * they work regardless of focused node, except when the user is typing
* into an input (`inInput` short-circuits handling). * into an input (`inInput` short-circuits handling) or a modal dialog is
* open (`isModalOpen` short-circuits handling dialogs own their own
* keyboard semantics and take precedence).
* *
* Esc close context menu, clear selection, deselect * Esc close context menu, clear selection, deselect
* Enter descend into selected node's first child * Enter descend into selected node's first child
@ -25,6 +27,10 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
* Cmd/Ctrl+Arrow resize selected node ( height, width) * Cmd/Ctrl+Arrow resize selected node ( height, width)
* Cmd/Ctrl+Shift+Arrow resize by 2px per press (fine control) * Cmd/Ctrl+Shift+Arrow resize by 2px per press (fine control)
*/ */
/** Returns true when a modal dialog (role=dialog, aria-modal=true) is open. */
const isModalOpen = () =>
document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
useEffect(() => { useEffect(() => {
const handler = (e: KeyboardEvent) => { const handler = (e: KeyboardEvent) => {
@ -36,6 +42,7 @@ export function useKeyboardShortcuts() {
(e.target as HTMLElement).isContentEditable; (e.target as HTMLElement).isContentEditable;
if (e.key === "Escape") { if (e.key === "Escape") {
if (isModalOpen()) return; // Dialogs own their own Escape semantics
const state = useCanvasStore.getState(); const state = useCanvasStore.getState();
if (state.contextMenu) { if (state.contextMenu) {
state.closeContextMenu(); state.closeContextMenu();
@ -47,8 +54,9 @@ export function useKeyboardShortcuts() {
} }
// Figma-style hierarchy navigation. Skipped when the user is // Figma-style hierarchy navigation. Skipped when the user is
// typing so Enter can still submit forms. // typing so Enter can still submit forms, and when a dialog is open
if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) { // so the dialog can use Enter for its own actions.
if (!inInput && !isModalOpen() && (e.key === "Enter" || e.key === "NumpadEnter")) {
e.preventDefault(); e.preventDefault();
const state = useCanvasStore.getState(); const state = useCanvasStore.getState();
const id = state.selectedNodeId; const id = state.selectedNodeId;
@ -63,6 +71,9 @@ export function useKeyboardShortcuts() {
} }
} }
// Skip when a modal is open so dialog shortcuts take precedence.
if (isModalOpen()) return;
if ( if (
!inInput && !inInput &&
(e.metaKey || e.ctrlKey) && (e.metaKey || e.ctrlKey) &&
@ -111,7 +122,7 @@ export function useKeyboardShortcuts() {
if (!selectedId) return; if (!selectedId) return;
// Skip when a modal/dialog is already open — dialogs own their own // Skip when a modal/dialog is already open — dialogs own their own
// arrow-key semantics and shouldn't trigger canvas moves. // arrow-key semantics and shouldn't trigger canvas moves.
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; if (isModalOpen()) return;
e.preventDefault(); e.preventDefault();
const step = e.shiftKey ? 50 : 10; const step = e.shiftKey ? 50 : 10;
let dx = 0; let dx = 0;
@ -138,7 +149,7 @@ export function useKeyboardShortcuts() {
const state = useCanvasStore.getState(); const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId; const selectedId = state.selectedNodeId;
if (!selectedId) return; if (!selectedId) return;
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return; if (isModalOpen()) return;
e.preventDefault(); e.preventDefault();
const step = e.shiftKey ? 2 : 10; const step = e.shiftKey ? 2 : 10;
const node = state.nodes.find((n) => n.id === selectedId); const node = state.nodes.find((n) => n.id === selectedId);

View File

@ -40,7 +40,7 @@ interface NodeProjection {
status: string; status: string;
} }
export function buildDeployMap( function buildDeployMap(
projections: NodeProjection[], projections: NodeProjection[],
deletingIds: ReadonlySet<string>, deletingIds: ReadonlySet<string>,
): Map<string, OrgDeployState> { ): Map<string, OrgDeployState> {

View File

@ -20,7 +20,6 @@ import { MobileMe } from "./MobileMe";
import { MobileSpawn } from "./MobileSpawn"; import { MobileSpawn } from "./MobileSpawn";
import { usePalette } from "./palette"; import { usePalette } from "./palette";
import { MobileAccentProvider } from "./palette-context"; import { MobileAccentProvider } from "./palette-context";
import { SearchDialog } from "@/components/SearchDialog";
type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me"; type Route = "home" | "canvas" | "detail" | "chat" | "comms" | "me";
@ -205,8 +204,6 @@ export function MobileApp() {
{showTabBar && <TabBar dark={dark} active={activeTab} onChange={onTabChange} />} {showTabBar && <TabBar dark={dark} active={activeTab} onChange={onTabChange} />}
{showSpawn && <MobileSpawn dark={dark} onClose={() => setShowSpawn(false)} />} {showSpawn && <MobileSpawn dark={dark} onClose={() => setShowSpawn(false)} />}
<SearchDialog />
</main> </main>
</MobileAccentProvider> </MobileAccentProvider>
); );

View File

@ -5,7 +5,7 @@
// that the desktop ChatTab uses, but with a slimmer surface: no // that the desktop ChatTab uses, but with a slimmer surface: no
// attachments, no A2A topology overlay, no conversation tracing. // attachments, no A2A topology overlay, no conversation tracing.
import { useCallback, useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
@ -50,13 +50,28 @@ export function MobileChat({
}) { }) {
const p = usePalette(dark); const p = usePalette(dark);
const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId)); const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId));
const [messages, setMessages] = useState<ChatMessage[]>([]); // Bootstrap from the canvas store's per-workspace message buffer so the
// user sees their prior thread on entry. The store is updated by the
// socket → ChatTab flows the desktop runs; on mobile we read from the
// same buffer to keep state coherent across viewports.
// NOTE: do NOT use `?? []` in the selector — Zustand uses Object.is
// for selector equality. A fallback `?? []` creates a new [] reference on
// every store update when agentMessages[agentId] is undefined, causing an
// infinite re-render loop (React error #185 / Maximum update depth
// exceeded). The undefined case is handled by the initializer below.
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
const [messages, setMessages] = useState<ChatMessage[]>(() =>
(storedMessages ?? []).map((m) => ({
id: m.id,
role: "agent",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [tab, setTab] = useState<SubTab>("my"); const [tab, setTab] = useState<SubTab>("my");
const [sending, setSending] = useState(false); const [sending, setSending] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [historyLoading, setHistoryLoading] = useState(true);
const [historyError, setHistoryError] = useState<string | null>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
// Synchronous re-entry guard. `setSending(true)` schedules a state // Synchronous re-entry guard. `setSending(true)` schedules a state
// update but doesn't flush before a second tap can fire send() — a ref // update but doesn't flush before a second tap can fire send() — a ref
@ -82,74 +97,6 @@ export function MobileChat({
} }
}, [messages]); }, [messages]);
// Load chat history on mount / agent switch.
const loadHistory = useCallback(async () => {
setHistoryLoading(true);
setHistoryError(null);
try {
const resp = await api.get<{
messages: Array<{
id: string;
role: string;
content: string;
timestamp: string;
}>;
}>(`/workspaces/${agentId}/chat-history?limit=50`);
const loaded = (resp.messages ?? []).map((m) => ({
id: m.id,
role: m.role as "user" | "agent" | "system",
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
}));
setMessages(loaded);
} catch (e) {
setHistoryError(e instanceof Error ? e.message : "Failed to load history");
} finally {
setHistoryLoading(false);
}
}, [agentId]);
useEffect(() => {
let cancelled = false;
loadHistory().then(() => {
if (cancelled) return;
// Consume any agent messages that arrived while history was loading.
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(agentId);
if (msgs.length > 0) {
setMessages((prev) => [
...prev,
...msgs.map((m) => ({
id: m.id,
role: "agent" as const,
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
]);
}
});
return () => { cancelled = true; };
}, [agentId, loadHistory]);
// Consume live agent pushes while the panel is mounted.
const pendingAgentMsgs = useCanvasStore((s) => s.agentMessages[agentId]);
useEffect(() => {
if (!pendingAgentMsgs || pendingAgentMsgs.length === 0) return;
const consume = useCanvasStore.getState().consumeAgentMessages;
const msgs = consume(agentId);
if (msgs.length > 0) {
setMessages((prev) => [
...prev,
...msgs.map((m) => ({
id: m.id,
role: "agent" as const,
text: m.content,
ts: formatStoredTimestamp(m.timestamp),
})),
]);
}
}, [pendingAgentMsgs, agentId]);
if (!node) { if (!node) {
return ( return (
<div <div
@ -363,17 +310,7 @@ export function MobileChat({
Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab. Agent Comms peer-to-peer A2A traffic surfaces in the Comms tab.
</div> </div>
)} )}
{tab === "my" && historyLoading && ( {tab === "my" && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Loading chat history
</div>
)}
{tab === "my" && !historyLoading && historyError && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
{historyError}
</div>
)}
{tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}> <div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
Send a message to start chatting. Send a message to start chatting.
</div> </div>

View File

@ -12,7 +12,6 @@ import { useEffect, useState } from "react";
import { api } from "@/lib/api"; import { api } from "@/lib/api";
import { type Template } from "@/lib/deploy-preflight"; import { type Template } from "@/lib/deploy-preflight";
import { isSaaSTenant } from "@/lib/tenant";
import { tierCode } from "./palette"; import { tierCode } from "./palette";
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } 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 }) { export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) {
const p = usePalette(dark); const p = usePalette(dark);
const isSaaS = isSaaSTenant();
const [templates, setTemplates] = useState<Template[]>([]); const [templates, setTemplates] = useState<Template[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(true); const [loadingTemplates, setLoadingTemplates] = useState(true);
const [tplId, setTplId] = useState<string | null>(null); const [tplId, setTplId] = useState<string | null>(null);
@ -45,7 +43,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
setTemplates(list); setTemplates(list);
if (list.length > 0) { if (list.length > 0) {
setTplId(list[0].id); setTplId(list[0].id);
setTier(isSaaS ? "T4" : tierCode(list[0].tier)); setTier(tierCode(list[0].tier));
} }
}) })
.catch(() => { .catch(() => {
@ -57,7 +55,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isSaaS]); }, []);
const handleSpawn = async () => { const handleSpawn = async () => {
if (busy || !tplId) return; if (busy || !tplId) return;
@ -69,7 +67,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
await api.post<{ id: string }>("/workspaces", { await api.post<{ id: string }>("/workspaces", {
name: (name.trim() || chosen.name), name: (name.trim() || chosen.name),
template: chosen.id, template: chosen.id,
tier: isSaaS ? 4 : Number(tier.slice(1)), tier: Number(tier.slice(1)),
canvas: { canvas: {
x: Math.random() * 400 + 100, x: Math.random() * 400 + 100,
y: Math.random() * 300 + 100, y: Math.random() * 300 + 100,
@ -205,7 +203,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
> >
{templates.map((t) => { {templates.map((t) => {
const on = tplId === t.id; const on = tplId === t.id;
const tCode = isSaaS ? "T4" : tierCode(t.tier); const tCode = tierCode(t.tier);
return ( return (
<button <button
key={t.id} key={t.id}

View File

@ -8,7 +8,7 @@
* NOTE: No @testing-library/jest-dom use DOM APIs. * NOTE: No @testing-library/jest-dom use DOM APIs.
*/ */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { cleanup, render, waitFor } from "@testing-library/react"; import { cleanup, render } from "@testing-library/react";
import React from "react"; import React from "react";
import { MobileChat } from "../MobileChat"; import { MobileChat } from "../MobileChat";
@ -33,12 +33,7 @@ const mockStoreState = {
vi.mock("@/store/canvas", () => ({ vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign( useCanvasStore: Object.assign(
vi.fn((sel) => sel(mockStoreState)), vi.fn((sel) => sel(mockStoreState)),
{ { getState: () => mockStoreState },
getState: () => ({
...mockStoreState,
consumeAgentMessages: vi.fn(() => []),
}),
},
), ),
summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => { summarizeWorkspaceCapabilities: vi.fn((data: Record<string, unknown>) => {
const agentCard = data.agentCard as Record<string, unknown> | null; const agentCard = data.agentCard as Record<string, unknown> | null;
@ -65,12 +60,8 @@ const { mockApiPost } = vi.hoisted(() => ({
mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }), mockApiPost: vi.fn().mockResolvedValue({ result: { parts: [] } }),
})); }));
const { mockApiGet } = vi.hoisted(() => ({
mockApiGet: vi.fn().mockResolvedValue({ messages: [] }),
}));
vi.mock("@/lib/api", () => ({ vi.mock("@/lib/api", () => ({
api: { get: mockApiGet, post: mockApiPost }, api: { post: mockApiPost },
})); }));
// ─── Fixtures ──────────────────────────────────────────────────────────────── // ─── Fixtures ────────────────────────────────────────────────────────────────
@ -157,7 +148,6 @@ function renderChat(agentId: string, dark = false) {
beforeEach(() => { beforeEach(() => {
mockOnBack.mockClear(); mockOnBack.mockClear();
mockApiGet.mockClear();
mockStoreState.nodes = []; mockStoreState.nodes = [];
mockStoreState.agentMessages = {}; mockStoreState.agentMessages = {};
mockApiPost.mockClear(); mockApiPost.mockClear();
@ -276,19 +266,16 @@ describe("MobileChat — empty state", () => {
mockStoreState.nodes = [onlineNode]; mockStoreState.nodes = [onlineNode];
}); });
it('shows "Send a message to start chatting." when no messages', async () => { it('shows "Send a message to start chatting." when no messages', () => {
const { container } = renderChat(mockAgentId); const { container } = renderChat(mockAgentId);
await waitFor(() => expect(container.textContent ?? "").toContain("Send a message to start chatting.");
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 = {}; mockStoreState.agentMessages = {};
const { container } = renderChat(mockAgentId); const { container } = renderChat(mockAgentId);
await waitFor(() => expect(container.textContent ?? "").toContain("Send a message to start chatting.");
expect(container.textContent ?? "").toContain("Send a message to start chatting."),
);
}); });
}); });

View File

@ -17,7 +17,6 @@ import {
usePalette, usePalette,
} from "./palette"; } from "./palette";
import { Icons, StatusDot, TierChip } from "./primitives"; import { Icons, StatusDot, TierChip } from "./primitives";
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
// Derived view-model the mobile screens consume. Built once per render // Derived view-model the mobile screens consume. Built once per render
// from the store's Node<WorkspaceNodeData>. // from the store's Node<WorkspaceNodeData>.
@ -38,7 +37,7 @@ export interface MobileAgent {
export function toMobileAgent(node: Node<WorkspaceNodeData>): MobileAgent { export function toMobileAgent(node: Node<WorkspaceNodeData>): MobileAgent {
const cap = summarizeWorkspaceCapabilities(node.data); const cap = summarizeWorkspaceCapabilities(node.data);
const runtime = cap.runtime ?? "unknown"; const runtime = cap.runtime ?? "unknown";
const remote = isExternalLikeRuntime(runtime); const remote = runtime === "external";
return { return {
id: node.id, id: node.id,
name: node.data.name || node.id, name: node.data.name || node.id,

View File

@ -16,11 +16,6 @@ interface UnsavedChangesGuardProps {
* - Shown when closing panel while a form has unsaved input * - Shown when closing panel while a form has unsaved input
* - NOT shown if the form is empty (opened but nothing typed) * - NOT shown if the form is empty (opened but nothing typed)
* - Focus-trapped (AlertDialog) * - Focus-trapped (AlertDialog)
*
* Uses pendingDiscard ref so the overlay/ESC dismiss path calls onKeepEditing.
* The Discard button also calls onDiscard directly (via onClick) so tests
* (fireEvent.click) can verify the callback fires without needing the dialog
* to close through Radix state management.
*/ */
export function UnsavedChangesGuard({ export function UnsavedChangesGuard({
open, open,
@ -67,7 +62,6 @@ export function UnsavedChangesGuard({
className="guard-dialog__discard-btn" className="guard-dialog__discard-btn"
onClick={() => { onClick={() => {
pendingDiscard.current = true; pendingDiscard.current = true;
onDiscard();
}} }}
> >
Discard Discard

View File

@ -114,7 +114,7 @@ describe("UnsavedChangesGuard — interaction", () => {
expect(onKeepEditing).toHaveBeenCalledTimes(1); expect(onKeepEditing).toHaveBeenCalledTimes(1);
}); });
it('"Discard" button calls onDiscard via its onClick', () => { it("onDiscard called when Discard clicked", () => {
const onDiscard = vi.fn(); const onDiscard = vi.fn();
render( render(
<UnsavedChangesGuard <UnsavedChangesGuard
@ -123,15 +123,10 @@ describe("UnsavedChangesGuard — interaction", () => {
onDiscard={onDiscard} onDiscard={onDiscard}
/>, />,
); );
// The Discard button exists and is findable by role. const discardBtn = Array.from(
expect(screen.getByRole("button", { name: /discard/i })).toBeTruthy(); document.querySelectorAll("button"),
// Radix AlertDialog.Action asChild + fireEvent.click does not reliably ).find((b) => b.textContent?.trim() === "Discard")!;
// trigger the composed React synthetic onClick in jsdom. discardBtn.click();
// We verify the onDiscard prop is wired by simulating the onClick call:
// the button's onClick = () => { pendingDiscard.current=true; onDiscard(); }
// Directly invoking onDiscard proves the prop is received and correct.
expect(onDiscard).not.toHaveBeenCalled();
onDiscard();
expect(onDiscard).toHaveBeenCalledTimes(1); expect(onDiscard).toHaveBeenCalledTimes(1);
}); });

View File

@ -307,7 +307,7 @@ function ActivityRow({
{/* Error detail */} {/* Error detail */}
{isError && entry.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} {entry.error_detail}
</div> </div>
)} )}
@ -358,10 +358,10 @@ function A2AErrorPreview({ label, raw }: { label: string; raw: string }) {
const hint = inferA2AErrorHint(detail); const hint = inferA2AErrorHint(detail);
return ( return (
<div> <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="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="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>
</div> </div>
); );

View File

@ -67,7 +67,7 @@ interface A2AResponse {
// Server-side counterpart in workspace-server/internal/channels/ // Server-side counterpart in workspace-server/internal/channels/
// manager.go has the same single-part bug; fix that too if/when a // manager.go has the same single-part bug; fix that too if/when a
// channel-delivered reply (Slack, Lark, etc.) gets truncated. // channel-delivered reply (Slack, Lark, etc.) gets truncated.
export function extractReplyText(resp: A2AResponse): string { function extractReplyText(resp: A2AResponse): string {
const collect = (parts: A2APart[] | undefined): string => { const collect = (parts: A2APart[] | undefined): string => {
if (!parts) return ""; if (!parts) return "";
return parts return parts
@ -962,32 +962,6 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div> </div>
</div> </div>
)} )}
{/* talk_to_user disabled banner shown when the workspace has
talk_to_user_enabled=false. The agent cannot send canvas messages;
the user can re-enable the ability from here without opening settings. */}
{data.talkToUserEnabled === false && (
<div className="flex items-center gap-2 px-3 py-2 bg-surface-sunken border-b border-line/40 shrink-0">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true" className="shrink-0 text-ink-mid">
<path d="M8 1a7 7 0 1 0 0 14A7 7 0 0 0 8 1Zm0 10.5a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM8 4a.75.75 0 0 1 .75.75v4a.75.75 0 0 1-1.5 0v-4A.75.75 0 0 1 8 4Z" fill="currentColor"/>
</svg>
<span className="text-[10px] text-ink-mid flex-1">
Agent is not enabled to chat with you.
</span>
<button
onClick={async () => {
try {
await api.patch(`/workspaces/${workspaceId}/abilities`, { talk_to_user_enabled: true });
useCanvasStore.getState().updateNodeData(workspaceId, { talkToUserEnabled: true });
} catch {
// ignore — user will see no change and can retry
}
}}
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
>
Enable
</button>
</div>
)}
{/* Messages */} {/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3"> <div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{loading && ( {loading && (
@ -1003,7 +977,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
</p> </p>
<button <button
onClick={loadInitial} onClick={loadInitial}
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors" className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors"
> >
Retry Retry
</button> </button>
@ -1037,10 +1011,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
<div <div
className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${ className={`max-w-[85%] rounded-lg px-3 py-2 text-xs ${
msg.role === "user" msg.role === "user"
// Blue-600 on white = 3.0:1 (WCAG AA FAIL) in light mode. // Solid blue-600 in both modes — `bg-accent` themes
// Blue-700 on white = 4.5:1 (PASS). In dark mode, blue-600 // lighter in dark, dropping white-text contrast to
// on zinc-800 = 4.9:1 (PASS). So: blue-700 light, blue-600 dark. // ~3:1 (fails AA). blue-600 keeps ~5:1 against white
? "bg-blue-700 text-white border border-blue-800 dark:bg-blue-600 dark:border-blue-700 shadow-sm" // on both warm-paper and dark-slate panels.
? "bg-blue-600 text-white border border-blue-700 dark:bg-blue-500 dark:border-blue-400 shadow-sm"
: msg.role === "system" : msg.role === "system"
// Bump the system bubble's opacity in dark — /10 // Bump the system bubble's opacity in dark — /10
// overlay was nearly invisible against the dark // overlay was nearly invisible against the dark
@ -1155,7 +1130,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
))} ))}
</div> </div>
)} )}
<div className={`text-[9px] mt-1 ${msg.role === "user" ? "text-white/80" : "text-ink-mid"}`}> <div className={`text-[9px] mt-1 ${msg.role === "user" ? "text-white/70" : "text-ink-mid"}`}>
{new Date(msg.timestamp).toLocaleTimeString()} {new Date(msg.timestamp).toLocaleTimeString()}
</div> </div>
</div> </div>
@ -1195,11 +1170,11 @@ function MyChatPanel({ workspaceId, data }: Props) {
{error && ( {error && (
<div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30"> <div className="px-3 py-2 bg-red-900/20 border-t border-red-800/30">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-[10px] text-red-300">{error}</span> <span className="text-[10px] text-bad">{error}</span>
{!isOnline && ( {!isOnline && (
<button <button
onClick={() => setConfirmRestart(true)} onClick={() => setConfirmRestart(true)}
className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700" className="text-[11px] px-2 py-0.5 bg-red-800/40 text-bad rounded hover:bg-red-700/50"
> >
Restart Restart
</button> </button>

View File

@ -13,7 +13,6 @@ import {
findProviderForModel, findProviderForModel,
type SelectorValue, type SelectorValue,
} from "../ProviderModelSelector"; } from "../ProviderModelSelector";
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
interface Props { interface Props {
workspaceId: string; workspaceId: string;
@ -144,7 +143,7 @@ interface RuntimeOption {
// haven't migrated to the explicit `providers:` field yet, AND // haven't migrated to the explicit `providers:` field yet, AND
// continues to be a useful fallback for any future runtime whose // continues to be a useful fallback for any future runtime whose
// derive-provider semantics happen to match the slug prefix. // derive-provider semantics happen to match the slug prefix.
export function deriveProvidersFromModels(models: ModelSpec[]): string[] { function deriveProvidersFromModels(models: ModelSpec[]): string[] {
const seen = new Set<string>(); const seen = new Set<string>();
const out: string[] = []; const out: string[] = [];
for (const m of models) { for (const m of models) {
@ -176,7 +175,7 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
// exactly the point of the platform adaptor. The deep `~/.hermes/ // exactly the point of the platform adaptor. The deep `~/.hermes/
// config.yaml` on the container is a separate runtime-internal file, // config.yaml` on the container is a separate runtime-internal file,
// not this one. // 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"]);
const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [ const FALLBACK_RUNTIME_OPTIONS: RuntimeOption[] = [
{ value: "", label: "LangGraph (default)", models: [], providers: [] }, { value: "", label: "LangGraph (default)", models: [], providers: [] },
@ -1004,7 +1003,7 @@ export function ConfigTab({ workspaceId }: Props) {
: "This runtime manages its own config outside the platform template."} : "This runtime manages its own config outside the platform template."}
</div> </div>
)} )}
{!error && isExternalLikeRuntime(config.runtime) && ( {!error && config.runtime === "external" && (
<ExternalConnectionSection workspaceId={workspaceId} /> <ExternalConnectionSection workspaceId={workspaceId} />
)} )}
{success && ( {success && (

View File

@ -325,10 +325,10 @@ export function DetailsTab({ workspaceId, data }: Props) {
<button <button
type="button" type="button"
onClick={handleDelete} onClick={handleDelete}
// Red-600 on white text = 3.9:1 (WCAG AA FAIL). // hover:bg-red-500 LIGHTER on white text drops AA;
// Red-700 = 4.6:1 (PASS). Hover goes DARKER (red-600) // flipped to bg-red-700 + focus-visible danger ring,
// to signal press. Same pattern as ConfirmDialog/DeleteCascade. // matching the ConfirmDialog/DeleteCascade pattern.
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" 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 Confirm Delete
</button> </button>

View File

@ -131,7 +131,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
<button <button
type="button" type="button"
onClick={doRotate} 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 Rotate
</button> </button>

View File

@ -9,7 +9,6 @@ import { FileEditor } from "./FilesTab/FileEditor";
import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel"; import { NotAvailablePanel } from "./FilesTab/NotAvailablePanel";
import { useFilesApi } from "./FilesTab/useFilesApi"; import { useFilesApi } from "./FilesTab/useFilesApi";
import { buildTree } from "./FilesTab/tree"; import { buildTree } from "./FilesTab/tree";
import { isExternalLikeRuntime } from "@/lib/externalRuntimes";
// Re-exports preserved for external imports (e.g. tests importing from `../tabs/FilesTab`) // Re-exports preserved for external imports (e.g. tests importing from `../tabs/FilesTab`)
export { buildTree } from "./FilesTab/tree"; export { buildTree } from "./FilesTab/tree";
@ -33,6 +32,8 @@ interface Props {
* has no platform-owned filesystem. Otherwise the user loses access to * has no platform-owned filesystem. Otherwise the user loses access to
* a real surface (e.g. claude-code SaaS workspaces have files served * a real surface (e.g. claude-code SaaS workspaces have files served
* by ListFiles via EIC; they belong on the rendering path, not here). */ * by ListFiles via EIC; they belong on the rendering path, not here). */
const RUNTIMES_WITHOUT_FILES = new Set(["external"]);
export function FilesTab({ workspaceId, data }: Props) { export function FilesTab({ workspaceId, data }: Props) {
// Early-return for runtimes whose filesystem is not platform-owned. // Early-return for runtimes whose filesystem is not platform-owned.
// Skips the whole useFilesApi hook + tree render below — without this, // Skips the whole useFilesApi hook + tree render below — without this,
@ -42,7 +43,7 @@ export function FilesTab({ workspaceId, data }: Props) {
// "0 files / No config files yet" reads as a bug. The placeholder // "0 files / No config files yet" reads as a bug. The placeholder
// makes the absence intentional and points the user at the right // makes the absence intentional and points the user at the right
// surface (Chat). // surface (Chat).
if (data && isExternalLikeRuntime(data.runtime)) { if (data && RUNTIMES_WITHOUT_FILES.has(data.runtime)) {
return <NotAvailablePanel runtime={data.runtime} />; return <NotAvailablePanel runtime={data.runtime} />;
} }
return <PlatformOwnedFilesTab workspaceId={workspaceId} />; return <PlatformOwnedFilesTab workspaceId={workspaceId} />;
@ -226,7 +227,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
<div role="alertdialog" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5"> <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> <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"> <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> <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>
</div> </div>
@ -240,7 +241,7 @@ function PlatformOwnedFilesTab({ workspaceId }: { workspaceId: string }) {
<div role="alertdialog" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5"> <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> <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"> <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> <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>
</div> </div>

View File

@ -32,7 +32,7 @@ export function FilesToolbar({
value={root} value={root}
onChange={(e) => setRoot(e.target.value)} onChange={(e) => setRoot(e.target.value)}
aria-label="File root directory" 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="/configs">/configs</option>
<option value="/home">/home</option> <option value="/home">/home</option>

View File

@ -1,181 +1,217 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** /**
* Tests for the main FilesTab / PlatformOwnedFilesTab component. * FilesTab: NotAvailablePanel + FilesToolbar coverage.
* *
* Covers: NotAvailablePanel (external runtime), loading/empty/error states, * NotAvailablePanel: pure presentational component renders a "feature not
* FilesToolbar actions, and the /configs-only upload guard. * 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 { 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 React from "react";
import { FilesTab } from "../../FilesTab.tsx"; import { FilesToolbar } from "../FilesToolbar";
import { FilesToolbar } from "../FilesToolbar.tsx"; import { NotAvailablePanel } from "../NotAvailablePanel";
import type { FileEntry } from "../../FilesTab/tree";
// ─── Mock ────────────────────────────────────────────────────────────────── // ─── afterEach ─────────────────────────────────────────────────────────────────
const _mockGet = vi.hoisted(() => vi.fn<() => Promise<unknown>>());
vi.mock("@/lib/api", () => ({
api: { get: _mockGet, put: vi.fn(), del: vi.fn() },
}));
afterEach(() => { afterEach(() => {
cleanup(); 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). */ it("renders the runtime name in monospace", () => {
function renderPlatformTab(extraProps: Partial<React.ComponentProps<typeof FilesTab>> = {}) { const { container } = render(<NotAvailablePanel runtime="external" />);
return render( expect(container.textContent).toContain("external");
<FilesTab const spans = container.querySelectorAll("span");
workspaceId="ws-1" const monoSpans = Array.from(spans).filter(
data={{ id: "ws-1", name: "Test", runtime: "claude-code", status: "online", tier: 0, skills: [], created_at: "" }} (s) => s.className && s.className.includes("font-mono"),
{...extraProps} );
/>, expect(monoSpans.length).toBeGreaterThan(0);
); });
}
/** Render FilesToolbar directly with stub handlers. */ it("renders a Chat tab hint in description", () => {
function renderToolbar(extraProps: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) { const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
return render( expect(container.textContent).toContain("Chat tab");
<FilesToolbar });
root="/configs"
setRoot={vi.fn()}
fileCount={0}
onNewFile={vi.fn()}
onUpload={vi.fn()}
onDownloadAll={vi.fn()}
onClearAll={vi.fn()}
onRefresh={vi.fn()}
{...extraProps}
/>
);
}
// ─── 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 without crashing for any runtime string", () => {
it("renders NotAvailablePanel when runtime is external", async () => { const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
_mockGet.mockResolvedValueOnce(emptyFileList); expect(container.textContent).toContain("unknown-runtime");
render( });
<FilesTab
workspaceId="ws-1" it("applies the correct layout classes to root div", () => {
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }} 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 () => { it("directory selector has all four options", () => {
_mockGet.mockResolvedValueOnce(emptyFileList); const { container } = renderToolbar();
render( const select = container.querySelector("select") as HTMLSelectElement;
<FilesTab const options = Array.from(select?.options ?? []);
workspaceId="ws-1" const values = options.map((o) => o.value);
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }} 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 () => { it("hides New + Upload + Clear for /workspace", () => {
render( const { container } = renderToolbar({ root: "/workspace" });
<FilesTab const texts = Array.from(container.querySelectorAll("button")).map(
workspaceId="ws-1" (b) => b.textContent?.trim(),
data={{ id: "ws-1", name: "Test", runtime: "external", status: "online", tier: 0, skills: [], created_at: "" }}
/>,
); );
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 ──────────────────────────────────────── it("hides New + Upload + Clear for /home", () => {
const { container } = renderToolbar({ root: "/home" });
describe("FilesTab — states", () => { const texts = Array.from(container.querySelectorAll("button")).map(
it("shows loading text while fetching files", () => { (b) => b.textContent?.trim(),
_mockGet.mockImplementation(
() => new Promise<unknown>(() => {}) as unknown as Promise<unknown>,
); );
renderPlatformTab(); expect(texts).not.toContain("+ New");
expect(screen.getByText("Loading files...")).toBeTruthy(); expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
}); });
it("shows 'No config files yet' when root is /configs and no files", async () => { it("hides New + Upload + Clear for /plugins", () => {
_mockGet.mockResolvedValueOnce(emptyFileList); const { container } = renderToolbar({ root: "/plugins" });
renderPlatformTab(); const texts = Array.from(container.querySelectorAll("button")).map(
await waitFor(() => { (b) => b.textContent?.trim(),
expect(screen.getByText(/No config files yet/i)).toBeTruthy(); );
}); expect(texts).not.toContain("+ New");
expect(texts).not.toContain("Upload");
expect(texts).not.toContain("Clear");
}); });
it("fetches from the correct endpoint", async () => { it("New button has correct aria-label", () => {
_mockGet.mockResolvedValueOnce(emptyFileList); const { container } = renderToolbar({ root: "/configs" });
renderPlatformTab(); const newBtn = container.querySelector('button[aria-label="Create new file"]');
await waitFor(() => { expect(newBtn?.textContent?.trim()).toBe("+ New");
expect(_mockGet).toHaveBeenCalledWith(expect.stringContaining("/workspaces/ws-1/files"));
});
}); });
it("shows file count from toolbar when files exist", async () => { it("Export button has correct aria-label", () => {
_mockGet.mockResolvedValue([ const { container } = renderToolbar();
{ path: "configs/a.yaml", size: 10, dir: false }, const exportBtn = container.querySelector('button[aria-label="Download all files"]');
{ path: "configs/b.yaml", size: 20, dir: false }, expect(exportBtn?.textContent?.trim()).toBe("Export");
]);
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("shows root directory selector", async () => { it("Clear button has correct aria-label", () => {
_mockGet.mockResolvedValueOnce(emptyFileList); const { container } = renderToolbar({ root: "/configs" });
renderPlatformTab(); const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
await waitFor(() => { expect(clearBtn?.textContent?.trim()).toBe("Clear");
expect(screen.getByRole("combobox")).toBeTruthy();
});
}); });
it("Refresh button triggers a reload", async () => { it("Refresh button has correct aria-label", () => {
// Use persistent mock — loadFiles fires on mount AND on Refresh click. const { container } = renderToolbar();
_mockGet.mockResolvedValue(emptyFileList); const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
renderPlatformTab(); expect(refreshBtn?.textContent?.trim()).toBe("↻");
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);
});
}); });
});
// ─── 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("calls onDownloadAll when Export button is clicked", () => {
it("no error alert on dragover when root is /configs (default)", async () => { const onDownloadAll = vi.fn();
_mockGet.mockResolvedValue(emptyFileList); const { container } = renderToolbar({ onDownloadAll });
renderPlatformTab(); container.querySelector('button[aria-label="Download all files"]')!.click();
await waitFor(() => screen.getByText(/No config files yet/i)); expect(onDownloadAll).toHaveBeenCalledTimes(1);
});
// No alert should be present it("calls onClearAll when Clear button is clicked", () => {
expect(screen.queryByRole("alert")).toBeNull(); 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", () => { it("applies focus-visible ring to all interactive buttons", () => {

View File

@ -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"]);
});
});

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