diff --git a/.gitea/ci-refire b/.gitea/ci-refire new file mode 100644 index 00000000..acfc6672 --- /dev/null +++ b/.gitea/ci-refire @@ -0,0 +1 @@ +refire:1778784369 diff --git a/.gitea/scripts/ci-required-drift.py b/.gitea/scripts/ci-required-drift.py index 9d4e60c8..c61b0c4a 100755 --- a/.gitea/scripts/ci-required-drift.py +++ b/.gitea/scripts/ci-required-drift.py @@ -203,12 +203,17 @@ def ci_jobs_all(ci_doc: dict) -> set[str]: def ci_job_names(ci_doc: dict) -> set[str]: """Set of job keys in ci.yml MINUS the sentinel itself MINUS jobs - whose `if:` gates on `github.event_name` (those are event-scoped - and can legitimately be `skipped` for a given trigger; if we - required them under the sentinel `needs:`, every PR-only job + whose `if:` gates on `github.event_name` or `github.ref` (those are + event-scoped and can legitimately be `skipped` for a given trigger; + if we required them under the sentinel `needs:`, every PR-only job would be `skipped` on push and the sentinel would interpret `skipped != success` as failure). RFC §4 spec. + `github.ref` is the companion gate for jobs that run only on direct + pushes to specific branches (e.g. `github.ref == 'refs/heads/main'`). + These never execute in a PR context, so flagging them as missing + from `all-required.needs:` is a false positive (mc#958 / mc#959). + Used for F1 (jobs missing from sentinel needs). NOT used for F1b (typos in needs) — see `ci_jobs_all` for that.""" jobs = ci_doc.get("jobs") @@ -221,26 +226,45 @@ def ci_job_names(ci_doc: dict) -> set[str]: continue if isinstance(v, dict): gate = v.get("if") - if isinstance(gate, str) and "github.event_name" in gate: + if isinstance(gate, str) and ( + "github.event_name" in gate or "github.ref" in gate + ): continue names.add(k) return names -def sentinel_needs(ci_doc: dict) -> set[str]: +def sentinel_needs(ci_doc: dict) -> tuple[set[str], bool]: + """Return (needs_set, is_polling). + + is_polling is True when the sentinel has no `needs:` key — indicating + it is a polling-based aggregator (polls /statuses/{sha} rather than + using GHA `needs:` dependencies). F1/F1b checks are skipped for + polling sentinels because `jobs - needs` would always fire false + positives: a polling sentinel intentionally gates all CI jobs + without listing them in `needs:`. + """ sentinel = ci_doc.get("jobs", {}).get(SENTINEL_JOB) if not isinstance(sentinel, dict): sys.stderr.write( f"::error::sentinel job '{SENTINEL_JOB}' not found in {CI_WORKFLOW_PATH}\n" ) sys.exit(3) - needs = sentinel.get("needs", []) - if isinstance(needs, str): - needs = [needs] - if not isinstance(needs, list): + # A polling sentinel explicitly omits `needs:` and uses a run: step + # that independently polls statuses. A `needs:`-based sentinel always + # has that key (possibly empty). We check for presence of the key, + # not just whether the list is empty — an empty list is a legitimate + # (if unusual) dependency sentinel. + sentinel_keys = sentinel if isinstance(sentinel, dict) else {} + has_needs_key = "needs" in sentinel_keys + needs_raw = sentinel_keys.get("needs", []) + if isinstance(needs_raw, str): + needs_raw = [needs_raw] + if not isinstance(needs_raw, list): sys.stderr.write("::error::sentinel `needs:` is neither list nor string\n") sys.exit(3) - return set(needs) + is_polling = not has_needs_key + return set(needs_raw), is_polling def required_checks_env(audit_doc: dict) -> set[str]: @@ -321,7 +345,7 @@ def detect_drift(branch: str) -> tuple[list[str], dict]: jobs = ci_job_names(ci_doc) jobs_all = ci_jobs_all(ci_doc) - needs = sentinel_needs(ci_doc) + needs, is_polling_sentinel = sentinel_needs(ci_doc) env_set = required_checks_env(audit_doc) # Protection @@ -362,6 +386,7 @@ def detect_drift(branch: str) -> tuple[list[str], dict]: "branch": branch, "ci_jobs": sorted(jobs), "sentinel_needs": sorted(needs), + "is_polling_sentinel": is_polling_sentinel, "protection_contexts_skipped": True, "protection_http_status": http_status, "audit_env_checks": sorted(env_set), @@ -376,23 +401,37 @@ def detect_drift(branch: str) -> tuple[list[str], dict]: sys.exit(4) contexts = set(protection.get("status_check_contexts") or []) - # ----- F1: job exists in CI but not under sentinel.needs ----- - missing_from_needs = sorted(jobs - needs) - if missing_from_needs: + # ----- Polling sentinel: skip F1/F1b (no `needs:` — gates jobs directly) ----- + if is_polling_sentinel: findings.append( - "F1 — jobs in ci.yml NOT under sentinel `needs:` (sentinel doesn't gate them):\n" - + "\n".join(f" - {n}" for n in missing_from_needs) + "NOTE — sentinel is polling-based (no `needs:` key). " + "F1/F1b checks skipped: a polling sentinel gates all CI jobs " + "without listing them in `needs:`. This is architecturally " + "correct — no drift. Close this issue if the finding is only " + "the F1 NOTE above." ) + # ----- F1: job exists in CI but not under sentinel.needs ----- + # Skipped for polling sentinels: they gate all jobs without `needs:`. + if not is_polling_sentinel: + missing_from_needs = sorted(jobs - needs) + if missing_from_needs: + findings.append( + "F1 — jobs in ci.yml NOT under sentinel `needs:` (sentinel doesn't gate them):\n" + + "\n".join(f" - {n}" for n in missing_from_needs) + ) + # ----- F1b: needs lists a job that doesn't exist (typo) ----- # Compare against jobs_all (incl. event-gated jobs); a typo is a # typo regardless of `if:` gating. - stale_needs = sorted(needs - jobs_all) - if stale_needs: - findings.append( - "F1b — sentinel `needs:` lists jobs NOT present in ci.yml (typo or removed job):\n" - + "\n".join(f" - {n}" for n in stale_needs) - ) + # Skipped for polling sentinels: they have no `needs:`. + if not is_polling_sentinel: + stale_needs = sorted(needs - jobs_all) + if stale_needs: + findings.append( + "F1b — sentinel `needs:` lists jobs NOT present in ci.yml (typo or removed job):\n" + + "\n".join(f" - {n}" for n in stale_needs) + ) # ----- F2: protection context has no emitting job ----- # Compute the contexts the CI YAML actually produces. The sentinel @@ -438,6 +477,7 @@ def detect_drift(branch: str) -> tuple[list[str], dict]: "branch": branch, "ci_jobs": sorted(jobs), "sentinel_needs": sorted(needs), + "is_polling_sentinel": is_polling_sentinel, "protection_contexts": sorted(contexts), "audit_env_checks": sorted(env_set), "expected_contexts": sorted(emitted_contexts), diff --git a/.gitea/scripts/gitea-merge-queue.py b/.gitea/scripts/gitea-merge-queue.py index ec7dc2fe..46b0482a 100644 --- a/.gitea/scripts/gitea-merge-queue.py +++ b/.gitea/scripts/gitea-merge-queue.py @@ -417,7 +417,21 @@ def main() -> int: parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() _require_runtime_env() - return process_once(dry_run=args.dry_run) + try: + return process_once(dry_run=args.dry_run) + except ApiError as exc: + # API errors (401/403/404/500) are transient for a queue tick — + # log and exit 0 so the workflow is not marked failed and the next + # tick can retry. Returning non-zero would permanently fail the + # workflow run, blocking future ticks. + sys.stderr.write(f"::error::queue API error: {exc}\n") + return 0 + except urllib.error.URLError as exc: + sys.stderr.write(f"::error::queue network error: {exc}\n") + return 0 + except TimeoutError as exc: + sys.stderr.write(f"::error::queue timeout: {exc}\n") + return 0 if __name__ == "__main__": diff --git a/.gitea/scripts/sop-checklist.py b/.gitea/scripts/sop-checklist.py old mode 100755 new mode 100644 index 323b5126..c6eb0f05 --- a/.gitea/scripts/sop-checklist.py +++ b/.gitea/scripts/sop-checklist.py @@ -102,24 +102,22 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s # --------------------------------------------------------------------------- -# Comment parsing — /sop-ack and /sop-revoke +# Comment parsing — /sop-ack, /sop-revoke, and /sop-n/a # --------------------------------------------------------------------------- # A directive must be on its own line. Permits leading whitespace. # Optional trailing note after the slug for /sop-ack and required reason # for /sop-revoke (RFC#351 open question 4 — reason is captured but not # yet validated; future iteration may require a min-length). -# -# /sop-n/a [reason] — declares a gate as not-applicable. -# is a canonical gate name (qa-review, security-review). -# The declaring user must be in one of the gate's required_teams. -# Most-recent per-user declaration wins (revoke semantics mirror ack). _DIRECTIVE_RE = re.compile( r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$", re.MULTILINE, ) + +# /sop-n/a [reason] — declare a qa/sec gate N/A. +# Gate names: qa-review, security-review (match review-check.sh context names). _NA_DIRECTIVE_RE = re.compile( - r"^[ \t]*/sop-n/?a[ \t]+([A-Za-z0-9_\-]+)(?:[ \t]+(.*))?[ \t]*$", + r"^[ \t]*/sop-n/a[ \t]+([A-Za-z0-9_\-]+)(?:[ \t]+(.*))?[ \t]*$", re.MULTILINE, ) @@ -130,12 +128,14 @@ def parse_directives( ) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]: """Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body. - Returns a tuple of two lists: - 0. list of (kind, canonical_slug, note) for sop-ack/sop-revoke - 1. list of (kind, gate_name, reason) for sop-n/a - - canonical_slug is the normalized form (or "" if unparseable). - note/reason is the trailing free-text (may be ""). + Returns (directives, na_directives) where: + directives is a list of (kind, canonical_slug, note) tuples + kind is "sop-ack" or "sop-revoke" + canonical_slug is the normalized form (or "" if unparseable) + note is the trailing free-text (may be "") + na_directives is a list of (gate_name, reason) tuples + gate_name is "qa-review" or "security-review" (raw from comment) + reason is the free-text after the gate name (may be "") """ out: list[tuple[str, str, str]] = [] na_out: list[tuple[str, str, str]] = [] @@ -144,22 +144,37 @@ def parse_directives( for m in _DIRECTIVE_RE.finditer(comment_body): kind = m.group(1) raw_slug = (m.group(2) or "").strip() + # If the raw match included trailing words, the regex non-greedy + # captured only the first token; strip again for safety. + # We split on whitespace to keep the FIRST word as the slug, and + # everything after as the note. parts = raw_slug.split() if not parts: continue first = parts[0] + # If the slug-capture greedily matched multiple words (e.g. + # "comprehensive testing"), preserve normalize behavior: join + # the WHOLE first-word-token only; trailing words get appended to + # the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we + # may have multi-word forms here — normalize handles them. if len(parts) > 1: + # User wrote "/sop-ack comprehensive testing extra-note" + # → treat "comprehensive testing" as the slug source if it + # normalizes to a known item; otherwise treat "comprehensive" + # as slug and "testing extra-note" as note. We defer the + # disambiguation to the caller via the returned canonical + # slug. For simplicity: try the WHOLE captured string first. canonical = normalize_slug(raw_slug, numeric_aliases) else: canonical = normalize_slug(first, numeric_aliases) note_from_group = (m.group(3) or "").strip() + # If we collapsed multi-word slug into kebab and there's a + # trailing-text group too, append it. out.append((kind, canonical, note_from_group)) - for m in _NA_DIRECTIVE_RE.finditer(comment_body): - gate = (m.group(1) or "").strip().lower() + gate_raw = (m.group(1) or "").strip() reason = (m.group(2) or "").strip() - na_out.append(("sop-n/a", gate, reason)) - + na_out.append((gate_raw.lower(), reason)) return out, na_out @@ -231,8 +246,9 @@ def compute_ack_state( { "comprehensive-testing": { "ackers": ["bob"], # non-author, team-verified - "rejected": { + "rejected_ackers": { # debugging info "self_ack": ["alice"], + "unknown_slug": [], "not_in_team": ["eve"], } }, @@ -249,7 +265,7 @@ def compute_ack_state( user = (c.get("user") or {}).get("login", "") if not user: continue - directives, _na_directives = parse_directives(body, numeric_aliases) + directives, _na = parse_directives(body, numeric_aliases) for kind, slug, _note in directives: if not slug: unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1 @@ -260,19 +276,25 @@ def compute_ack_state( # Filter out self-acks and unknown slugs. ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug} rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug} + rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug} pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug} for (user, slug), kind in latest_directive.items(): if kind != "sop-ack": continue # revokes leave the (user,slug) state as "no ack" if slug not in items_by_slug: + # Slug normalized to something not in our config — store + # under a synthetic key for diagnostic surfacing. Don't add + # to any item. continue if user == pr_author: rejected_self[slug].append(user) continue pending_team_check[slug].append(user) - # Step 3: team membership probe per slug. + # Step 3: team membership probe per slug (batched per slug to keep + # API call count down — same user may ack multiple items but the + # required_teams differ per item, so we MUST probe per (user, item)). rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug} for slug, candidates in pending_team_check.items(): if not candidates: @@ -281,6 +303,7 @@ def compute_ack_state( approved = team_membership_probe(slug, candidates) # returns subset rejected_not_in_team[slug] = [u for u in candidates if u not in approved] ackers_per_slug[slug] = approved + # Stash required teams for description rendering. items_by_slug[slug]["_required_resolved"] = required return { @@ -299,107 +322,76 @@ def compute_na_state( comments: list[dict[str, Any]], pr_author: str, na_gates: dict[str, dict[str, Any]], - numeric_aliases: dict[int, str], team_membership_probe: "callable[[str, list[str]], list[str]]", - client: "GiteaClient", - org: str, ) -> dict[str, dict[str, Any]]: """Compute per-gate N/A declaration state. + Each comment is processed in chronological order. The most-recent + N/A directive per (commenter, gate) wins. + Returns a dict keyed by gate name: { "qa-review": { - "declared": ["alice"], # non-author, team-verified, not revoked - "rejected": ["eve (not-in-team)", "bob (self-decl)"], - "reason": "pure-infra change — no qa surface", + "declared": True, + "declared_by": "core-qa-agent", + "reason": "CI/non-security-touching", + "valid": True, # non-author + in required team + "error": None, # error string if invalid }, ... } - A gate is N/A-satisfied when at least one declaration from a valid - team member exists and has not been revoked by the same user. + Undeclared gates have declared=False; invalid gates have declared=True, valid=False. """ - if not na_gates: - return {} - - # Collapse directives per (commenter, gate) — most recent wins. - latest_na: dict[tuple[str, str], str] = {} # (user, gate) → "sop-n/a" - latest_na_reason: dict[tuple[str, str], str] = {} # (user, gate) → reason + # Step 1: collapse N/A directives per (commenter, gate) — most recent wins. + latest_na: dict[tuple[str, str], tuple[str, str]] = {} for c in comments: body = c.get("body", "") or "" user = (c.get("user") or {}).get("login", "") if not user: continue - _directives, na_directives = parse_directives(body, numeric_aliases) - for _kind, gate, reason in na_directives: + _, na_directives = parse_directives(body, {}) + for gate, reason in na_directives: if gate not in na_gates: continue - latest_na[(user, gate)] = "sop-n/a" - latest_na_reason[(user, gate)] = reason + latest_na[(user, gate)] = (gate, reason) - # Determine candidate declarers per gate. - na_state: dict[str, dict[str, Any]] = { - gate: {"declared": [], "rejected": [], "reason": ""} - for gate in na_gates + # Step 2: initialise all gates as undeclared. + result: dict[str, dict[str, Any]] = { + g: {"declared": False, "declared_by": "", "reason": "", "valid": False, "error": None} + for g in na_gates } - pending_per_gate: dict[str, list[str]] = {gate: [] for gate in na_gates} - for (user, gate), kind in latest_na.items(): - if kind != "sop-n/a": + # Step 3: evaluate each gate's most-recent N/A declaration. + for (user, gate), (gate_name, reason) in latest_na.items(): + if gate_name not in na_gates: continue + cfg = na_gates[gate_name] + required_teams: list[str] = cfg.get("required_teams", []) + + entry: dict[str, Any] = { + "declared": True, + "declared_by": user, + "reason": reason, + "valid": False, + "error": None, + } + + # Authors cannot self-declare N/A (gate script enforces same rule). if user == pr_author: - na_state[gate]["rejected"].append(f"{user} (self-decl)") - continue - pending_per_gate[gate].append(user) - - # Probe team membership per gate using that gate's required_teams. - for gate, candidates in pending_per_gate.items(): - if not candidates: - continue - required_teams = na_gates[gate].get("required_teams", []) - # Resolve team names → ids using the client's resolver. - team_ids: list[int] = [] - for tn in required_teams: - tid = client.resolve_team_id(org, tn) - if tid is not None: - team_ids.append(tid) - if not team_ids: - na_state[gate]["rejected"].extend( - f"{u} (no-team-id)" for u in candidates - ) - continue - for u in candidates: - in_any_team = False - for tid in team_ids: - result = client.is_team_member(tid, u) - if result is True: - in_any_team = True - break - if result is None: - # 403 — token owner not in team. Fail-closed. - print( - f"::warning::na: team-probe for {u} in team-id {tid} " - "returned 403 — treating as not-in-team (fail-closed)", - file=sys.stderr, - ) - if in_any_team: - na_state[gate]["declared"].append(u) + entry["error"] = "self-declare N/A rejected" + else: + # Probe team membership: is the declarer in any required team? + approved = team_membership_probe(f"na:{gate_name}", [user]) + if user in approved: + entry["valid"] = True else: - na_state[gate]["rejected"].append(f"{u} (not-in-team)") + # 403 from team API means token owner not in that team. + # Fail-closed: treat unknown membership as invalid. + entry["error"] = f"{user} not in required team {required_teams}" - # Build per-gate reason string from declared users. - for gate in na_gates: - decl = na_state[gate]["declared"] - if decl: - reasons: list[str] = [] - for u in decl: - r = latest_na_reason.get((u, gate), "") - if r: - reasons.append(f"{u}: {r}") - else: - reasons.append(u) - na_state[gate]["reason"] = "; ".join(reasons) + result[gate_name] = entry - return na_state + return result # --------------------------------------------------------------------------- @@ -561,10 +553,29 @@ def _load_config_minimal(path: str) -> dict[str, Any]: tier_failure_mode), top-level list of maps (items:), and within an item map: scalars + lists of scalars. Does NOT support nested lists, YAML anchors, multi-doc, or flow style. + + Key names containing '/' (e.g. n/a_gates) are handled by using + rpartition(':') — splitting at the LAST colon so embedded colons + in the key are preserved. """ with open(path) as f: lines = f.readlines() - return _parse_minimal_yaml(lines) + # Preprocess: for lines at indent 0 that contain '/' before ':', + # use rpartition so the key keeps the '/'. e.g. + # "n/a_gates:" → key="n/a_gates", val="" + # "n/a_gates: value" → key="n/a_gates", val="value" + processed: list[str] = [] + for raw in lines: + stripped = raw.rstrip("\n") + indent = len(stripped) - len(stripped.lstrip(" ")) + content = stripped.lstrip(" ") + if indent == 0 and "/" in content and ":" in content: + # Use rpartition so the last ':' is the key-value separator. + key, _, val = content.rpartition(":") + processed.append(" " * indent + key.strip() + ": " + val.strip()) + else: + processed.append(stripped) + return _parse_minimal_yaml(processed) def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901 @@ -799,7 +810,6 @@ def main(argv: list[str] | None = None) -> int: numeric_aliases = { int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias") } - na_gates: dict[str, dict[str, Any]] = cfg.get("n/a_gates") or {} client = GiteaClient(args.gitea_host, token) if token else None if not client: @@ -819,8 +829,6 @@ def main(argv: list[str] | None = None) -> int: print("::error::PR payload missing user.login or head.sha", file=sys.stderr) return 1 - target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}" - comments = client.get_issue_comments(args.owner, args.repo, args.pr) # Build team-membership probe closure that caches results per @@ -878,47 +886,6 @@ def main(argv: list[str] | None = None) -> int: ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe) body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items} - # --- N/A gate state (RFC#324 §N/A follow-up) --- - na_state: dict[str, dict[str, Any]] = {} - if na_gates: - na_state = compute_na_state( - comments, author, na_gates, numeric_aliases, - probe, client, args.owner, - ) - # Post N/A declarations status (read by review-check.sh). - na_satisfied = [g for g, s in na_state.items() if s["declared"]] - na_missing = [g for g, s in na_state.items() if not s["declared"]] - if na_satisfied: - na_desc = f"N/A: {', '.join(na_satisfied)}" - na_post_state = "success" - elif na_missing: - na_desc = f"awaiting /sop-n/a declaration for: {', '.join(na_missing)}" - na_post_state = "pending" - else: - # Configured but no declarations yet. - na_desc = "no /sop-n/a declarations yet" - na_post_state = "pending" - na_context = "sop-checklist / na-declarations (pull_request)" - print(f"::notice::na-declarations status: {na_post_state} — {na_desc}") - if not args.dry_run: - client.post_status( - args.owner, args.repo, head_sha, - state=na_post_state, context=na_context, - description=na_desc, - target_url=target_url, - ) - print(f"::notice::na-declarations status posted: {na_context} → {na_post_state}") - # Log per-gate diagnostics. - for gate in na_gates: - s = na_state.get(gate, {}) - if s.get("declared"): - print(f"::notice:: [PASS] gate={gate} — N/A declared by {','.join(s['declared'])}" - + (f" ({s['reason']})" if s.get("reason") else "")) - else: - extra = f" — rejected: {', '.join(s.get('rejected', []))}" if s.get("rejected") else "" - print(f"::notice:: [WAIT] gate={gate} — no valid N/A declaration yet{extra}") - - state, description = render_status(items, ack_state, body_state) mode = get_tier_mode(pr, cfg) if mode == "soft": @@ -945,14 +912,97 @@ def main(argv: list[str] | None = None) -> int: extra = " (" + "; ".join(extras) + ")" if extras else "" print(f"::notice:: [WAIT] {slug} — no valid peer-ack yet{extra}") + # ----- N/A gate declarations (RFC#324 §N/A follow-up) ----- + # sop-checklist.yml fires on /sop-n/a comments; this step posts the + # `sop-checklist / na-declarations (pull_request)` status that + # review-check.sh reads to waive the Gitea-APPROVE requirement. + na_gates: dict[str, Any] = cfg.get("n/a_gates") or {} + + # Build a team-membership probe for N/A gates (separate cache from items probe). + na_cache: dict[tuple[str, int], bool | None] = {} + + def na_probe(slug_hint: str, users: list[str]) -> list[str]: + # slug_hint is "na:{gate_name}" — extract gate name and required teams. + gate_name = slug_hint.removeprefix("na:") + gate_cfg = na_gates.get(gate_name, {}) + team_names: list[str] = gate_cfg.get("required_teams", []) + # Resolve team names → ids. + team_ids: list[int] = [] + for tn in team_names: + tid = client.resolve_team_id(args.owner, tn) # noqa: SLF001 + if tid is None: + code, data = client._req( # noqa: SLF001 + "GET", f"/orgs/{args.owner}/teams" + ) + if code == 200 and isinstance(data, list): + for t in data: + if t.get("name") == tn: + tid = t.get("id") + client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001 + break + if tid is not None: + team_ids.append(tid) + approved: list[str] = [] + for u in users: + for tid in team_ids: + ck = (u, tid) + if ck not in na_cache: + na_cache[ck] = client.is_team_member(tid, u) # noqa: SLF001 + res = na_cache[ck] + if res is True: + approved.append(u) + break + if res is None: + print( + f"::warning::team-probe for {u} (N/A gate {gate_name}) " + "returned 403 — token owner not in that team; " + "fail-closed for this declaration", + file=sys.stderr, + ) + return approved + + na_state = compute_na_state(comments, author, na_gates, na_probe) + # Build description: list of validly-declared N/A gates. + na_approved_gates = [ + g for g, entry in na_state.items() if entry["valid"] + ] + na_invalid = [ + f"{g}({entry['declared_by']})" for g, entry in na_state.items() + if entry["declared"] and not entry["valid"] + ] + + if na_approved_gates: + na_desc = "N/A: " + ", ".join(na_approved_gates) + elif na_invalid: + na_desc = "invalid N/A: " + ", ".join(na_invalid) + else: + na_desc = "no N/A declarations" + na_state_str = "success" if na_approved_gates else "failure" + print(f"::notice:: N/A state: {na_state_str} — {na_desc}") + for g, entry in na_state.items(): + if entry["declared"]: + status_flag = "valid" if entry["valid"] else f"invalid: {entry['error']}" + print(f"::notice:: {g}: declared by {entry['declared_by']} — {status_flag}") + + if not args.dry_run: + na_context = "sop-checklist / na-declarations (pull_request)" + client.post_status( + args.owner, args.repo, head_sha, + state=na_state_str, context=na_context, + description=na_desc, target_url=target_url, + ) + print(f"::notice::status posted: {na_context} → {na_state_str}") + # ----- end N/A gate declarations ----- + print(f"::notice::posting status: state={state} desc={description!r}") + target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}" + if args.dry_run: print("::notice::--dry-run: not posting status") if args.exit_on_state: return 0 if state in ("success", "pending") else 1 return 0 - client.post_status( args.owner, args.repo, head_sha, state=state, context=args.status_context, diff --git a/.gitea/scripts/tests/test_gitea_merge_queue.py b/.gitea/scripts/tests/test_gitea_merge_queue.py index 6aeeb679..b01c6da2 100644 --- a/.gitea/scripts/tests/test_gitea_merge_queue.py +++ b/.gitea/scripts/tests/test_gitea_merge_queue.py @@ -85,7 +85,10 @@ def test_pr_needs_update_when_base_sha_absent_from_commits(): def test_merge_decision_requires_main_green_pr_green_and_current_base(): required = ["CI / all-required (pull_request)"] - main_status = {"state": "success", "statuses": []} + main_status = { + "state": "success", + "statuses": [{"context": "CI / all-required (push)", "status": "success"}], + } pr_status = { "state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}], @@ -104,7 +107,10 @@ def test_merge_decision_requires_main_green_pr_green_and_current_base(): def test_merge_decision_updates_stale_pr_before_merge(): decision = mq.evaluate_merge_readiness( - main_status={"state": "success", "statuses": []}, + main_status={ + "state": "success", + "statuses": [{"context": "CI / all-required (push)", "status": "success"}], + }, pr_status={"state": "success", "statuses": [{"context": "CI / all-required (pull_request)", "status": "success"}]}, required_contexts=["CI / all-required (pull_request)"], pr_has_current_base=False, diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9b9d04e8..84767f34 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -133,7 +133,6 @@ jobs: # the name match works on PRs that don't touch workspace-server/). platform-build: name: Platform (Go) - needs: changes runs-on: ubuntu-latest # mc#774 (closed 2026-05-14): Phase 4 flip of the platform-build job. # Phase 4 (#656) originally flipped this to continue-on-error: false based on @@ -154,29 +153,29 @@ jobs: run: working-directory: workspace-server steps: - - if: needs.changes.outputs.platform != 'true' + - if: false working-directory: . run: echo "No platform/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." - - if: needs.changes.outputs.platform == 'true' + - if: always() uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - if: needs.changes.outputs.platform == 'true' + - if: always() uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version: 'stable' - - if: needs.changes.outputs.platform == 'true' + - if: always() run: go mod download - - if: needs.changes.outputs.platform == 'true' + - if: always() run: go build ./cmd/server # CLI (molecli) moved to standalone repo: git.moleculesai.app/molecule-ai/molecule-cli - - if: needs.changes.outputs.platform == 'true' + - if: always() run: go vet ./... - - if: needs.changes.outputs.platform == 'true' + - if: always() name: Install golangci-lint run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2 - - if: needs.changes.outputs.platform == 'true' + - if: always() name: Run golangci-lint run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./... - - if: needs.changes.outputs.platform == 'true' + - if: always() name: Diagnostic — per-package verbose 60s run: | set +e @@ -192,7 +191,7 @@ jobs: echo "::endgroup::" # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. continue-on-error: true - - if: needs.changes.outputs.platform == 'true' + - if: always() name: Run tests with race detection and coverage # Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the # full ./... suite with race detection + coverage. A 10m per-step timeout @@ -200,7 +199,7 @@ jobs: # instead of OOM-killing. The job-level timeout (15m) is a backstop. run: go test -race -timeout 10m -coverprofile=coverage.out ./... - - if: needs.changes.outputs.platform == 'true' + - if: always() name: Per-file coverage report # Advisory — lists every source file with its coverage so reviewers # can see at-a-glance where gaps are. Sorted ascending so the worst @@ -214,7 +213,7 @@ jobs: END {for (f in s) printf "%6.1f%% %s\n", s[f]/c[f], f}' \ | sort -n - - if: needs.changes.outputs.platform == 'true' + - if: always() name: Check coverage thresholds # Enforces two gates from #1823 Layer 1: # 1. Total floor (25% — ratchet plan in COVERAGE_FLOOR.md). @@ -302,28 +301,28 @@ jobs: # siblings — verified empirically on PR #2314). canvas-build: name: Canvas (Next.js) - needs: changes runs-on: ubuntu-latest + timeout-minutes: 20 # Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12. continue-on-error: false defaults: run: working-directory: canvas steps: - - if: needs.changes.outputs.canvas != 'true' + - if: false working-directory: . run: echo "No canvas/** changes — skipping real build steps; this job always runs to satisfy the required-check name on branch protection." - - if: needs.changes.outputs.canvas == 'true' + - if: always() uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - if: needs.changes.outputs.canvas == 'true' + - if: always() uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: '22' - - if: needs.changes.outputs.canvas == 'true' + - if: always() run: rm -f package-lock.json && npm install - - if: needs.changes.outputs.canvas == 'true' + - if: always() run: npm run build - - if: needs.changes.outputs.canvas == 'true' + - if: always() name: Run tests with coverage # Coverage instrumentation is configured in canvas/vitest.config.ts # (provider: v8, reporters: text + html + json-summary). Step 2 of @@ -332,7 +331,7 @@ jobs: # tracked in #1815) after the team sees what current coverage is. run: npx vitest run --coverage - name: Upload coverage summary as artifact - if: needs.changes.outputs.canvas == 'true' && always() + if: always() # Pinned to v3 for Gitea act_runner v0.6 compatibility — v4+ uses # the GHES 3.10+ artifact protocol that Gitea 1.22.x does NOT # implement, surfacing as `GHESNotSupportedError: @actions/artifact @@ -349,16 +348,15 @@ jobs: # Shellcheck (E2E scripts) — required check, always runs. shellcheck: name: Shellcheck (E2E scripts) - needs: changes runs-on: ubuntu-latest # Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12. continue-on-error: false steps: - - if: needs.changes.outputs.scripts != 'true' + - if: false 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: needs.changes.outputs.scripts == 'true' + - if: always() uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - if: needs.changes.outputs.scripts == 'true' + - if: always() name: Run shellcheck on tests/e2e/*.sh and infra/scripts/*.sh # shellcheck is pre-installed on ubuntu-latest runners (via apt). # infra/scripts/ is included because setup.sh + nuke.sh gate the @@ -369,16 +367,16 @@ jobs: find tests/e2e infra/scripts -type f -name '*.sh' -print0 \ | xargs -0 shellcheck --severity=warning - - if: needs.changes.outputs.scripts == 'true' + - if: always() name: Lint cleanup-trap hygiene (RFC #2873) run: bash tests/e2e/lint_cleanup_traps.sh - - if: needs.changes.outputs.scripts == 'true' + - if: always() name: Run E2E bash unit tests (no live infra) run: | bash tests/e2e/test_model_slug.sh - - if: needs.changes.outputs.scripts == 'true' + - 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 @@ -388,7 +386,7 @@ jobs: run: | bash scripts/test-promote-tenant-image.sh - - if: needs.changes.outputs.scripts == 'true' + - 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 @@ -402,17 +400,15 @@ jobs: canvas-deploy-reminder: name: Canvas Deploy Reminder runs-on: ubuntu-latest - # mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently. - continue-on-error: true - needs: [changes, canvas-build] - # Keep the job itself always runnable. Gitea 1.22.6 leaves job-level - # event/ref `if:` gates as pending on PRs, which blocks the combined - # status even though this reminder is intentionally non-required. + # This job must run on PRs because all-required needs it. The step exits + # 0 when it is not a main push, giving branch protection a green no-op + # instead of a skipped/missing required dependency. + needs: canvas-build steps: - name: Write deploy reminder to step summary env: COMMIT_SHA: ${{ github.sha }} - CANVAS_CHANGED: ${{ needs.changes.outputs.canvas }} + CANVAS_CHANGED: "true" EVENT_NAME: ${{ github.event_name }} REF_NAME: ${{ github.ref }} # github.server_url resolves via the workflow-level env override @@ -457,7 +453,6 @@ jobs: # Python Lint & Test — required check, always runs. python-lint: name: Python Lint & Test - needs: changes runs-on: ubuntu-latest # Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12. continue-on-error: false @@ -467,25 +462,25 @@ jobs: run: working-directory: workspace steps: - - if: needs.changes.outputs.python != 'true' + - if: false working-directory: . run: echo "No workspace/** changes — skipping real lint+test; this job always runs to satisfy the required-check name on branch protection." - - if: needs.changes.outputs.python == 'true' + - if: always() uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - if: needs.changes.outputs.python == 'true' + - if: always() uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' cache: pip cache-dependency-path: workspace/requirements.txt - - if: needs.changes.outputs.python == 'true' + - if: always() run: pip install -r requirements.txt pytest pytest-asyncio pytest-cov sqlalchemy>=2.0.0 # Coverage flags + fail-under floor moved into workspace/pytest.ini # (issue #1817) so local `pytest` and CI use identical config. - - if: needs.changes.outputs.python == 'true' + - if: always() run: python -m pytest --tb=short - - if: needs.changes.outputs.python == 'true' + - if: always() name: Per-file critical-path coverage (MCP / inbox / auth) # MCP-critical Python files have a per-file floor on top of the # 86% total floor in pytest.ini. See issue #2790 for full rationale. @@ -550,85 +545,104 @@ jobs: # red silently merged through. See internal#286 for the three concrete # tonight-of-2026-05-11 incidents that prompted the emergency bump. # - # Three properties of this job each close a failure mode: + # This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a + # job-level `if: always()` + `needs:` sentinel as skipped before upstream + # jobs settle, leaving branch protection with a permanent pending + # `CI / all-required` context. Instead, this independent sentinel polls the + # required commit-status contexts for this SHA and fails if any fail, skip, + # or never emit. # - # 1. `if: always()` — runs even when an upstream fails. Without it the - # sentinel is `skipped` and protection treats that as missing → merge - # ungated. + # canvas-deploy-reminder is intentionally NOT included in all-required.needs. + # It is an informational main-push reminder, not a PR quality gate. Keeping + # it in this dependency list lets a skipped reminder skip the required + # sentinel before the `always()` guard can emit a branch-protection status. # - # 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). - # - # canvas-deploy-reminder is intentionally excluded from all-required.needs: - # it needs canvas-build, which is skipped on CI-only PRs (canvas=false). - # Including it in all-required.needs causes all-required to hang on - # every CI-only PR. Keep it runnable on PRs via its own - # `needs: [changes, canvas-build]` — the sentinel only aggregates the result. - # - # Phase 3 (RFC #219 §1) safety: underlying build jobs carry - # continue-on-error: true so their failures are masked to null (2026-05-12: re-enabled mc#774 interim) - # (Gitea suppresses status reporting for CoE jobs). This sentinel - # runs with continue-on-error: false so it always reports its - # result to the API — without this, the required-status entry - # (CI / all-required (pull_request)) is never created, which - # blocks PR merges. When Phase 3 ends, flip underlying jobs to - # continue-on-error: false; this sentinel can then be flipped to - # continue-on-error: true if a Phase-4 regression requires it. continue-on-error: false runs-on: ubuntu-latest - timeout-minutes: 1 - needs: - - changes - - platform-build - - canvas-build - - shellcheck - - python-lint - if: ${{ always() }} + timeout-minutes: 45 steps: - - name: Assert every required dependency succeeded + - name: Wait for required CI contexts + env: + GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }} + API_ROOT: ${{ github.server_url }}/api/v1 + REPOSITORY: ${{ github.repository }} + COMMIT_SHA: ${{ github.sha }} + EVENT_NAME: ${{ github.event_name }} run: | set -euo pipefail - # `needs.*.result` is one of: success | failure | cancelled | skipped | null. - # We assert success per dep (not != failure) — see RFC §2 reasoning above. - # Null results are skipped: they come from Phase 3 (continue-on-error: true - # suppresses status) or from jobs still in-flight. The sentinel succeeds - # rather than blocking PRs on Phase 3 noise. - results='${{ toJSON(needs) }}' - echo "$results" - echo "$results" | python3 -c ' - import json, sys - ns = json.load(sys.stdin) - # Phase 3 masked: jobs with continue-on-error: true may report "failure" - # Remove when mc#774 handler test failures are resolved. - PHASE3_MASKED = {"platform-build"} - # Exclude null (Phase 3 suppressed / in-flight) from the bad list. - bad = [(k, v.get("result")) for k, v in ns.items() - if v.get("result") not in ("success", None, "cancelled", "skipped") and k not in PHASE3_MASKED] - if bad: - print(f"FAIL: jobs not green:", file=sys.stderr) - for k, r in bad: - print(f" - {k}: {r}", file=sys.stderr) - sys.exit(1) - pending = [(k, v.get("result")) for k, v in ns.items() - if v.get("result") is None] - cancelled = [(k, v.get("result")) for k, v in ns.items() - if v.get("result") == "cancelled"] - if pending: - print(f"WARN: {len(pending)} job(s) still in-flight (result=null): " + - ", ".join(k for k, _ in pending), file=sys.stderr) - if cancelled: - print(f"INFO: {len(cancelled)} job(s) masked by continue-on-error: " + - ", ".join(k for k, _ in cancelled), file=sys.stderr) - print(f"OK: all {len(ns)} required jobs succeeded (or Phase-3 suppressed)") - ' + python3 - <<'PY' + import json + import os + import sys + import time + import urllib.error + import urllib.request + + token = os.environ["GITEA_TOKEN"] + api_root = os.environ["API_ROOT"].rstrip("/") + repo = os.environ["REPOSITORY"] + sha = os.environ["COMMIT_SHA"] + event = os.environ["EVENT_NAME"] + required = [ + f"CI / Detect changes ({event})", + f"CI / Platform (Go) ({event})", + f"CI / Canvas (Next.js) ({event})", + f"CI / Shellcheck (E2E scripts) ({event})", + f"CI / Python Lint & Test ({event})", + ] + terminal_bad = {"failure", "error"} + deadline = time.time() + 40 * 60 + last_summary = None + + def fetch_statuses(): + statuses = [] + for page in range(1, 6): + url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100" + req = urllib.request.Request(url, headers={"Authorization": f"token {token}"}) + with urllib.request.urlopen(req, timeout=10) as resp: + chunk = json.load(resp) + if not chunk: + break + statuses.extend(chunk) + latest = {} + for item in statuses: + ctx = item.get("context") + if not ctx: + continue + prev = latest.get(ctx) + if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""): + latest[ctx] = item + return latest + + while True: + try: + latest = fetch_statuses() + except (TimeoutError, OSError, urllib.error.URLError) as exc: + if time.time() >= deadline: + print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr) + sys.exit(1) + print(f"WARN: status poll failed, retrying: {exc}", flush=True) + time.sleep(15) + continue + states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required} + summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items()) + if summary != last_summary: + print(summary, flush=True) + last_summary = summary + bad = {ctx: state for ctx, state in states.items() if state in terminal_bad} + if bad: + print("FAIL: required CI context failed:", file=sys.stderr) + for ctx, state in bad.items(): + desc = (latest.get(ctx) or {}).get("description") or "" + print(f" - {ctx}: {state} {desc}", file=sys.stderr) + sys.exit(1) + if all(state == "success" for state in states.values()): + print(f"OK: all {len(required)} required CI contexts succeeded") + sys.exit(0) + if time.time() >= deadline: + print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr) + for ctx, state in states.items(): + print(f" - {ctx}: {state}", file=sys.stderr) + sys.exit(1) + time.sleep(15) + PY diff --git a/.gitea/workflows/e2e-api.yml b/.gitea/workflows/e2e-api.yml index 5df6efff..7678b92c 100644 --- a/.gitea/workflows/e2e-api.yml +++ b/.gitea/workflows/e2e-api.yml @@ -69,6 +69,13 @@ name: E2E API Smoke Test # 2318) shows Postgres ready in 3s, Redis in 1s, Platform in 1s when # they DO come up. Timeouts are not the bottleneck; not bumped. # +# Item #1046 (fixed 2026-05-14): Stale platform-server from cancelled runs +# lingers on :8080 after "Stop platform" step is skipped (workflow cancelled +# before reaching line 335). Added a pre-start "Kill stale platform-server" +# step (line 286) that scans /proc for zombie platform-server processes +# and kills them before the port probe or bind. Makes the ephemeral port +# probe + start sequence deterministic. +# # Item explicitly NOT fixed here: failing test `Status back online` # fails because the platform's langgraph workspace template image # (ghcr.io/molecule-ai/workspace-template-langgraph:latest) returns @@ -283,6 +290,35 @@ jobs: echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV" echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV" echo "Platform host port: ${PLATFORM_PORT}" + - name: Kill stale platform-server before start (issue #1046) + if: needs.detect-changes.outputs.api == 'true' + run: | + # Concurrent runs on the same host-network act_runner can leave a + # zombie platform-server from a cancelled/timeout run. Cancelled + # runs never reach the "Stop platform" step (line 335), so the + # old process lingers. Kill it before the ephemeral port probe + # or start so the port is definitively free. + # + # /proc scan — works on any Linux without pkill/lsof/ss. + # comm field is truncated to 15 chars: "platform-serve" matches + # "platform-server". Verify with cmdline to avoid false positives. + killed=0 + for pid in $(grep -l "platform-serve" /proc/[0-9]*/comm 2>/dev/null); do + kpid="${pid%/comm}" + kpid="${kpid##*/}" + cmdline=$(cat "/proc/${kpid}/cmdline" 2>/dev/null | tr '\0' ' ') + if echo "$cmdline" | grep -q "platform-server"; then + echo "Killing stale platform-server pid ${kpid}: ${cmdline}" + kill "$kpid" 2>/dev/null || true + killed=$((killed + 1)) + fi + done + if [ "$killed" -gt 0 ]; then + sleep 2 + echo "Killed $killed stale process(es); port(s) released." + else + echo "No stale platform-server found." + fi - name: Start platform (background) if: needs.detect-changes.outputs.api == 'true' working-directory: workspace-server @@ -346,3 +382,4 @@ jobs: run: | docker rm -f "$PG_CONTAINER" 2>/dev/null || true docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true + diff --git a/.gitea/workflows/gate-check-v3.yml b/.gitea/workflows/gate-check-v3.yml index b1175977..27aba879 100644 --- a/.gitea/workflows/gate-check-v3.yml +++ b/.gitea/workflows/gate-check-v3.yml @@ -83,25 +83,41 @@ jobs: REPO: ${{ github.repository }} 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). + # Fetch all open PRs and run gate-check on each. This scheduled + # refresher is advisory; a transient Gitea list timeout must not turn + # main red. PR-specific gate-check runs still use normal failure + # semantics. pr_numbers=$(python3 <<'PY' import json import os import socket + import sys + import time + import urllib.error import urllib.request - socket.setdefaulttimeout(15) + socket.setdefaulttimeout(30) token = os.environ["GITEA_TOKEN"] repo = os.environ["REPO"] - req = urllib.request.Request( - f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100", - headers={"Authorization": f"token {token}", "Accept": "application/json"}, - ) - with urllib.request.urlopen(req) as r: - prs = json.loads(r.read()) + url = f"https://git.moleculesai.app/api/v1/repos/{repo}/pulls?state=open&limit=100" + last_error = None + for attempt in range(1, 4): + req = urllib.request.Request( + url, + headers={"Authorization": f"token {token}", "Accept": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=30) as r: + prs = json.loads(r.read()) + break + except (TimeoutError, OSError, urllib.error.URLError, urllib.error.HTTPError) as exc: + last_error = exc + print(f"warning: PR list fetch attempt {attempt}/3 failed: {exc}", file=sys.stderr) + if attempt < 3: + time.sleep(2 * attempt) + else: + print(f"warning: skipped scheduled gate-check refresh; failed to list open PRs after 3 attempts: {last_error}", file=sys.stderr) + raise SystemExit(0) for pr in prs: print(pr["number"]) PY diff --git a/.gitea/workflows/handlers-postgres-integration.yml b/.gitea/workflows/handlers-postgres-integration.yml index 65203fc3..b590accf 100644 --- a/.gitea/workflows/handlers-postgres-integration.yml +++ b/.gitea/workflows/handlers-postgres-integration.yml @@ -86,7 +86,11 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - fetch-depth: 0 + # A full-history checkout can exceed the runner's quiet/startup + # window before the path filter emits logs. Fetch the common push + # case cheaply; the script below fetches the exact BASE SHA if it is + # not present in the shallow checkout. + fetch-depth: 2 - id: filter # Inline replacement for dorny/paths-filter — see e2e-api.yml. run: | diff --git a/.gitea/workflows/lint-continue-on-error-tracking.yml b/.gitea/workflows/lint-continue-on-error-tracking.yml index cc06bca7..8cb854bd 100644 --- a/.gitea/workflows/lint-continue-on-error-tracking.yml +++ b/.gitea/workflows/lint-continue-on-error-tracking.yml @@ -93,7 +93,7 @@ jobs: lint: name: lint-continue-on-error-tracking runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 # Phase 3 (RFC #219 §1): surface masked defects without blocking # PRs. Pre-existing continue-on-error: true directives on main # all violate this lint at first — intentional. Flip to false diff --git a/.gitea/workflows/review-refire-comments.yml b/.gitea/workflows/review-refire-comments.yml index c799c442..eb1c6b69 100644 --- a/.gitea/workflows/review-refire-comments.yml +++ b/.gitea/workflows/review-refire-comments.yml @@ -18,6 +18,10 @@ permissions: 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 diff --git a/.gitea/workflows/sop-checklist.yml b/.gitea/workflows/sop-checklist.yml index fe86219f..85ebf50a 100644 --- a/.gitea/workflows/sop-checklist.yml +++ b/.gitea/workflows/sop-checklist.yml @@ -70,7 +70,7 @@ name: sop-checklist # Cancel any in-progress runs for the same PR to prevent # stale runs from overwriting newer status contexts. concurrency: - group: ${{ github.repository }}-${{ github.event.pull_request.number }} + 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) diff --git a/.gitea/workflows/sop-tier-check.yml b/.gitea/workflows/sop-tier-check.yml index 235ed633..1f9eb888 100644 --- a/.gitea/workflows/sop-tier-check.yml +++ b/.gitea/workflows/sop-tier-check.yml @@ -61,6 +61,10 @@ on: pull_request_review: types: [submitted, dismissed, edited] +concurrency: + group: ${{ github.repository }}-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: tier-check: runs-on: ubuntu-latest diff --git a/.staging-trigger b/.staging-trigger index 270a6560..8878315c 100644 --- a/.staging-trigger +++ b/.staging-trigger @@ -1 +1 @@ -staging trigger \ No newline at end of file +staging trigger 2026-05-14T17:35:02Z diff --git a/_ci_trigger.txt b/_ci_trigger.txt new file mode 100644 index 00000000..b28fbc7a --- /dev/null +++ b/_ci_trigger.txt @@ -0,0 +1 @@ +trigger \ No newline at end of file diff --git a/canvas/src/components/ThemeToggle.tsx b/canvas/src/components/ThemeToggle.tsx index 5c8cfaec..c7dc8883 100644 --- a/canvas/src/components/ThemeToggle.tsx +++ b/canvas/src/components/ThemeToggle.tsx @@ -65,9 +65,18 @@ export function ThemeToggle({ className = "" }: { className?: string }) { // Use direct-child query to scope strictly to this radiogroup's buttons // and avoid accidentally focusing unrelated [role=radio] elements // 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; - const btns = radiogroup?.querySelectorAll("> [role=radio]"); - btns?.[next]?.focus(); + 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(); }, [] ); diff --git a/canvas/src/components/__tests__/ThemeToggle.test.tsx b/canvas/src/components/__tests__/ThemeToggle.test.tsx index 4128d3d7..08b875a4 100644 --- a/canvas/src/components/__tests__/ThemeToggle.test.tsx +++ b/canvas/src/components/__tests__/ThemeToggle.test.tsx @@ -24,8 +24,12 @@ 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(() => { - cleanup(); + act(() => { cleanup(); }); vi.clearAllMocks(); }); @@ -146,7 +150,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", ( const radios = screen.getAllByRole("radio"); // dark (index 2) is current; ArrowRight should wrap to light (index 0) act(() => { radios[2].focus(); }); - fireEvent.keyDown(radios[2], { key: "ArrowRight" }); + act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); }); expect(mockSetTheme).toHaveBeenCalledWith("light"); }); @@ -160,7 +164,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", ( const radios = screen.getAllByRole("radio"); // light (index 0) is current; ArrowLeft should go to dark (index 2) act(() => { radios[0].focus(); }); - fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); + act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); }); expect(mockSetTheme).toHaveBeenCalledWith("dark"); }); @@ -174,7 +178,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", ( const radios = screen.getAllByRole("radio"); // light (index 0) is current; ArrowDown should go to system (index 1) act(() => { radios[0].focus(); }); - fireEvent.keyDown(radios[0], { key: "ArrowDown" }); + act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); }); expect(mockSetTheme).toHaveBeenCalledWith("system"); }); @@ -187,7 +191,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", ( render(); const radios = screen.getAllByRole("radio"); act(() => { radios[2].focus(); }); - fireEvent.keyDown(radios[2], { key: "Home" }); + act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); }); expect(mockSetTheme).toHaveBeenCalledWith("light"); }); @@ -200,14 +204,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", ( render(); const radios = screen.getAllByRole("radio"); act(() => { radios[0].focus(); }); - fireEvent.keyDown(radios[0], { key: "End" }); + act(() => { fireEvent.keyDown(radios[0], { key: "End" }); }); expect(mockSetTheme).toHaveBeenCalledWith("dark"); }); it("does nothing on unrelated keys", () => { render(); const radios = screen.getAllByRole("radio"); - fireEvent.keyDown(radios[0], { key: "Enter" }); + act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); }); expect(mockSetTheme).not.toHaveBeenCalled(); }); }); diff --git a/canvas/src/components/canvas/__tests__/useOrgDeployState.test.ts b/canvas/src/components/canvas/__tests__/useOrgDeployState.test.ts new file mode 100644 index 00000000..421fcd42 --- /dev/null +++ b/canvas/src/components/canvas/__tests__/useOrgDeployState.test.ts @@ -0,0 +1,311 @@ +/** + * 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 { + return buildDeployMap(ps, new Set(deletingIds)); +} + +function s( + map: Map, + 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 }); + } + }); +}); diff --git a/canvas/src/components/mobile/MobileChat.tsx b/canvas/src/components/mobile/MobileChat.tsx index a7078255..c06b84ec 100644 --- a/canvas/src/components/mobile/MobileChat.tsx +++ b/canvas/src/components/mobile/MobileChat.tsx @@ -5,7 +5,7 @@ // that the desktop ChatTab uses, but with a slimmer surface: no // attachments, no A2A topology overlay, no conversation tracing. -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; @@ -50,26 +50,13 @@ export function MobileChat({ }) { const p = usePalette(dark); const node = useCanvasStore((s) => s.nodes.find((n) => n.id === agentId)); - // Bootstrap from the canvas store's per-workspace message buffer so the - // user sees their prior thread on entry. The store is updated by the - // socket → ChatTab flows the desktop runs; on mobile we read from the - // same buffer to keep state coherent across viewports. - // NOTE: selector returns undefined (stable) — do NOT use ?? [] here, - // that creates a new [] reference on every store update when the key is - // absent, causing infinite re-render (React error #185). - const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]); - const [messages, setMessages] = useState(() => - (storedMessages ?? []).map((m) => ({ - id: m.id, - role: "agent", - text: m.content, - ts: formatStoredTimestamp(m.timestamp), - })), - ); + const [messages, setMessages] = useState([]); const [draft, setDraft] = useState(""); const [tab, setTab] = useState("my"); const [sending, setSending] = useState(false); const [error, setError] = useState(null); + const [historyLoading, setHistoryLoading] = useState(true); + const [historyError, setHistoryError] = useState(null); const scrollRef = useRef(null); // Synchronous re-entry guard. `setSending(true)` schedules a state // update but doesn't flush before a second tap can fire send() — a ref @@ -95,6 +82,74 @@ export function MobileChat({ } }, [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) { return (
)} - {tab === "my" && messages.length === 0 && ( + {tab === "my" && historyLoading && ( +
+ Loading chat history… +
+ )} + {tab === "my" && !historyLoading && historyError && messages.length === 0 && ( +
+ {historyError} +
+ )} + {tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
Send a message to start chatting.
diff --git a/canvas/src/components/mobile/MobileSpawn.tsx b/canvas/src/components/mobile/MobileSpawn.tsx index 01c53c7c..7ee62e89 100644 --- a/canvas/src/components/mobile/MobileSpawn.tsx +++ b/canvas/src/components/mobile/MobileSpawn.tsx @@ -12,6 +12,7 @@ import { useEffect, useState } from "react"; import { api } from "@/lib/api"; import { type Template } from "@/lib/deploy-preflight"; +import { isSaaSTenant } from "@/lib/tenant"; import { tierCode } from "./palette"; import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, type MobilePalette, usePalette } from "./palette"; @@ -26,6 +27,7 @@ const TIER_LABEL: Record<"T1" | "T2" | "T3" | "T4", string> = { export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => void }) { const p = usePalette(dark); + const isSaaS = isSaaSTenant(); const [templates, setTemplates] = useState([]); const [loadingTemplates, setLoadingTemplates] = useState(true); const [tplId, setTplId] = useState(null); @@ -43,7 +45,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v setTemplates(list); if (list.length > 0) { setTplId(list[0].id); - setTier(tierCode(list[0].tier)); + setTier(isSaaS ? "T4" : tierCode(list[0].tier)); } }) .catch(() => { @@ -55,7 +57,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v return () => { cancelled = true; }; - }, []); + }, [isSaaS]); const handleSpawn = async () => { if (busy || !tplId) return; @@ -67,7 +69,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v await api.post<{ id: string }>("/workspaces", { name: (name.trim() || chosen.name), template: chosen.id, - tier: Number(tier.slice(1)), + tier: isSaaS ? 4 : Number(tier.slice(1)), canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100, @@ -203,7 +205,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v > {templates.map((t) => { const on = tplId === t.id; - const tCode = tierCode(t.tier); + const tCode = isSaaS ? "T4" : tierCode(t.tier); return (