Compare commits

..

1 Commits

Author SHA1 Message Date
app-fe fadecda5e2 test(canvas/FilesTab): add NotAvailablePanel + FilesToolbar coverage (22 cases)
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 12s
Harness Replays / detect-changes (pull_request) Successful in 13s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 12s
CI / Detect changes (pull_request) Successful in 38s
gate-check-v3 / gate-check (pull_request) Successful in 21s
qa-review / approved (pull_request) Failing after 14s
E2E API Smoke Test / detect-changes (pull_request) Successful in 42s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 42s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 45s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 41s
Harness Replays / Harness Replays (pull_request) Successful in 8s
security-review / approved (pull_request) Successful in 14s
CI / Platform (Go) (pull_request) Successful in 8s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 5s
CI / Python Lint & Test (pull_request) Successful in 6s
sop-tier-check / tier-check (pull_request) Successful in 15s
audit-force-merge / audit (pull_request) Has been skipped
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 6m59s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / all-required (pull_request) Successful in 0s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7m21s
NotAvailablePanel: renders heading, runtime name in monospace, Chat hint,
SVG aria-hidden, flex layout.

FilesToolbar: directory selector options + aria-label, setRoot on change,
file count display, New/Upload/Clear visible only for /configs,
Export/Refresh always visible, aria-labels on all buttons,
onNewFile/onDownloadAll/onClearAll/onRefresh called on click,
focus-visible ring on all buttons.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 23:34:43 +00:00
28 changed files with 758 additions and 2424 deletions
+7 -7
View File
@@ -49,11 +49,11 @@ if [ "$MERGED" != "true" ]; then
exit 0
fi
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty') || true
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"') || true
TITLE=$(echo "$PR" | jq -r '.title // ""') || true
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"') || true
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty') || true
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty')
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"')
TITLE=$(echo "$PR" | jq -r '.title // ""')
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"')
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty')
if [ -z "$MERGE_SHA" ]; then
echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge."
@@ -75,7 +75,7 @@ STATUS=$(curl -sS -H "$AUTH" \
declare -A CHECK_STATE
while IFS=$'\t' read -r ctx state; do
[ -n "$ctx" ] && CHECK_STATE[$ctx]="$state"
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"') || true
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"')
# 4. For each required check, was it green at merge? YAML block scalars
# (`|`) leave a trailing newline; skip blank/whitespace-only lines.
@@ -97,7 +97,7 @@ fi
# 5. Emit structured audit event.
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .) || true
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .)
# Print as a single-line JSON so Vector's parse_json transform can pick
# it up cleanly from docker_logs.
+4 -57
View File
@@ -301,19 +301,7 @@ def expected_context(job_key: str, workflow_name: str = "ci") -> str:
# Drift detection
# --------------------------------------------------------------------------
def detect_drift(branch: str) -> tuple[list[str], dict]:
"""Returns (findings, debug). Empty findings == no drift.
Raises:
ApiError: propagated from the protection fetch only when the
failure is likely a transient Gitea outage (5xx).
403/404 from the protection endpoint is treated as
"cannot determine drift for this branch" — a token-
scope issue (missing repo-admin on DRIFT_BOT_TOKEN) or
a repo with no protection set should not turn the
hourly cron red. The workflow continues to the next
branch; no [ci-drift] issue is filed for a branch
whose protection cannot be read.
"""
"""Returns (findings, debug). Empty findings == no drift."""
findings: list[str] = []
ci_doc = load_yaml(CI_WORKFLOW_PATH)
@@ -325,50 +313,9 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
env_set = required_checks_env(audit_doc)
# Protection
# api() raises ApiError on non-2xx. Transient 5xx should fail loud.
# 403/404 means the token lacks repo-admin scope (Gitea 1.22.6's
# branch_protections endpoint requires it — see DRIFT_BOT_TOKEN
# provisioning trail in ci-required-drift.yml). Treat as
# "cannot determine drift for this branch" — skip without turning
# the workflow red. Surface a clear diagnostic so the operator
# knows what to fix.
contexts: set[str] = set()
protection_path = f"/repos/{OWNER}/{NAME}/branch_protections/{branch}"
try:
_, protection = api("GET", protection_path)
except ApiError as e:
# Isolate the HTTP status from the error message.
http_status: int | None = None
msg = str(e)
# ApiError message format: "{method} {path} → HTTP {status}: {body}"
import re as _re
m = _re.search(r"HTTP (\d{3})", msg)
if m:
http_status = int(m.group(1))
if http_status in (403, 404):
# Token lacks scope OR branch has no protection. Cannot
# determine drift — skip this branch. Do NOT exit non-zero;
# the issue IS the alarm, not a red workflow.
sys.stderr.write(
f"::error::GET {protection_path} returned HTTP {http_status}"
f"DRIFT_BOT_TOKEN lacks repo-admin scope (Gitea 1.22.6 "
f"requires it for this endpoint) OR branch has no protection "
f"configured. Cannot determine drift for {branch}; "
f"skipping. Fix: grant repo-admin to mc-drift-bot or "
f"configure protection on {branch}.\n"
)
debug = {
"branch": branch,
"ci_jobs": sorted(jobs),
"sentinel_needs": sorted(needs),
"protection_contexts_skipped": True,
"protection_http_status": http_status,
"audit_env_checks": sorted(env_set),
}
return [], debug
# 5xx — propagate (transient outage, fail loud per design).
raise
# api() raises ApiError on non-2xx; let it propagate so a transient
# 500 fails the run loudly rather than producing a "no drift" lie.
_, protection = api("GET", f"/repos/{OWNER}/{NAME}/branch_protections/{branch}")
if not isinstance(protection, dict):
sys.stderr.write(
f"::error::protection response for {branch} not a JSON object\n"
+9 -41
View File
@@ -96,27 +96,16 @@ API="https://${GITEA_HOST}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
# Sanity: token resolves to a user.
# Use || true on the jq pipeline so that set -euo pipefail (line 45) does not
# cause the script to exit prematurely when the token is empty/invalid — the
# if check below handles that case gracefully. Without || true, a 401 from an
# empty/invalid token causes jq to exit 1, triggering set -e and exiting the
# entire script before SOP_FAIL_OPEN can be evaluated (the check is in the jq-
# install block; if jq is already on PATH, that block is skipped entirely).
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""') || true
# Sanity: token resolves to a user
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
if [ -z "$WHOAMI" ]; then
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
echo "::notice::token resolves to user: $WHOAMI"
# 1. Read tier label. || true ensures set -euo pipefail does not abort the
# script if curl or jq fails (e.g. 401 from empty token).
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name') || true
# 1. Read tier label
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
TIER=""
for L in $LABELS; do
case "$L" in
@@ -187,25 +176,17 @@ fi
# 4. Resolve all team names → IDs
# /orgs/{org}/teams/{slug}/... endpoints don't exist on Gitea 1.22;
# we use /teams/{id}.
# set +e prevents set -e from aborting the script if curl fails (e.g. empty token).
ORG_TEAMS_FILE=$(mktemp)
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
set +e
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/teams")
_HTTP_EXIT=$?
set -e
debug "teams-list HTTP=$HTTP_CODE (curl exit=$_HTTP_EXIT) size=$(wc -c <"$ORG_TEAMS_FILE")"
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] teams-list body (first 300 chars):" >&2
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
fi
if [ "$_HTTP_EXIT" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /orgs/${OWNER}/teams failed (curl exit=$_HTTP_EXIT HTTP=$HTTP_CODE) — token may lack read:org scope or be invalid."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope."
exit 1
fi
@@ -250,22 +231,9 @@ for _t in $_all_teams; do
debug "team-id: $_t$_id"
done
# 5. Read approving reviewers. set +e disables set -e temporarily so that curl
# failures (e.g. empty/invalid token → HTTP 401) do not abort the script before
# SOP_FAIL_OPEN is evaluated. set -e is restored immediately after.
set +e
# 5. Read approving reviewers
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
_REVIEWS_EXIT=$?
set -e
if [ $_REVIEWS_EXIT -ne 0 ] || [ -z "$REVIEWS" ]; then
echo "::error::Failed to fetch reviews (curl exit=$_REVIEWS_EXIT) — token may be invalid or unreachable."
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
exit 0
fi
exit 1
fi
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]') || true
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
if [ -z "$APPROVERS" ]; then
echo "::error::No approving reviews on this PR. Set SOP_DEBUG=1 and re-run for diagnostics."
exit 1
+20 -194
View File
@@ -19,39 +19,18 @@ What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
downstream — Gitea uses ` / ` as the workflow/job separator).
Classify each by whether `on:` contains a `push:` trigger.
2. List the last N (=30, rev3 — widened from 10) commits on
WATCH_BRANCH via GET /repos/{o}/{r}/commits?sha={branch}&limit={N}.
rev2 sweeps N commits per tick instead of HEAD only — schedule
workflows post `failure` to whatever SHA was HEAD when they
COMPLETED, so by the next */5 tick main has often moved forward
and the red gets stranded on a stale commit. rev3 widens the
window from 10 → 30 because schedule workflows post `failure`
RETROACTIVELY (5-15 min after their merge); a 10-commit window
is narrower than the merge-cadence during a burst, so reds land
OUTSIDE the window before reaper sees them (Phase 1+2 evidence:
rev2 run 17057 at 02:46Z saw 185/0 contexts on 10 SHAs; direct
probe ~30min later showed ~25 fails on those same 10 SHAs).
2. GET combined status for HEAD of WATCH_BRANCH.
3. For EACH SHA in the list:
- GET combined commit status. Per-SHA error isolation
(refinement #7): if this call raises ApiError or any 5xx,
LOG `::warning::` + continue to the next SHA. Different from
the single-HEAD pre-rev2 path where fail-loud was correct;
the sweep is best-effort across historical commits, so one
transient blip on a stale SHA must not strand reds on the
OTHER stale SHAs.
- If combined.state == "success": skip — cost optimization
(refinement #2), common case (most commits are green).
- Otherwise iterate per-context entries. For each entry where:
state == "failure" AND context.endswith(" (push)")
Parse context as `<workflow_name> / <job_name> (push)`.
Look up workflow_name in the trigger map:
- missing → log ::notice:: and skip (conservative).
- has_push_trigger=True → preserve (real defect signal).
- has_push_trigger=False → POST a compensating
`state=success` status to /statuses/{sha} with the same
context (Gitea de-dups by context) and a description
documenting the workaround + this script's path.
3. For each per-context status entry where:
state == "failure" AND context.endswith(" (push)")
Parse context as `<workflow_name> / <job_name> (push)`. Look up
workflow_name in the trigger map:
- missing → log ::notice:: and skip (conservative).
- has_push_trigger=True → preserve (would mask real signal).
- has_push_trigger=False → POST a compensating
`state=success` status to /statuses/{sha} with the same
context (Gitea de-dups by context) and a description that
documents the workaround + this script's path.
4. Exit 0. Re-running is idempotent — Gitea's commit-status table
stores the LATEST state-per-context, so the success POST sticks
@@ -422,29 +401,21 @@ def reap(
sha: str,
*,
dry_run: bool = False,
) -> dict[str, Any]:
) -> dict[str, int]:
"""Walk `combined.statuses[]` and compensate where appropriate.
Per-SHA worker. The multi-SHA orchestrator (`reap_branch`) calls
this once per stale main commit each tick.
Returns counters for observability:
{compensated, preserved_real_push, preserved_unknown,
preserved_non_failure, preserved_non_push_suffix,
preserved_unparseable,
compensated_contexts: [<context>, ...]}
`compensated_contexts` is rev2-added so `reap_branch` can build
`compensated_per_sha` without re-deriving it from the POST stream.
preserved_unparseable}
"""
counters: dict[str, Any] = {
counters = {
"compensated": 0,
"preserved_real_push": 0,
"preserved_unknown": 0,
"preserved_non_failure": 0,
"preserved_non_push_suffix": 0,
"preserved_unparseable": 0,
"compensated_contexts": [],
}
statuses = combined.get("statuses") or []
@@ -493,146 +464,10 @@ def reap(
sha, context, s.get("target_url"), dry_run=dry_run
)
counters["compensated"] += 1
counters["compensated_contexts"].append(context)
return counters
# --------------------------------------------------------------------------
# rev2: multi-SHA sweep over the last N commits on WATCH_BRANCH
# --------------------------------------------------------------------------
# How many main commits to sweep per tick. Sized to cover a burst-merge
# window where multiple PRs land in the 5-min interval between reaper
# ticks. Older reds falling off the window is acceptable — they were
# already stale enough that the schedule-run that posted them has long
# since been overwritten by a real push trigger. See `reference_post_
# suspension_pipeline` for the merge-cadence baseline.
#
# rev3 (2026-05-12, hongming-pc2 GO 03:25Z): widened from 10 → 30.
# rev2 (limit=10) shipped 01:48Z and ran 6/6 ticks post-merge with
# `compensated:0` despite ~25 stranded reds visible on those same 10
# SHAs ~30min later. Root cause: schedule workflows post `failure`
# RETROACTIVELY 5-15 min after their merge, so by the time reaper's
# next */5 tick lands, the stranded red is on a SHA that has already
# fallen out of a 10-commit window during a burst-merge period.
# Trades window-width-cheap for cadence-loady (per hongming-pc2):
# kept `*/5` cron unchanged; only the window-N is widened.
DEFAULT_SWEEP_LIMIT = 30
def list_recent_commit_shas(branch: str, limit: int) -> list[str]:
"""List the most recent `limit` commit SHAs on `branch`, newest
first.
Wraps GET /repos/{o}/{r}/commits?sha={branch}&limit={limit}. Gitea
1.22.6 returns a JSON list of commit objects each with a `sha` key
(verified via vendor-truth probe 2026-05-11 against
git.moleculesai.app — `feedback_smoke_test_vendor_truth_not_shape_match`).
Raises ApiError on non-2xx OR on unexpected response shape. This is
a HARD halt — without the commit list the sweep can't proceed. (The
per-SHA error isolation downstream is a different concern: tolerating
a transient 5xx on ONE commit's status is best-effort; losing the
commit list itself means we don't even know which commits to try.)
"""
_, body = api(
"GET",
f"/repos/{OWNER}/{NAME}/commits",
query={"sha": branch, "limit": str(limit)},
)
if not isinstance(body, list):
raise ApiError(
f"commits listing for {branch} not a JSON array "
f"(got {type(body).__name__})"
)
shas: list[str] = []
for entry in body:
if not isinstance(entry, dict):
continue
sha = entry.get("sha")
if isinstance(sha, str) and len(sha) >= 7:
shas.append(sha)
if not shas:
raise ApiError(
f"commits listing for {branch} returned no usable SHAs"
)
return shas
def reap_branch(
workflow_trigger_map: dict[str, bool],
branch: str,
*,
limit: int = DEFAULT_SWEEP_LIMIT,
dry_run: bool = False,
) -> dict[str, Any]:
"""Sweep the last `limit` commits on `branch`, applying `reap()`
to each (with per-SHA error isolation).
Returns aggregated counters PLUS rev2 observability fields:
- scanned_shas: how many SHAs we actually iterated
- compensated_per_sha: {<sha_full>: [<context>, ...]} — only
SHAs that actually got at least one compensation are included
"""
shas = list_recent_commit_shas(branch, limit)
aggregate: dict[str, Any] = {
"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_per_sha": {},
}
for sha in shas:
aggregate["scanned_shas"] += 1
# Per-SHA error isolation (refinement #7). One transient blip
# on a historical commit must NOT abort the whole tick — the
# OTHER stale SHAs may still hold strandable reds.
try:
combined = get_combined_status(sha)
except ApiError as e:
print(
f"::warning::get_combined_status({sha[:10]}) failed; "
f"skipping this SHA: {e}"
)
continue
# Cost optimization (refinement #2): the common case is a green
# commit. Skip the per-context loop entirely when combined is
# already success — saves a tight loop over ~20 statuses per SHA
# on green commits, the dominant majority.
if combined.get("state") == "success":
continue
per_sha = reap(
workflow_trigger_map, combined, sha, dry_run=dry_run
)
# Aggregate scalar counters.
for key in (
"compensated",
"preserved_real_push",
"preserved_unknown",
"preserved_non_failure",
"preserved_non_push_suffix",
"preserved_unparseable",
):
aggregate[key] += per_sha[key]
# Record per-SHA compensated contexts (only when non-empty —
# keep the summary readable when most SHAs are no-ops).
contexts = per_sha.get("compensated_contexts") or []
if contexts:
aggregate["compensated_per_sha"][sha] = list(contexts)
return aggregate
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
@@ -640,15 +475,6 @@ def main() -> int:
action="store_true",
help="Skip the compensating POST; print what would be done.",
)
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_SWEEP_LIMIT,
help=(
"How many recent commits on WATCH_BRANCH to sweep per tick "
f"(default: {DEFAULT_SWEEP_LIMIT})."
),
)
args = parser.parse_args()
_require_runtime_env()
@@ -660,11 +486,11 @@ def main() -> int:
f"class-O candidates={sum(1 for v in workflow_trigger_map.values() if not v)}"
)
counters = reap_branch(
workflow_trigger_map,
WATCH_BRANCH,
limit=args.limit,
dry_run=args.dry_run,
sha = get_head_sha(WATCH_BRANCH)
combined = get_combined_status(sha)
counters = reap(
workflow_trigger_map, combined, sha, dry_run=args.dry_run
)
# Observability: print one JSON line summarising the tick. Loki
@@ -673,9 +499,9 @@ def main() -> int:
"status-reaper summary: "
+ json.dumps(
{
"sha": sha,
"branch": WATCH_BRANCH,
"dry_run": args.dry_run,
"limit": args.limit,
**counters,
},
sort_keys=True,
+1 -2
View File
@@ -317,8 +317,7 @@ JQ_FILTER='.[]
T12_INPUT='[{"state":"APPROVED","dismissed":false,"user":{"login":"core-devops"}},{"state":"CHANGES_REQUESTED","dismissed":false,"user":{"login":"bob"}},{"state":"APPROVED","dismissed":false,"user":{"login":"alice"}},{"state":"APPROVED","dismissed":true,"user":{"login":"carol"}}]'
JQ_CMD=$(command -v jq 2>/dev/null || echo /tmp/jq)
T12_CANDIDATES=$(echo "$T12_INPUT" | "$JQ_CMD" -r "$JQ_FILTER" 2>/dev/null | sort -u)
T12_CANDIDATES=$(echo "$T12_INPUT" | /tmp/jq -r "$JQ_FILTER" 2>/dev/null | sort -u)
assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-devops" "$T12_CANDIDATES"
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
+5 -5
View File
@@ -23,11 +23,11 @@
# `feedback_behavior_based_ast_gates` — NOT grep-by-name. That way
# job renames or matrix-expansion-induced churn produce honest signal.
#
# NOTE on protection endpoint scope: `GET /repos/.../branch_protections/{branch}`
# requires repo-admin role in Gitea 1.22.6. If DRIFT_BOT_TOKEN lacks it,
# the script skips that branch with a clear ::error:: diagnostic and exits 0
# (the issue IS the alarm, not a red workflow). See provisioning trail in
# the run step's GITEA_TOKEN env comment.
# IMPORTANT — TRANSITIONAL STATE: molecule-core's ci.yml does NOT yet
# contain the `all-required` sentinel job (RFC §4 Phase 4 adds it).
# Until Phase 4 lands the detector will hard-fail with exit 3 on the
# missing sentinel. That's intentional: a red workflow on a 5-min cron
# is louder than a silent issue and forces Phase 4 to land soon.
name: ci-required-drift
-15
View File
@@ -148,21 +148,6 @@ jobs:
- if: needs.changes.outputs.platform == 'true'
name: Run golangci-lint
run: golangci-lint run --timeout 3m ./... || true
- if: needs.changes.outputs.platform == 'true'
name: Diagnostic — per-package verbose 60s
run: |
set +e
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
handlers_exit=$?
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
pu_exit=$?
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
tail -100 /tmp/test-handlers.log
echo "::endgroup::"
echo "::group::pendinguploads exit=$pu_exit (last 100 lines)"
tail -100 /tmp/test-pu.log
echo "::endgroup::"
continue-on-error: true
- if: needs.changes.outputs.platform == 'true'
name: Run tests with race detection and coverage
run: go test -race -coverprofile=coverage.out ./...
+1 -5
View File
@@ -71,12 +71,8 @@ jobs:
run: |
set -euo pipefail
# Fetch all open PRs and run gate-check on each
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
# gate_check.py uses timeout=15 on every urlopen call; this catches the
# inline Python polling loop too (issue #603).
pr_numbers=$(python3 -c "
import socket, urllib.request, json, os
socket.setdefaulttimeout(15)
import urllib.request, json, os
token = os.environ['GITEA_TOKEN']
req = urllib.request.Request(
'https://git.moleculesai.app/api/v1/repos/${{ github.repository }}/pulls?state=open&limit=100',
+1 -11
View File
@@ -37,11 +37,6 @@ name: main-red-watchdog
# "unknown on type" when `workflow_dispatch.inputs.X` is present. Revisit
# when Gitea ≥ 1.23 is fleet-wide.
on:
# SCHEDULE RE-ENABLED 2026-05-12 rev3 — interim disable (mc#645) reverted alongside
# status-reaper rev3 (widen-window). Job-level timeout-minutes raised 5 → 15 below
# to absorb runner-saturation latency without spurious cancels (the original cascade
# cause). If runner-saturation root persists, the dedicated-runner-label split
# remains the structural next step (tracked separately).
schedule:
# Hourly at :05 — task spec calls for "off-zero" (`5 * * * *`),
# offset from :17 (ci-required-drift) and :00 (peak cron load).
@@ -63,12 +58,7 @@ concurrency:
jobs:
watchdog:
runs-on: ubuntu-latest
# rev3 (2026-05-12, mc#645 revert): raised 5 → 15 to absorb runner-saturation
# latency. Original 5min cap was producing 124-style cancels under load,
# which fed the very `[main-red]` issues this workflow files (self-poisoning).
# 15min is still well below Gitea-default 6h job ceiling; if a real hang
# occurs the issue-file path is still the alarm surface.
timeout-minutes: 15
timeout-minutes: 5
steps:
- name: Check out repo (script lives at .gitea/scripts/)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+5 -7
View File
@@ -54,13 +54,11 @@ env:
jobs:
build-and-push:
name: Build & push canvas image
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
# See issue #576 + infra-lead pulse ~00:30Z.
runs-on: ubuntu-latest
# NOTE: infra-sre must register a `docker` label on every act-runner that
# mounts /var/run/docker.sock (group=docker, socket perms 660+). Jobs without
# the `docker` label land on runners that lack the socket and fail here.
# See issue #576.
runs-on: [ubuntu-latest, docker]
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
continue-on-error: true
steps:
@@ -52,13 +52,12 @@ env:
jobs:
build-and-push:
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
# See issue #576 + infra-lead pulse ~00:30Z.
runs-on: ubuntu-latest
# NOTE: infra-sre must register a `docker` label on every act-runner that
# mounts /var/run/docker.sock (group=docker, socket perms 660+). Jobs without
# the `docker` label land on runners that lack the socket and fail here.
# molecule-runner-1 (no socket) vs molecule-runner-4 (socket) — coin-flip
# without this label gate. See issue #576.
runs-on: [ubuntu-latest, docker]
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-70
View File
@@ -1,70 +0,0 @@
name: review-check-tests
# Runs review-check.sh regression tests on every PR + push that touches
# the evaluator script or its test fixtures.
#
# Follows RFC#324 follow-up (issue #540):
# .gitea/scripts/review-check.sh is load-bearing for PR merge gates.
# It has ZERO production CI coverage. This workflow closes that gap.
#
# Design choices:
# - Bash test harness (not bats). The existing test_review_check.sh
# uses a custom assert_eq/assert_contains framework that is already
# working and covers all 13 acceptance criteria (issue #540 §Acceptance).
# Converting to bats would be refactoring, not closing the gap.
# - No bats dependency: the runner-base image needs no extra tooling.
# - continue-on-error: false — these tests must pass; a failure means
# the review-gate evaluator is broken and must not be merged.
on:
push:
branches: [main, staging]
paths:
- '.gitea/scripts/review-check.sh'
- '.gitea/scripts/tests/test_review_check.sh'
- '.gitea/scripts/tests/_review_check_fixture.py'
- '.gitea/workflows/review-check-tests.yml'
pull_request:
branches: [main, staging]
paths:
- '.gitea/scripts/review-check.sh'
- '.gitea/scripts/tests/test_review_check.sh'
- '.gitea/scripts/tests/_review_check_fixture.py'
- '.gitea/workflows/review-check-tests.yml'
workflow_dispatch:
env:
GITHUB_SERVER_URL: https://git.moleculesai.app
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: review-check.sh regression tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install jq
# Required for T12 jq-filter test case. Gitea Actions runners (ubuntu-latest
# label) do not bundle jq. Install via apt-get first (reliable for Ubuntu
# runners with internet access to package mirrors). Falls back to GitHub
# binary download. GitHub releases may be blocked on some runner networks
# (infra#241 follow-up).
continue-on-error: true
run: |
if apt-get update -qq && apt-get install -y -qq jq; then
echo "::notice::jq installed via apt-get: $(jq --version)"
elif timeout 120 curl -sSL \
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
-o /usr/local/bin/jq && chmod +x /usr/local/bin/jq; then
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
else
echo "::warning::jq install failed — apt-get and GitHub download both failed."
fi
jq --version 2>/dev/null || echo "::notice::jq not yet available — continuing"
- name: Run review-check.sh regression suite
run: bash .gitea/scripts/tests/test_review_check.sh
+6 -13
View File
@@ -53,18 +53,12 @@ name: status-reaper
# `inputs:` block here. Gitea 1.22.6 rejects the whole workflow as
# "unknown on type" when `workflow_dispatch.inputs.X` is present.
on:
# SCHEDULE RE-ENABLED 2026-05-12 rev3 — interim disable (mc#645) reverted now that
# rev3 widens DEFAULT_SWEEP_LIMIT 10 → 30 (covers retroactive-failure timing window).
# Sibling watchdog re-enabled in the same PR with timeout-minutes raised 5 → 15.
schedule:
# Every 5 minutes. Off-zero alignment with sibling cron workflows:
# ci-required-drift (`:17`), main-red-watchdog (`:05`),
# railway-pin-audit (`:23`). 5-min cadence gives a tight enough
# close on schedule-triggered false-reds that main-red-watchdog
# (hourly :05) almost never files an issue on the false case.
# rev3 keeps `*/5` unchanged per hongming-pc2 03:25Z review:
# "trades window-width-cheap for cadence-loady" — N=30 widens
# the lookback cheaply without doubling runner load via `*/2`.
- cron: '*/5 * * * *'
workflow_dispatch:
@@ -73,13 +67,12 @@ on:
permissions:
contents: read
# NOTE: NO `concurrency:` block is intentional.
# Gitea 1.22.6 doesn't honor `cancel-in-progress: false`: queued ticks
# of the same group get cancelled-with-started=0 instead of waiting
# (DB-verified 2026-05-12, runs 16053/16085 of status-reaper.yml).
# The reaper's POST /statuses/{sha} is idempotent — Gitea de-dups by
# context — so concurrent ticks are safe; accept them rather than
# serialise via the broken mechanism.
# Single-flight: two reaper ticks racing would POST duplicate
# compensations. Idempotent at the API (Gitea overwrites by context
# on POST /statuses/{sha}) but cleaner to serialise.
concurrency:
group: status-reaper
cancel-in-progress: false
jobs:
reap:
-120
View File
@@ -1,120 +0,0 @@
name: Weekly Platform-Go Surface
# Surface latent vet/test errors on main by running the full Platform-Go
# suite on a weekly cron regardless of whether the last push touched
# workspace-server/.
#
# Background: ci.yml's `platform-build` job gates real work on
# `if: needs.changes.outputs.platform == 'true'`. When no push touches
# workspace-server/, the skip fires and the suite never executes on main.
# Latent vet errors and test flakes can sit for weeks undetected.
#
# This workflow runs the full suite (build, vet, golangci-lint, tests with
# coverage) every Monday at 04:17 UTC. Results are posted as commit statuses
# but continue-on-error: true means they never block anything — they're
# purely a noise-reduction signal for when the next workspace-server push
# lands and would otherwise trigger the first real suite run.
#
# Why 04:17 UTC on Monday: off-peak, before the weekly sprint cycle starts.
on:
schedule:
- cron: '17 4 * * 1' # Mondays at 04:17 UTC
workflow_dispatch:
permissions:
contents: read
statuses: write
jobs:
weekly-platform-go:
name: Weekly Platform-Go Surface
runs-on: ubuntu-latest
# continue-on-error: surface only, never block
continue-on-error: true
defaults:
run:
working-directory: workspace-server
steps:
- name: Checkout main
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
fetch-depth: 1
- name: Set up Go
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
with:
go-version: stable
- name: Go mod download
run: go mod download
- name: Build
run: go build ./cmd/server
# `go vet` is NOT `|| true`-guarded: surfacing latent vet errors on main is
# the whole point of this workflow (issue #567 — the motivating case was a
# `go vet` error in org_external.go that sat undetected on main for weeks).
# A vet error here fails the step → fails the job → shows red on the weekly
# commit. Per Gitea quirk #10 (job-level continue-on-error is ignored), that
# red surfaces on main — which is the intended signal, not a regression.
- name: go vet
run: go vet ./...
# golangci-lint stays `|| true`-guarded: lint is noisier (more false-
# positives than vet) and golangci-lint may not be pre-installed on every
# runner image — a `|| true` here keeps a missing-binary or lint-noise case
# from masking the vet/test signal above. Tighten to match ci.yml's lint
# gate if/when ci.yml's lint step becomes hard-failing.
- name: golangci-lint
run: golangci-lint run --timeout 3m ./... || true
- name: Tests with race detection + coverage
run: go test -race -coverprofile=coverage.out ./...
- name: Check coverage thresholds
run: |
set -e
TOTAL_FLOOR=25
CRITICAL_PATHS=(
"internal/handlers/tokens"
"internal/handlers/workspace_provision"
"internal/handlers/a2a_proxy"
"internal/handlers/registry"
"internal/handlers/secrets"
"internal/middleware/wsauth"
"internal/crypto"
)
TOTAL=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $3}' | sed 's/%//')
echo "Total coverage: ${TOTAL}%"
if awk "BEGIN{exit !(\$TOTAL < \$TOTAL_FLOOR)}"; then
echo "::error::Total coverage \${TOTAL}% is below the \${TOTAL_FLOOR}% floor."
exit 1
fi
ALLOWLIST=""
if [ -f ../.coverage-allowlist.txt ]; then
ALLOWLIST=$(grep -vE '^(#|[[:space:]]*$)' ../.coverage-allowlist.txt || true)
fi
FAILED=0
for path in "\${CRITICAL_PATHS[@]}"; do
while read -r file pct; do
[[ "$file" == *_test.go ]] && continue
[[ "$file" == *"$path"* ]] || continue
awk "BEGIN{exit !(\$pct < 10)}" || continue
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
continue
fi
echo "::error::Low coverage \${pct}% on \${rel} (below 10% in critical path \${path})"
FAILED=$((FAILED + 1))
done < <(go tool cover -func=coverage.out | grep -v '^total:' | awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++} END {for (f in s) printf "%s %.1f\n", f, s[f]/c[f]}' | sort)
done
if [ "$FAILED" -gt 0 ]; then
echo "::error::\${FAILED} critical paths below 10% coverage — see above."
exit 1
fi
echo "Coverage thresholds: OK"
-10
View File
@@ -156,16 +156,6 @@ and run CI manually.
| python-lint | pytest with coverage |
| e2e-api | Full API test suite (62 tests) |
| shellcheck | Shell script linting |
| review-check-tests | `review-check.sh` evaluator regression suite (13 scenarios) |
| ops-scripts | Python unittest suite for `scripts/*.py` |
## Local Testing
### review-check.sh
```bash
bash .gitea/scripts/tests/test_review_check.sh
```
Runs the full regression suite against a fixture HTTP server. No network access required.
## Code Style
+1 -1
View File
@@ -402,7 +402,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
);
}
export function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
if (!card) return [];
const skills = card.skills;
if (!Array.isArray(skills)) return [];
+1 -1
View File
@@ -647,7 +647,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
);
}
export function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
if (!agentCard) return [];
const rawSkills = agentCard.skills;
if (!Array.isArray(rawSkills)) return [];
@@ -1,330 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
import React from "react";
import { BudgetSection } from "../BudgetSection";
import { api } from "@/lib/api";
// Queue-based mock for the api module. Each api call shifts from the queue.
// Tests push with qGet/qPatch and the module-level mockImplementation
// reads from the queue.
type QueueEntry = { body?: unknown; err?: Error };
const apiQueue: QueueEntry[] = [];
vi.mock("@/lib/api", () => ({
api: {
get: vi.fn(async (path: string) => {
const next = apiQueue.shift();
if (!next) throw new Error(`api.get queue exhausted at: ${path}`);
if (next.err) throw next.err;
return next.body;
}),
patch: vi.fn(async (path: string, _body?: unknown) => {
const next = apiQueue.shift();
if (!next) throw new Error(`api.patch queue exhausted at: ${path}`);
if (next.err) throw next.err;
return next.body;
}),
},
}));
afterEach(cleanup);
beforeEach(() => {
apiQueue.length = 0;
vi.clearAllMocks();
});
const WS_ID = "budget-test-ws";
function qGet(body: unknown) {
apiQueue.push({ body });
}
function qGetErr(status: number, msg: string) {
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
}
function qPatch(body: unknown) {
apiQueue.push({ body });
}
function qPatchErr(status: number, msg: string) {
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
}
function makeBudget(overrides: Partial<{
budget_limit: number | null;
budget_used: number;
budget_remaining: number | null;
}> = {}) {
return {
budget_limit: 10_000,
budget_used: 3_500,
budget_remaining: 6_500,
...overrides,
};
}
describe("BudgetSection", () => {
describe("loading state", () => {
it("shows loading indicator while fetching", async () => {
let resolveGet: (v: unknown) => void;
vi.mocked(api.get).mockImplementationOnce(
async () => new Promise((r) => { resolveGet = r as (v: unknown) => void; }),
);
render(<BudgetSection workspaceId={WS_ID} />);
expect(screen.getByTestId("budget-loading")).toBeTruthy();
// Resolve after render to verify state clears
resolveGet!(makeBudget());
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-loading")).toBeNull();
});
});
});
describe("fetch error state", () => {
it("shows error message on non-402 fetch failure", async () => {
qGetErr(500, "Internal Server Error");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
});
expect(screen.getByTestId("budget-fetch-error")!.textContent).toContain("500");
});
it("shows 402 as exceeded banner, not fetch error", async () => {
// 402 means the budget limit was hit — different UX from a network/API error.
qGetErr(402, "Payment Required");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
expect(screen.queryByTestId("budget-fetch-error")).toBeNull();
});
});
describe("budget loaded — display", () => {
it("renders used / limit stats row", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-used-value")!.textContent).toBe("3,500");
});
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
});
it("renders 'Unlimited' when budget_limit is null", async () => {
qGet(makeBudget({ budget_limit: null, budget_used: 1_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("Unlimited");
});
});
it("renders remaining credits when present", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: 6_500 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("6,500");
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("credits remaining");
});
});
it("omits remaining credits when budget_remaining is null", async () => {
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-remaining")).toBeNull();
});
});
it("caps progress bar at 100% when used > limit", async () => {
// Over-limit: 12000 used of 10000 limit should show 100%, not 120%.
qGet(makeBudget({ budget_limit: 10_000, budget_used: 12_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
const fill = screen.getByTestId("budget-progress-fill");
expect(fill.getAttribute("style")).toContain("100%");
});
});
it("omits progress bar when budget_limit is null (unlimited)", async () => {
qGet(makeBudget({ budget_limit: null, budget_used: 5_000, budget_remaining: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-progress-fill")).toBeNull();
});
});
});
describe("budget exceeded (402)", () => {
it("shows exceeded banner when load returns 402", async () => {
qGetErr(402, "Payment Required");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
expect(screen.getByTestId("budget-exceeded-banner")!.textContent).toContain("Budget exceeded");
});
});
it("clears exceeded banner after successful save", async () => {
qGetErr(402, "Payment Required");
qPatch(makeBudget({ budget_limit: 50_000, budget_used: 0, budget_remaining: 50_000 }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
const input = screen.getByTestId("budget-limit-input");
fireEvent.change(input, { target: { value: "50000" } });
const saveBtn = screen.getByTestId("budget-save-btn");
fireEvent.click(saveBtn);
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
});
});
});
describe("save flow", () => {
it("shows save error on non-402 patch failure", async () => {
qGet(makeBudget());
qPatchErr(500, "Internal Server Error");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
const saveBtn = screen.getByTestId("budget-save-btn");
fireEvent.click(saveBtn);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
expect(screen.getByTestId("budget-save-error")!.textContent).toContain("500");
});
});
it("updates input to new limit value after successful save", async () => {
qGet(makeBudget({ budget_limit: 10_000 }));
qPatch(makeBudget({ budget_limit: 20_000 }));
render(<BudgetSection workspaceId={WS_ID} />);
// Wait for the input to appear (loading → loaded)
await vi.waitFor(() => {
expect(screen.queryByTestId("budget-loading")).toBeNull();
});
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
// Debug: check what values are rendered
const limitValue = screen.getByTestId("budget-limit-value")?.textContent;
expect(input.value).toBe("10000"); // initial value from API
expect(limitValue).toBe("10,000");
fireEvent.change(input, { target: { value: "20000" } });
expect(input.value).toBe("20000");
fireEvent.click(screen.getByTestId("budget-save-btn"));
await vi.waitFor(() => {
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("20000");
});
});
it("sends null when input is cleared (unlimited)", async () => {
qGet(makeBudget({ budget_limit: 10_000 }));
qPatch(makeBudget({ budget_limit: null }));
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByTestId("budget-save-btn"));
await vi.waitFor(() => {
// After save with null limit, input should show empty (unlimited)
expect(input.value).toBe("");
});
});
it("shows saving state on button while patch is in flight", async () => {
qGet(makeBudget());
let resolvePatch: (v: unknown) => void;
vi.mocked(api.patch).mockImplementationOnce(
async () => new Promise((r) => { resolvePatch = r as (v: unknown) => void; }),
);
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "50000" } });
fireEvent.click(screen.getByTestId("budget-save-btn"));
const btn = screen.getByTestId("budget-save-btn");
expect(btn.textContent).toContain("Saving");
resolvePatch!(makeBudget({ budget_limit: 50_000 }));
await vi.waitFor(() => {
expect(btn.textContent).toContain("Save");
});
});
});
describe("isApiError402 — regression coverage", () => {
it("classifies ': 402' with space as 402", async () => {
qGetErr(402, "Payment Required");
qPatch(makeBudget());
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
});
});
it("classifies non-402 error messages as regular fetch errors", async () => {
qGetErr(503, "Service Unavailable");
render(<BudgetSection workspaceId={WS_ID} />);
await vi.waitFor(() => {
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
});
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
});
});
});
File diff suppressed because it is too large Load Diff
@@ -1,140 +0,0 @@
// @vitest-environment jsdom
/**
* Unit tests for extractSkills — pure helper from SkillsTab.
*
* Covers: null card, non-array skills, empty skills, full skill entries
* (id, name, description, tags, examples), id-only fallback, name-only
* fallback, string coercion, array coercion for tags/examples,
* filtering entries with no id after coercion, empty string id (filtered).
*/
import { describe, it, expect } from "vitest";
import { extractSkills } from "../SkillsTab";
describe("extractSkills", () => {
it("returns [] for null card", () => {
expect(extractSkills(null)).toEqual([]);
});
it("returns [] when card.skills is not an array", () => {
expect(extractSkills({ skills: undefined })).toEqual([]);
expect(extractSkills({ skills: "not-an-array" })).toEqual([]);
expect(extractSkills({ skills: { id: "x" } })).toEqual([]);
});
it("returns [] for empty skills array", () => {
expect(extractSkills({ skills: [] })).toEqual([]);
});
it("maps a fully-populated skill entry", () => {
const card = {
skills: [
{
id: "code_search",
name: "Code Search",
description: "Semantic code search",
tags: ["search", "code"],
examples: ["Find unused exports", "Search by AST pattern"],
},
],
};
expect(extractSkills(card)).toEqual([
{
id: "code_search",
name: "Code Search",
description: "Semantic code search",
tags: ["search", "code"],
examples: ["Find unused exports", "Search by AST pattern"],
},
]);
});
it("uses name as id when id is absent", () => {
const card = { skills: [{ name: "web_scraper" }] };
expect(extractSkills(card)).toEqual([
{ id: "web_scraper", name: "web_scraper", description: "", tags: [], examples: [] },
]);
});
it("uses id as name when name is absent", () => {
const card = { skills: [{ id: "legacy_skill" }] };
expect(extractSkills(card)).toEqual([
{ id: "legacy_skill", name: "legacy_skill", description: "", tags: [], examples: [] },
]);
});
it("filters out entries with neither id nor name", () => {
// id: String(undefined || undefined || "") → "" → filtered (id.length = 0)
const card = { skills: [{ description: "orphan entry" }] };
expect(extractSkills(card)).toEqual([]);
});
it("filters out entries with no id after string coercion", () => {
// id resolves to "" after String(undefined || null || {})
const card = { skills: [{ id: null, name: null }] };
expect(extractSkills(card)).toEqual([]);
});
it("filters out entries with empty-string id", () => {
const card = { skills: [{ id: "", name: "" }] };
expect(extractSkills(card)).toEqual([]);
});
it("coerces numeric tags to strings", () => {
const card = { skills: [{ id: "x", tags: [1, "two", 3] }] };
expect(extractSkills(card)).toEqual([
{ id: "x", name: "x", description: "", tags: ["1", "two", "3"], examples: [] },
]);
});
it("coerces non-array tags to empty array", () => {
const card = { skills: [{ id: "x", tags: "not-an-array" }] };
expect(extractSkills(card)).toEqual([
{ id: "x", name: "x", description: "", tags: [], examples: [] },
]);
});
it("coerces non-array examples to empty array", () => {
const card = { skills: [{ id: "x", examples: 42 }] };
expect(extractSkills(card)).toEqual([
{ id: "x", name: "x", description: "", tags: [], examples: [] },
]);
});
// NOTE: extractSkills uses `String(skill.description || "")` — falsy values
// (0, null, false) fall through to "", NOT to their string form.
it("returns '' for falsy description values (0, null, false)", () => {
const card = { skills: [{ id: "x", description: 0 }] };
expect(extractSkills(card)).toEqual([
{ id: "x", name: "x", description: "", tags: [], examples: [] },
]);
});
it("handles mixed valid/invalid entries", () => {
const card = {
skills: [
{ id: "valid_one", name: "One" },
{ name: "named_only" },
{ description: "orphan" }, // filtered — id becomes ""
{ id: "valid_two", examples: ["a", "b"] },
],
};
expect(extractSkills(card)).toEqual([
{ id: "valid_one", name: "One", description: "", tags: [], examples: [] },
{ id: "named_only", name: "named_only", description: "", tags: [], examples: [] },
{ id: "valid_two", name: "valid_two", description: "", tags: [], examples: ["a", "b"] },
]);
});
it("handles a realistic agent card with multiple skills", () => {
const card = {
skills: [
{ id: "web_search", name: "Web Search", description: "Search the web", tags: ["search"], examples: ["Latest news"] },
{ id: "file_read", name: "Read Files", description: "Read from disk", tags: ["io"], examples: [] },
],
};
const result = extractSkills(card);
expect(result).toHaveLength(2);
expect(result[0].id).toBe("web_search");
expect(result[1].tags).toEqual(["io"]);
});
});
@@ -1,95 +0,0 @@
// @vitest-environment jsdom
/**
* Unit tests for getSkills — pure helper from DetailsTab.
*
* Covers: null card, non-array skills, empty skills, id-only entries,
* name-only entries (id derives from name), entries with description,
* entries with neither id nor name (filtered out), mixed entries.
*/
import { describe, it, expect } from "vitest";
import { getSkills } from "../DetailsTab";
describe("getSkills", () => {
it("returns [] for null card", () => {
expect(getSkills(null)).toEqual([]);
});
it("returns [] when card.skills is not an array", () => {
expect(getSkills({ skills: undefined })).toEqual([]);
expect(getSkills({ skills: "not-an-array" })).toEqual([]);
expect(getSkills({ skills: { id: "x" } })).toEqual([]);
});
it("returns [] for empty skills array", () => {
expect(getSkills({ skills: [] })).toEqual([]);
});
it("maps skill with id and description", () => {
const card = { skills: [{ id: "code_search", description: "Find code patterns" }] };
expect(getSkills(card)).toEqual([{ id: "code_search", description: "Find code patterns" }]);
});
it("maps skill with id only (description absent)", () => {
const card = { skills: [{ id: "code_search" }] };
expect(getSkills(card)).toEqual([{ id: "code_search", description: undefined }]);
});
it("derives id from name when id is absent", () => {
const card = { skills: [{ name: "web_scraper" }] };
expect(getSkills(card)).toEqual([{ id: "web_scraper" }]);
});
it("maps description when present", () => {
const card = { skills: [{ id: "file_write", description: "Writes files to disk" }] };
expect(getSkills(card)).toEqual([{ id: "file_write", description: "Writes files to disk" }]);
});
it("returns description as undefined when skill has no description", () => {
const card = { skills: [{ id: "noop_skill" }] };
const result = getSkills(card);
// The map always includes description; it's undefined when absent
expect(result).toEqual([{ id: "noop_skill", description: undefined }]);
});
it("filters out skills with neither id nor name", () => {
// id: String(undefined || undefined || "") → "" → filtered
const card = { skills: [{ description: "loner" }] };
expect(getSkills(card)).toEqual([]);
});
it("handles mixed valid/invalid entries", () => {
const card = {
skills: [
{ id: "valid_one" },
{ name: "named_skill" },
{ description: "orphaned" }, // filtered
{ id: "valid_two", description: "Has both" },
],
};
expect(getSkills(card)).toEqual([
{ id: "valid_one", description: undefined },
{ id: "named_skill", description: undefined },
{ id: "valid_two", description: "Has both" },
]);
});
it("handles string coercion for numeric ids/names", () => {
const card = { skills: [{ id: 42, name: "numeric_id" }] };
expect(getSkills(card)).toEqual([{ id: "42" }]);
});
it("uses id over name when both are present", () => {
const card = { skills: [{ id: "priority_id", name: "fallback_name" }] };
expect(getSkills(card)).toEqual([{ id: "priority_id", description: undefined }]);
});
it("omits description when it is falsy (0 is falsy in JS)", () => {
// The implementation uses `s.description ?` — 0 is falsy, so it's treated
// as absent and undefined is returned. Non-zero numbers coerce fine.
const cardZero = { skills: [{ id: "x", description: 0 }] };
expect(getSkills(cardZero)).toEqual([{ id: "x", description: undefined }]);
const cardNum = { skills: [{ id: "x", description: 42 }] };
expect(getSkills(cardNum)).toEqual([{ id: "x", description: "42" }]);
});
});
@@ -1,142 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for KeyValueField component.
*
* Covers: initial password type, onChange callback (including whitespace trim
* on type), aria-label forwarding, disabled state, and auto-hide timer setup.
*/
import React from "react";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { KeyValueField } from "../KeyValueField";
describe("KeyValueField — rendering", () => {
afterEach(cleanup);
it("renders input with type=password by default (secret hidden)", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
const input = screen.getByLabelText("Secret value");
expect(input.getAttribute("type")).toBe("password");
});
it("passes custom aria-label to the input element", () => {
render(<KeyValueField value="" onChange={vi.fn()} aria-label="API secret key" />);
expect(screen.getByLabelText("API secret key")).toBeTruthy();
});
it("disables the input when disabled=true", () => {
render(<KeyValueField value="secret" onChange={vi.fn()} disabled />);
expect(screen.getByLabelText("Secret value").disabled).toBe(true);
});
it("renders with the current value", () => {
render(<KeyValueField value="sk-test-key-123" onChange={vi.fn()} />);
expect(screen.getByLabelText("Secret value").value).toBe("sk-test-key-123");
});
it("renders with the placeholder text", () => {
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
});
it("renders the RevealToggle child button", () => {
render(<KeyValueField value="secret" onChange={vi.fn()} />);
// KeyValueField renders exactly one button (the RevealToggle)
expect(screen.getByRole("button")).toBeTruthy();
});
});
describe("KeyValueField — onChange", () => {
afterEach(cleanup);
it("calls onChange with the new value when user types", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "new-value" } });
expect(onChange).toHaveBeenCalledWith("new-value");
});
it("trims leading whitespace when user types with leading space", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " trimmed" } });
expect(onChange).toHaveBeenCalledWith("trimmed");
});
it("trims trailing whitespace when user types with trailing space", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "trimmed " } });
expect(onChange).toHaveBeenCalledWith("trimmed");
});
it("trims both sides when user types whitespace-surrounded value", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " both sides " } });
expect(onChange).toHaveBeenCalledWith("both sides");
});
it("does not modify value with no whitespace", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "clean-value" } });
expect(onChange).toHaveBeenCalledWith("clean-value");
});
});
describe("KeyValueField — auto-hide timer setup", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("sets up a 30s setTimeout when the component mounts with a non-empty value", () => {
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
render(<KeyValueField value="secret" onChange={vi.fn()} />);
// No timer should be set initially (revealed=false by default)
const callsBeforeInteraction = setTimeoutSpy.mock.calls.length;
// Simulate reveal (click the only button)
act(() => { fireEvent.click(screen.getByRole("button")); });
// After reveal, a 30s timer should be set
const timerCalls = setTimeoutSpy.mock.calls.filter(
([, delay]) => delay === 30_000,
);
expect(timerCalls.length).toBeGreaterThanOrEqual(1);
});
it("clears existing timer when a new toggle happens before auto-hide fires", () => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
const timerObj = {}; // fake timer ID
vi.spyOn(global, "setTimeout").mockImplementation((fn: () => void, delay: number) => {
return timerObj;
});
render(<KeyValueField value="secret" onChange={vi.fn()} />);
// First toggle — reveal
act(() => { fireEvent.click(screen.getByRole("button")); });
// Second toggle — hide (should clear the timer from first toggle)
act(() => { fireEvent.click(screen.getByRole("button")); });
// clearTimeout was called with the timer object
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerObj);
});
it("clears timer on unmount", () => {
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
const { unmount } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
// Toggle reveal to start the timer
act(() => { fireEvent.click(screen.getByRole("button")); });
unmount();
expect(clearTimeoutSpy).toHaveBeenCalled();
});
});
@@ -1,68 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for RevealToggle component.
*
* Covers: eye-icon (hidden) vs eye-off-icon (revealed), onToggle callback,
* aria-label (default + custom), title attribute.
*/
import { afterEach, describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { RevealToggle } from "../RevealToggle";
afterEach(cleanup);
describe("RevealToggle", () => {
it("renders as a button", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toBeTruthy();
});
it("uses default aria-label when not provided", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle reveal secret");
});
it("uses custom aria-label when provided", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
});
it('title is "Hide value" when revealed', () => {
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
});
it('title is "Show value" when hidden', () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
});
it("calls onToggle when clicked (revealed=true → should hide)", () => {
const onToggle = vi.fn();
render(<RevealToggle revealed={true} onToggle={onToggle} />);
fireEvent.click(screen.getByRole("button"));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("calls onToggle when clicked (revealed=false → should show)", () => {
const onToggle = vi.fn();
render(<RevealToggle revealed={false} onToggle={onToggle} />);
fireEvent.click(screen.getByRole("button"));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("renders the eye-open SVG (hide icon) when revealed=false", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const btn = screen.getByRole("button");
// The eye SVG contains a circle element; eye-off has a strikethrough line
expect(btn.querySelector("circle")).toBeTruthy();
expect(btn.querySelectorAll("line")).toHaveLength(0);
});
it("renders the eye-off SVG (show icon) when revealed=true", () => {
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
const btn = screen.getByRole("button");
// EyeOffIcon has a line (strikethrough) through the eye
expect(btn.querySelectorAll("line")).toHaveLength(1);
});
});
@@ -1,49 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ValidationHint component.
*
* Covers: null/neutral render, error state (red ⚠ + message), valid state
* (green ✓ + "Valid format"), ARIA role="alert" on error.
*/
import { afterEach, describe, it, expect } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import { ValidationHint } from "../ValidationHint";
afterEach(cleanup);
describe("ValidationHint", () => {
it("renders nothing when error is null and showValid is false", () => {
const { container } = render(<ValidationHint error={null} showValid={false} />);
expect(container.innerHTML).toBe("");
});
it("renders nothing when error is null and showValid is undefined", () => {
const { container } = render(<ValidationHint error={null} />);
expect(container.innerHTML).toBe("");
});
it("renders error state with ⚠ icon and message", () => {
render(<ValidationHint error="Key name must be UPPER_SNAKE_CASE" />);
const el = screen.getByRole("alert");
expect(el.textContent).toContain("⚠");
expect(el.textContent).toContain("Key name must be UPPER_SNAKE_CASE");
});
it("renders valid state with ✓ and 'Valid format'", () => {
render(<ValidationHint error={null} showValid />);
const el = screen.getByText("Valid format");
expect(el.textContent).toContain("✓");
});
it("prefers error over valid when both are set (error is not null)", () => {
// ValidationHint checks error first; showValid is only rendered when error is falsy.
render(<ValidationHint error="Some error" showValid />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.queryByText("Valid format")).toBeNull();
});
it("error alert has role='alert' for screen readers", () => {
render(<ValidationHint error="Invalid format" />);
expect(screen.getByRole("alert")).toBeTruthy();
});
});
+8 -220
View File
@@ -4,11 +4,11 @@ Documents persistent operational findings about Gitea Actions runner behaviour
that differ from GitHub Actions and require workarounds in workflow YAML or
runbooks.
> Last updated: 2026-05-12 (infra-runtime-be-agent)
> Last updated: 2026-05-11 (core-devops-agent)
---
## Quirk #1 — Large repo causes fetch timeout on Gitea Actions runner
## Large repo causes fetch timeout on Gitea Actions runner
### Finding
@@ -68,7 +68,7 @@ confirming this is a repo-size constraint, not network isolation.
---
## Quirk #2 — `continue-on-error` only works at step level, not job level
## `continue-on-error` only works at step level, not job level
### Finding
@@ -112,12 +112,12 @@ jobs:
### References
- Quirk #10 (this document): Gitea does NOT auto-populate `secrets.GITHUB_TOKEN`
- Gitea Actions quirk #10 (from migration checklist)
- PR #441: fix applied to `harness-replays.yml`
---
## Quirk #3 — `workflow_dispatch.inputs` not supported
## `workflow_dispatch.inputs` not supported
Gitea 1.22.6 parser rejects `workflow_dispatch.inputs`. Drop from all workflow
YAML files ported from GitHub Actions. Manual triggers should use
@@ -127,21 +127,21 @@ YAML files ported from GitHub Actions. Manual triggers should use
---
## Quirk #4 — `merge_group` not supported
## `merge_group` not supported
Gitea has no merge queue concept. Drop `merge_group:` triggers from all
workflow YAML files.
---
## Quirk #5 — `environment:` blocks not supported
## `environment:` blocks not supported
Gitea has no environments concept. Drop `environment:` from all workflow YAML
files. Secrets and variables are repo-level.
---
## Quirk #6 — Gitea combined status reports `failure` when all contexts are `null`
## Gitea combined status reports `failure` when all contexts are `null`
### Finding
@@ -189,215 +189,3 @@ primary consumer of combined status and is affected.
- Issue #481: first real-world case of this bug (2026-05-11)
- `feedback_no_such_thing_as_flakes`: watchdog directive
---
## Quirk #7 — TBD
*[Placeholder — document here when a new Gitea Actions quirk is discovered.]*
### Finding
*[What Gitea Actions does differently from GitHub Actions.]*
### Impact
*[Which workflows or operations are affected.]*
### Workaround
*[How to work around this quirk.]*
### References
- internal#[N]: first observation
---
## Quirk #8 — TBD
*[Placeholder — document here when a new Gitea Actions quirk is discovered.]*
### Finding
*[What Gitea Actions does differently from GitHub Actions.]*
### Impact
*[Which workflows or operations are affected.]*
### Workaround
*[How to work around this quirk.]*
### References
- internal#[N]: first observation
---
## Quirk #9 — TBD
*[Placeholder — document here when a new Gitea Actions quirk is discovered.]*
### Finding
*[What Gitea Actions does differently from GitHub Actions.]*
### Impact
*[Which workflows or operations are affected.]*
### Workaround
*[How to work around this quirk.]*
### References
- internal#[N]: first observation
---
## Quirk #10 — Gitea does NOT auto-populate `secrets.GITHUB_TOKEN`
### Finding
Gitea Actions (1.22.6) does **not** auto-populate `secrets.GITHUB_TOKEN`
the way GitHub Actions does. A workflow that references `secrets.GITHUB_TOKEN`
without explicitly provisioning a named secret gets an empty string — not a
read-only token scoped to the repo.
### Impact
Workflows that call the Gitea REST API using `secrets.GITHUB_TOKEN` as auth
receive **HTTP 401** on every API call. Affected workflows in molecule-core:
| Workflow | Symptom | Workaround |
|---|---|---|
| `gate-check-v3.yml` | Reports BLOCKED on every PR | Provision `SOP_TIER_CHECK_TOKEN`; update workflow to use it |
| `qa-review.yml` | Fails immediately on PR open | Same — needs named secret |
| `security-review.yml` | Fails immediately on PR open | Same — needs named secret |
### How to diagnose
Add a debug step to the failing workflow:
```yaml
- name: Diagnose token
run: |
echo "Token present: ${{ secrets.GITHUB_TOKEN != '' }}"
curl -sS --fail -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
"$GITHUB_SERVER_URL/api/v1/user" | jq -r '.login'
# Expected (GitHub): prints your username.
# Actual (Gitea): HTTP 401 or empty string.
```
### References
- internal#325: root-cause analysis and token provisioning
- `feedback_gitea_no_auto_supplied_github_token`
---
## Quirk #11 — PR-create event dispatcher races — only 1 of N workflows fires on `pull_request opened`
### Finding
When a PR is created via the Gitea web UI or API, the Gitea Actions event
dispatcher may fire **only 1 of N eligible workflows** on the initial
`pull_request opened` event. All other eligible workflows are silently dropped.
This was observed on molecule-core PR #558 (created 2026-05-11T19:54:10Z):
12+ workflows had no `paths:` filter and should have fired, but only
`sop-tier-check.yml` dispatched.
Concurrent PRs created within the same minute received 1230 dispatches each,
confirming this is specific to the PR-create event dispatch, not a general
runner capacity issue.
### Impact
- PRs may not run the full CI suite on first open.
- `gate-check-v3`, `secret-scan`, `qa-review`, and `security-review` can be
silently absent from the PR's status checks.
- Branch protection may block merge even though CI is effectively green.
### How to diagnose
```bash
# List workflow runs for the PR:
gh run list --event pull_request --repo molecule-ai/molecule-core \
| grep "$(gh pr view $PR --json number --jq '.number')"
# Expected: 12+ runs on PR open.
# Actual (when race fires): only 1 run.
```
### Workaround
Force a second dispatch by pushing a no-op synchronize commit:
```bash
git commit --allow-empty -m "chore: trigger workflows [skip ci]"
git push
```
The synchronize event fires a second `pull_request` event, which reliably
triggers all eligible workflows.
### References
- internal#329: first observation on PR #558
- `feedback_gitea_pr_create_dispatcher_race`
---
## When you find a new quirk
Copy the template below, increment the quirk number, and fill in the finding,
impact, workaround, and references. Place the new section in the **correct
numerical position** (before the next higher-numbered quirk). Update this
section's final paragraph to remove the next slot's number.
### Template
```markdown
## Quirk #N — <short title>
### Finding
<What Gitea Actions does differently from GitHub Actions.>
### Impact
<Which workflows or operations are affected. Include an affected workflows
table if more than one is affected.>
### How to diagnose
<Shell commands or API calls that confirm this is the quirk, not a real failure.>
### Workaround
<How to work around this quirk in workflow YAML or operations.>
### References
- internal#[N]: first observation
- <Any Gitea issue, feedback label, or upstream bug tracker reference>
```
---
## Open questions for Gitea 1.23
- [ ] **act_runner concurrent-job cap**: issue #305 — runner saturation under
merge burst; needs `max_concurrent_jobs` cap configured on act_runner
- [ ] **Infisical→Gitea secret-sync**: issue #307 — eliminate manual secret
PUTs by wiring an Infisical cron to the Gitea API
- [ ] **PR-create dispatcher race resolution**: internal #329 — is there a
Gitea fix or config knob to disable the race? File upstream bug if not
- [ ] **GITHUB_TOKEN auto-population**: internal #325 — is this on the
Gitea 1.23 roadmap? If not, the workaround (named secret) is the permanent
answer
-258
View File
@@ -601,261 +601,3 @@ def test_scan_workflows_missing_dir_returns_empty(sr_module, tmp_path, capsys):
assert out == {}
captured = capsys.readouterr()
assert "::warning::workflows dir not found" in captured.out
# --------------------------------------------------------------------------
# rev2: multi-SHA sweep — `reap_branch()` walks last N main commits
# --------------------------------------------------------------------------
# Phase 1+2 evidence (orchestrator + hongming-pc2): rev1 sees `compensated:0`
# every tick because the schedule workflow posts `failure` to whatever SHA
# was HEAD when it COMPLETED. By the next */5 tick, main has often moved
# forward, so the single-HEAD reaper misses the stranded red. rev2 sweeps
# the last 10 commits each tick. See `reference_post_suspension_pipeline`
# and parent rev1 PR #618 for context.
SHA_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
SHA_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
SHA_C = "cccccccccccccccccccccccccccccccccccccccc"
def test_reap_sweeps_n_shas_smoke(sr_module, monkeypatch):
"""rev2 contract: sweep last 10 (or N) main commits, GET combined
status for EACH. Smoke: with 3 stub SHAs, each is GET'd exactly once.
"""
gets: list[str] = []
posts: list[tuple[str, dict]] = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path.endswith("/commits"):
# commits listing — return 3 fake commit objects
return (200, [{"sha": SHA_A}, {"sha": SHA_B}, {"sha": SHA_C}])
if method == "GET" and "/commits/" in path and path.endswith("/status"):
sha = path.split("/commits/")[1].split("/status")[0]
gets.append(sha)
# All combined=success → cost-optimization short-circuit
return (200, {"state": "success", "statuses": []})
if method == "POST":
posts.append((path, body))
return (201, {})
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"x": False}
counters = sr_module.reap_branch(
workflow_map, "main", limit=10, dry_run=False
)
# Each of the 3 SHAs returned by /commits should be GET'd once.
assert gets == [SHA_A, SHA_B, SHA_C]
# No POST (everything was combined=success).
assert posts == []
# Counters reflect what we saw.
assert counters["scanned_shas"] == 3
assert counters["compensated"] == 0
assert counters["compensated_per_sha"] == {}
def test_reap_skips_combined_success_shas(sr_module, monkeypatch):
"""rev2 cost-optimization (refinement #2): when combined==success for
a SHA, do NOT iterate per-context statuses; move on to next SHA.
Mock 2 SHAs with combined=success + 1 with combined=failure → only
the failure-SHA's statuses get the per-context loop applied.
"""
per_context_iterated_for: list[str] = []
posts: list[tuple[str, dict]] = []
failure_statuses = [
{
"context": "drift / drift (push)",
"state": "failure",
"target_url": "https://example.test/run/42",
}
]
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path.endswith("/commits"):
return (200, [{"sha": SHA_A}, {"sha": SHA_B}, {"sha": SHA_C}])
if method == "GET" and "/commits/" in path and path.endswith("/status"):
sha = path.split("/commits/")[1].split("/status")[0]
if sha == SHA_B:
# Mark this SHA as the failure one — return per-context
# statuses that would compensate if iterated.
return (200, {"state": "failure", "statuses": failure_statuses})
# Others are combined=success — must short-circuit.
return (200, {"state": "success", "statuses": failure_statuses})
if method == "POST":
# If a POST hits a non-failure SHA, the short-circuit failed.
posts.append((path, body))
return (201, {})
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(sr_module, "api", fake_api)
# Workflow trigger map: `drift` is schedule-only (compensable).
workflow_map = {"drift": False}
counters = sr_module.reap_branch(
workflow_map, "main", limit=10, dry_run=False
)
# Only SHA_B (the combined=failure one) should be compensated.
assert counters["compensated"] == 1
assert counters["scanned_shas"] == 3
assert SHA_B in counters["compensated_per_sha"]
assert counters["compensated_per_sha"][SHA_B] == ["drift / drift (push)"]
# SHA_A and SHA_C must NOT appear in compensated_per_sha — their
# per-context loop was skipped via the combined=success short-circuit.
assert SHA_A not in counters["compensated_per_sha"]
assert SHA_C not in counters["compensated_per_sha"]
# Exactly one POST: the compensation on SHA_B.
assert len(posts) == 1
assert posts[0][0] == f"/repos/owner/repo/statuses/{SHA_B}"
def test_default_sweep_limit_is_30(sr_module):
"""rev3 contract: `DEFAULT_SWEEP_LIMIT = 30` (widened from rev2's 10).
Root cause of the widening: schedule workflows post `failure`
RETROACTIVELY 5-15 min after their merge. A 10-commit window is
narrower than the merge-cadence during a burst, so reds land
OUTSIDE the window before reaper's next tick sees them.
Evidence: rev2 run 17057 (02:46Z 2026-05-12) saw 185 contexts / 0
fails on its 10 SHAs; direct probe ~30min later showed ~25 fails
on those same 10 SHAs.
If this default is ever lowered back, that change MUST cite
re-measured cadence data — a smaller window than the
retroactive-failure-post lag re-introduces compensated:0.
"""
assert sr_module.DEFAULT_SWEEP_LIMIT == 30
def test_reap_widened_window_catches_retroactive_failure(sr_module, monkeypatch):
"""rev3 regression: with limit=30, a stranded red on a SHA at depth=20
(which the rev2 limit=10 window would have missed) IS swept + compensated.
Why this matters: rev2 ran with limit=10 and saw `compensated:0` for
6 consecutive ticks despite ~25 known-stranded reds across the last
30 main commits. Widening to 30 must demonstrably catch a SHA past
the old window. We mock 30 SHAs, plant the failure on SHA[20], and
verify exactly one compensation lands on that SHA.
"""
shas = [f"{c:02x}" * 20 for c in range(30)] # 30 deterministic SHAs
failing_sha = shas[20] # depth 20 — outside rev2's window=10, inside rev3's =30
posts: list[tuple[str, dict]] = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path.endswith("/commits"):
# /commits listing — return all 30 fake commit objects
assert query.get("limit") == "30", (
f"expected limit=30 in query, got {query}"
)
return (200, [{"sha": s} for s in shas])
if method == "GET" and "/commits/" in path and path.endswith("/status"):
sha = path.split("/commits/")[1].split("/status")[0]
if sha == failing_sha:
return (
200,
{
"state": "failure",
"statuses": [
{
"context": "retroactive-drift / drift (push)",
"state": "failure",
"target_url": "https://example.test/run/9001",
}
],
},
)
# All others combined=success (cost-opt short-circuit).
return (200, {"state": "success", "statuses": []})
if method == "POST":
posts.append((path, body))
return (201, {})
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"retroactive-drift": False} # schedule-only → class-O
counters = sr_module.reap_branch(
workflow_map, "main", limit=sr_module.DEFAULT_SWEEP_LIMIT, dry_run=False
)
# All 30 SHAs walked; exactly one compensated.
assert counters["scanned_shas"] == 30
assert counters["compensated"] == 1
assert failing_sha in counters["compensated_per_sha"]
assert counters["compensated_per_sha"][failing_sha] == [
"retroactive-drift / drift (push)"
]
assert len(posts) == 1
assert posts[0][0] == f"/repos/owner/repo/statuses/{failing_sha}"
# Sanity: with rev2's window=10, depth=20 would NOT have been reached.
# This assertion documents the rev3 widening as the structural fix:
# the failing_sha index (20) is strictly greater than rev2's old limit (10).
assert shas.index(failing_sha) >= 10
def test_reap_continues_on_per_sha_apierror(sr_module, monkeypatch, capsys):
"""rev2 refinement #7 (MOST CRITICAL): a transient ApiError or HTTP-5xx
on get_combined_status(SHA_X) must NOT fail the whole tick. Log + skip
SHA_X, continue with SHA_Y.
Different from the single-HEAD path (where fail-loud is correct): the
sweep is best-effort across historical commits, so one transient blip
on a stale SHA should not strand reds on the OTHER stale SHAs.
"""
posts: list[tuple[str, dict]] = []
def fake_api(method, path, *, body=None, query=None, expect_json=True):
if method == "GET" and path.endswith("/commits"):
return (200, [{"sha": SHA_A}, {"sha": SHA_B}])
if method == "GET" and "/commits/" in path and path.endswith("/status"):
sha = path.split("/commits/")[1].split("/status")[0]
if sha == SHA_A:
raise sr_module.ApiError(
f"GET /repos/owner/repo/commits/{SHA_A}/status "
f"-> HTTP 502: bad gateway"
)
# SHA_B returns normally with a failure to compensate.
return (
200,
{
"state": "failure",
"statuses": [
{
"context": "drift / drift (push)",
"state": "failure",
}
],
},
)
if method == "POST":
posts.append((path, body))
return (201, {})
raise AssertionError(f"unexpected api call: {method} {path}")
monkeypatch.setattr(sr_module, "api", fake_api)
workflow_map = {"drift": False}
# Must NOT raise — per-SHA error isolation contract.
counters = sr_module.reap_branch(
workflow_map, "main", limit=10, dry_run=False
)
# SHA_A was logged + skipped. SHA_B processed normally.
assert counters["scanned_shas"] == 2
assert counters["compensated"] == 1
assert SHA_B in counters["compensated_per_sha"]
assert SHA_A not in counters["compensated_per_sha"]
# Compensation POST landed on SHA_B only.
assert len(posts) == 1
assert posts[0][0] == f"/repos/owner/repo/statuses/{SHA_B}"
# The ApiError must be logged so a human auditing tick output can see
# WHICH SHA blipped and WHY.
captured = capsys.readouterr()
assert "::warning::" in captured.out or "::notice::" in captured.out
assert SHA_A[:10] in captured.out
+3 -9
View File
@@ -35,12 +35,6 @@ GITEA_HOST = os.environ.get("GITEA_HOST", "git.moleculesai.app")
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", os.environ.get("GITHUB_TOKEN", ""))
API_BASE = f"https://{GITEA_HOST}/api/v1"
# Timeout in seconds for all HTTP calls. Defence-in-depth: ensures a missing or
# invalid SOP_TIER_CHECK_TOKEN causes a fast (~15 s) failure rather than an
# indefinite hang. The real fix is provisioning the token; this caps worst-case
# wall-clock on a broken/unreachable Gitea host.
DEFAULT_TIMEOUT = 15
def api_get(path: str) -> dict | list:
url = f"{API_BASE}{path}"
@@ -52,7 +46,7 @@ def api_get(path: str) -> dict | list:
},
)
try:
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
with urllib.request.urlopen(req) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")
@@ -527,12 +521,12 @@ def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
comment_id = our_comments[-1]["id"]
url = f"{API_BASE}/repos/{owner}/{name}/issues/comments/{comment_id}"
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="PATCH")
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
with urllib.request.urlopen(req) as r:
r.read()
else:
url = f"{API_BASE}/repos/{owner}/{name}/issues/{pr_number}/comments"
req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="POST")
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
with urllib.request.urlopen(req) as r:
r.read()
except urllib.error.HTTPError as e:
if e.code == 403:
@@ -983,16 +983,7 @@ func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
WithArgs("dispatched", "", testSourceID, testDelegationID).
WillReturnResult(sqlmock.NewResult(0, 1))
// CanCommunicate: source != target → fires two getWorkspaceRef lookups.
// Both test fixtures have parent_id = NULL (root-level siblings) → allowed.
// Order matches call order: source first, then target.
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
WithArgs(testSourceID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testSourceID, nil))
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
WithArgs(testTargetID).
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testTargetID, nil))
// CanCommunicate (source=target self-call is always allowed — no DB lookup needed)
// resolveAgentURL: reads ws:{id}:url from Redis, falls back to DB for target
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id = ").
WithArgs(testTargetID).