Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fee8b9d86 | |||
| 6d802abcd1 |
@@ -274,8 +274,7 @@ def required_checks_env(audit_doc: dict) -> set[str]:
|
||||
found.append(v)
|
||||
if not found:
|
||||
sys.stderr.write(
|
||||
f"::error::REQUIRED_CHECKS env not found in any step of "
|
||||
f"{AUDIT_WORKFLOW_PATH}\n"
|
||||
f"::error::REQUIRED_CHECKS env not found in any step of {AUDIT_WORKFLOW_PATH}\n"
|
||||
)
|
||||
sys.exit(3)
|
||||
if len(found) > 1:
|
||||
@@ -385,15 +384,10 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
contexts = set(protection.get("status_check_contexts") or [])
|
||||
|
||||
# ----- F1: job exists in CI but not under sentinel.needs -----
|
||||
# Post-#1766 contract: the sentinel may deliberately have no `needs:`
|
||||
# and instead poll path-relevant statuses dynamically. In that case
|
||||
# F1 is a false positive — skip it. F1b (typos in existing needs)
|
||||
# is naturally skipped when needs is empty.
|
||||
missing_from_needs = sorted(jobs - needs)
|
||||
if missing_from_needs and needs:
|
||||
if missing_from_needs:
|
||||
findings.append(
|
||||
"F1 — jobs in ci.yml NOT under sentinel `needs:` "
|
||||
"(sentinel doesn't gate them):\n"
|
||||
"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)
|
||||
)
|
||||
|
||||
@@ -403,8 +397,7 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
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"
|
||||
"F1b — sentinel `needs:` lists jobs NOT present in ci.yml (typo or removed job):\n"
|
||||
+ "\n".join(f" - {n}" for n in stale_needs)
|
||||
)
|
||||
|
||||
@@ -412,9 +405,7 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
# Compute the contexts the CI YAML actually produces. The sentinel
|
||||
# is in (B) intentionally (`ci / all-required (pull_request)`); we
|
||||
# whitelist it explicitly.
|
||||
emitted_contexts = {
|
||||
expected_context(j) for j in jobs
|
||||
} | {expected_context(SENTINEL_JOB)}
|
||||
emitted_contexts = {expected_context(j) for j in jobs} | {expected_context(SENTINEL_JOB)}
|
||||
# Contexts NOT produced by ci.yml may still come from other
|
||||
# workflows in the repo (Secret scan etc). We can't enumerate
|
||||
# every workflow's emissions cheaply; instead, flag only contexts
|
||||
@@ -427,9 +418,8 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
)
|
||||
if stale_protection:
|
||||
findings.append(
|
||||
"F2 — protection `status_check_contexts` entries with `ci / ` "
|
||||
"prefix that NO job in ci.yml emits "
|
||||
"(stale name → silent advisory gate):\n"
|
||||
"F2 — protection `status_check_contexts` entries with `ci / ` prefix that NO "
|
||||
"job in ci.yml emits (stale name → silent advisory gate):\n"
|
||||
+ "\n".join(f" - {c}" for c in stale_protection)
|
||||
)
|
||||
|
||||
@@ -504,8 +494,7 @@ def render_body(branch: str, findings: list[str], debug: dict) -> str:
|
||||
f"# Drift detected on `{REPO}/{branch}`",
|
||||
"",
|
||||
"Auto-filed by `.gitea/workflows/ci-required-drift.yml` "
|
||||
"(RFC [internal#219]"
|
||||
"(https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
|
||||
"(RFC [internal#219](https://git.moleculesai.app/molecule-ai/internal/issues/219) §4 + §6).",
|
||||
"",
|
||||
"## Findings",
|
||||
"",
|
||||
@@ -516,11 +505,8 @@ def render_body(branch: str, findings: list[str], debug: dict) -> str:
|
||||
"",
|
||||
"## Resolution",
|
||||
"",
|
||||
"- **F1 / F1b**: if the sentinel job has a `needs:` block, add "
|
||||
"the missing job to it in `.gitea/workflows/ci.yml`, or remove "
|
||||
"the stale entry. If the sentinel deliberately has no `needs:` "
|
||||
"(path-aware polling sentinel per post-#1766 contract), this "
|
||||
"finding is expected and F1 is skipped.",
|
||||
"- **F1 / F1b**: add the missing job to `all-required.needs:` "
|
||||
"in `.gitea/workflows/ci.yml`, or remove the stale entry.",
|
||||
"- **F2**: rename the protection context to match an emitter, "
|
||||
"or remove it from `status_check_contexts` "
|
||||
"(PATCH `/api/v1/repos/{owner}/{repo}/branch_protections/{branch}`).",
|
||||
@@ -561,12 +547,12 @@ def file_or_update(
|
||||
|
||||
if dry_run:
|
||||
print(f"::notice::[dry-run] would file/update drift issue for {branch}")
|
||||
print("::group::[dry-run] title")
|
||||
print(f"::group::[dry-run] title")
|
||||
print(title)
|
||||
print("::endgroup::")
|
||||
print("::group::[dry-run] body")
|
||||
print(f"::endgroup::")
|
||||
print(f"::group::[dry-run] body")
|
||||
print(body)
|
||||
print("::endgroup::")
|
||||
print(f"::endgroup::")
|
||||
return
|
||||
|
||||
existing = find_open_issue(title)
|
||||
|
||||
@@ -15,6 +15,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PROFILES: dict[str, dict[str, str]] = {
|
||||
"ci": {
|
||||
"platform": r"^workspace-server/",
|
||||
@@ -152,10 +153,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser.add_argument("--event-name", default=os.environ.get("GITHUB_EVENT_NAME", ""))
|
||||
parser.add_argument("--pr-base-sha", default="")
|
||||
parser.add_argument("--base-ref", default="")
|
||||
parser.add_argument(
|
||||
"--push-before",
|
||||
default=os.environ.get("GITHUB_EVENT_BEFORE", ""),
|
||||
)
|
||||
parser.add_argument("--push-before", default=os.environ.get("GITHUB_EVENT_BEFORE", ""))
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
|
||||
@@ -183,9 +183,7 @@ def required_contexts_green(
|
||||
status = latest_statuses.get(context)
|
||||
state = status_state(status or {})
|
||||
if state != "success":
|
||||
if pr_labels and _is_tier_low_pending_ok(
|
||||
latest_statuses, context, pr_labels
|
||||
):
|
||||
if pr_labels and _is_tier_low_pending_ok(latest_statuses, context, pr_labels):
|
||||
continue # tier:low soft-fail: accept pending sop-checklist
|
||||
missing_or_bad.append(f"{context}={state or 'missing'}")
|
||||
return not missing_or_bad, missing_or_bad
|
||||
|
||||
@@ -13,9 +13,11 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import glob
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
SELF = ".gitea/workflows/lint-curl-status-capture.yml"
|
||||
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ def _ensure_labels(repo: str, names: list[str]) -> list[int]:
|
||||
if status != "ok" or not isinstance(labels, list):
|
||||
return []
|
||||
out: list[int] = []
|
||||
by_name = {label["name"]: label["id"] for label in labels if isinstance(label, dict)}
|
||||
by_name = {l["name"]: l["id"] for l in labels if isinstance(l, dict)}
|
||||
for n in names:
|
||||
if n in by_name:
|
||||
out.append(by_name[n])
|
||||
|
||||
@@ -82,7 +82,7 @@ import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -641,15 +641,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
base_workflows = workflows_at_sha(BASE_SHA)
|
||||
head_workflows = workflows_at_sha(HEAD_SHA)
|
||||
# Ignore workflow files that are identical on both sides — old branches
|
||||
# that haven't rebased onto main carry stale copies of workflows that
|
||||
# were updated later. Comparing those stale copies against the current
|
||||
# base produces false-positive "flips".
|
||||
base_workflows = {
|
||||
p: t for p, t in base_workflows.items()
|
||||
if p in head_workflows and head_workflows[p] != t
|
||||
}
|
||||
head_workflows = {p: t for p, t in head_workflows.items() if p in base_workflows}
|
||||
flips = detect_flips(base_workflows, head_workflows)
|
||||
|
||||
if not flips:
|
||||
|
||||
@@ -90,15 +90,6 @@ API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
# match by exact title without parsing.
|
||||
TITLE_PREFIX = "[main-red]"
|
||||
|
||||
# Contexts that are scheduled or non-required — their pending/failure
|
||||
# state should not block stale-issue closeout (mc#1789).
|
||||
SCHEDULED_CONTEXT_PATTERNS = (
|
||||
"Staging SaaS smoke",
|
||||
"Continuous synthetic E2E",
|
||||
"main-red-watchdog",
|
||||
"ci-arm64-advisory",
|
||||
)
|
||||
|
||||
# Settling window (seconds) between initial red detection and the
|
||||
# pre-file recheck. The recheck filters out the two largest false-
|
||||
# positive classes seen in mc#1597..1630 (task #394, 2026-05-21):
|
||||
@@ -274,11 +265,6 @@ def get_combined_status(sha: str) -> dict:
|
||||
return body
|
||||
|
||||
|
||||
def _entry_state(s: dict) -> str:
|
||||
"""Per-entry status key in Gitea 1.22.6 is `status`; fall back to `state`."""
|
||||
return s.get("status") or s.get("state") or ""
|
||||
|
||||
|
||||
def is_red(status: dict) -> tuple[bool, list[dict]]:
|
||||
"""Return (is_red, failed_statuses).
|
||||
|
||||
@@ -326,6 +312,9 @@ def is_red(status: dict) -> tuple[bool, list[dict]]:
|
||||
# "no per-context entries were in a red state" fallback even when
|
||||
# the combined-state correctly flagged red. See
|
||||
# `feedback_smoke_test_vendor_truth_not_shape_match`.
|
||||
def _entry_state(s: dict) -> str:
|
||||
return s.get("status") or s.get("state") or ""
|
||||
|
||||
def _is_cancel_cascade(s: dict) -> bool:
|
||||
"""status=3 entry per Gitea 1.22.6 description-string contract.
|
||||
Match exactly (after strip) — substring match would catch
|
||||
@@ -364,15 +353,6 @@ def title_for(sha: str) -> str:
|
||||
return f"{TITLE_PREFIX} {REPO}: {sha[:10]}"
|
||||
|
||||
|
||||
def _is_scheduled_context(context: str) -> bool:
|
||||
"""Return True if `context` is a known scheduled/non-required job.
|
||||
|
||||
These contexts run on a schedule and should not block stale-issue
|
||||
closeout when main's required CI has recovered (mc#1789).
|
||||
"""
|
||||
return any(pattern.lower() in context.lower() for pattern in SCHEDULED_CONTEXT_PATTERNS)
|
||||
|
||||
|
||||
def list_open_red_issues() -> list[dict]:
|
||||
"""All open issues whose title starts with `[main-red] {repo}: `.
|
||||
|
||||
@@ -382,34 +362,23 @@ def list_open_red_issues() -> list[dict]:
|
||||
file-or-update path to POST a duplicate — exactly the regression
|
||||
class the helper-raises contract closes.
|
||||
|
||||
Pagination is exhausted (mc#1789). The old "by design ≤ 1" invariant
|
||||
was false — backlog can exceed 50 open issues.
|
||||
Gitea issue search returns at most 50/page; we only need open
|
||||
`[main-red]` issues which are by design ≤ 1 at any time per repo,
|
||||
so a single page is enough.
|
||||
"""
|
||||
prefix = f"{TITLE_PREFIX} {REPO}: "
|
||||
all_issues: list[dict] = []
|
||||
page = 1
|
||||
limit = 50
|
||||
while True:
|
||||
_, results = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/issues",
|
||||
query={"state": "open", "type": "issues", "limit": str(limit), "page": str(page)},
|
||||
_, results = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/issues",
|
||||
query={"state": "open", "type": "issues", "limit": "50"},
|
||||
)
|
||||
if not isinstance(results, list):
|
||||
raise ApiError(
|
||||
f"issue search returned non-list body (got {type(results).__name__})"
|
||||
)
|
||||
if not isinstance(results, list):
|
||||
raise ApiError(
|
||||
f"issue search returned non-list body (got {type(results).__name__})"
|
||||
)
|
||||
matched = [
|
||||
i for i in results
|
||||
if isinstance(i, dict)
|
||||
prefix = f"{TITLE_PREFIX} {REPO}: "
|
||||
return [i for i in results if isinstance(i, dict)
|
||||
and isinstance(i.get("title"), str)
|
||||
and i["title"].startswith(prefix)
|
||||
]
|
||||
all_issues.extend(matched)
|
||||
if len(results) < limit:
|
||||
break
|
||||
page += 1
|
||||
return all_issues
|
||||
and i["title"].startswith(prefix)]
|
||||
|
||||
|
||||
def find_open_issue_for_sha(sha: str) -> dict | None:
|
||||
@@ -605,156 +574,10 @@ def file_or_update_red(
|
||||
sys.stderr.write(f"::warning::label '{RED_LABEL}' not found on repo\n")
|
||||
|
||||
|
||||
def close_stale_red_issues(
|
||||
current_sha: str,
|
||||
current_status: dict,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> int:
|
||||
"""Close open [main-red] issues whose specific failing contexts have
|
||||
all recovered on `current_sha`, even though `main` is still red for
|
||||
other reasons (mc#1789).
|
||||
|
||||
When main stays red across consecutive SHAs for *different* causes,
|
||||
`close_open_red_issues_for_other_shas` never fires (it only runs when
|
||||
main is green). This function prevents stale issues from accumulating
|
||||
indefinitely by comparing per-context recovery across SHAs.
|
||||
|
||||
An issue is considered stale when every context that was in a failed
|
||||
state on the issue's SHA is now either `success` on the current HEAD
|
||||
or absent (workflow removed / renamed). Issues whose original SHA had
|
||||
a combined-red-with-no-detail (empty statuses list) are skipped — we
|
||||
cannot verify recovery without per-context data.
|
||||
|
||||
Returns the number of issues closed.
|
||||
"""
|
||||
open_red = list_open_red_issues()
|
||||
if not open_red:
|
||||
return 0
|
||||
|
||||
current_statuses = current_status.get("statuses") or []
|
||||
closed = 0
|
||||
|
||||
for issue in open_red:
|
||||
title = issue.get("title", "")
|
||||
prefix = f"{TITLE_PREFIX} {REPO}: "
|
||||
if not title.startswith(prefix):
|
||||
continue
|
||||
short_sha = title[len(prefix):]
|
||||
if short_sha == current_sha[:10]:
|
||||
continue
|
||||
|
||||
# Query status for the old SHA. Short SHA should resolve; if it
|
||||
# doesn't (GC'd, force-pushed, ambiguous), skip conservatively.
|
||||
try:
|
||||
old_status = get_combined_status(short_sha)
|
||||
except ApiError:
|
||||
continue
|
||||
|
||||
old_red, old_failed = is_red(old_status)
|
||||
if not old_red:
|
||||
# Open issue for a now-green SHA — close it via the normal path.
|
||||
num = issue.get("number")
|
||||
if isinstance(num, int):
|
||||
comment = (
|
||||
f"Commit `{short_sha}` is no longer red. Closing as the "
|
||||
f"failure context has recovered or expired."
|
||||
)
|
||||
if dry_run:
|
||||
print(
|
||||
f"::notice::[dry-run] would close issue #{num} "
|
||||
f"({title}) — old SHA is now green"
|
||||
)
|
||||
closed += 1
|
||||
continue
|
||||
api(
|
||||
"POST",
|
||||
f"/repos/{OWNER}/{NAME}/issues/{num}/comments",
|
||||
body={"body": comment},
|
||||
)
|
||||
api(
|
||||
"PATCH",
|
||||
f"/repos/{OWNER}/{NAME}/issues/{num}",
|
||||
body={"state": "closed"},
|
||||
)
|
||||
print(
|
||||
f"::notice::Closed stale main-red issue #{num} "
|
||||
f"(old SHA {short_sha} is now green)"
|
||||
)
|
||||
closed += 1
|
||||
continue
|
||||
|
||||
if not old_failed:
|
||||
# Combined red with no per-context detail — can't verify recovery.
|
||||
continue
|
||||
|
||||
# Verify every failed context from the old SHA has recovered.
|
||||
all_recovered = True
|
||||
recovered_ctxs: list[str] = []
|
||||
still_failing_ctxs: list[str] = []
|
||||
for s in old_failed:
|
||||
ctx = s.get("context", "")
|
||||
if not ctx:
|
||||
continue
|
||||
current_match = None
|
||||
for cs in current_statuses:
|
||||
if isinstance(cs, dict) and cs.get("context") == ctx:
|
||||
current_match = cs
|
||||
break
|
||||
if current_match is None:
|
||||
recovered_ctxs.append(ctx)
|
||||
elif _entry_state(current_match) == "success":
|
||||
recovered_ctxs.append(ctx)
|
||||
else:
|
||||
all_recovered = False
|
||||
still_failing_ctxs.append(ctx)
|
||||
|
||||
if not all_recovered:
|
||||
continue
|
||||
|
||||
num = issue.get("number")
|
||||
if not isinstance(num, int):
|
||||
continue
|
||||
|
||||
comment = (
|
||||
f"The failing contexts from this SHA (`{short_sha}`) have "
|
||||
f"recovered on current HEAD `{current_sha[:10]}`: "
|
||||
f"{', '.join(recovered_ctxs)}. "
|
||||
f"Main is still red for other reasons; see the current "
|
||||
f"`[main-red]` issue for `{current_sha[:10]}`."
|
||||
)
|
||||
if dry_run:
|
||||
print(
|
||||
f"::notice::[dry-run] would close stale issue #{num} "
|
||||
f"({title}) — contexts recovered"
|
||||
)
|
||||
closed += 1
|
||||
continue
|
||||
|
||||
api(
|
||||
"POST",
|
||||
f"/repos/{OWNER}/{NAME}/issues/{num}/comments",
|
||||
body={"body": comment},
|
||||
)
|
||||
api(
|
||||
"PATCH",
|
||||
f"/repos/{OWNER}/{NAME}/issues/{num}",
|
||||
body={"state": "closed"},
|
||||
)
|
||||
print(
|
||||
f"::notice::Closed stale main-red issue #{num} "
|
||||
f"(contexts recovered at {current_sha[:10]})"
|
||||
)
|
||||
closed += 1
|
||||
|
||||
return closed
|
||||
|
||||
|
||||
def close_open_red_issues_for_other_shas(
|
||||
current_sha: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
close_same_sha: bool = False,
|
||||
) -> int:
|
||||
"""When main is green at current_sha, close any open `[main-red]`
|
||||
issues whose title references a different SHA. Returns the number
|
||||
@@ -763,25 +586,15 @@ def close_open_red_issues_for_other_shas(
|
||||
Lineage note: we only close issues whose title prefix matches; if
|
||||
a human renamed the issue or added a suffix this won't touch it.
|
||||
That's intentional — manual editorial state takes precedence.
|
||||
|
||||
Args:
|
||||
close_same_sha: set True when the caller already knows main is
|
||||
green at current_sha (e.g. recovery block) and wants to close
|
||||
the open issue for THIS SHA too. Defaults False so the
|
||||
green-path callers never accidentally close an issue they just
|
||||
filed on the same tick.
|
||||
"""
|
||||
target_title = title_for(current_sha)
|
||||
open_red = list_open_red_issues()
|
||||
closed = 0
|
||||
for issue in open_red:
|
||||
if issue.get("title") == target_title:
|
||||
if not close_same_sha:
|
||||
# Same SHA — caller should not have invoked this if main is
|
||||
# green. Skip defensively (guards against green-path callers
|
||||
# that accidentally pass the SHA they just filed for).
|
||||
continue
|
||||
# close_same_sha=True: close even this SHA's issue (recovery path)
|
||||
# Same SHA — caller should not have invoked this if main is
|
||||
# green. Skip defensively.
|
||||
continue
|
||||
num = issue.get("number")
|
||||
if not isinstance(num, int):
|
||||
continue
|
||||
@@ -886,10 +699,6 @@ def run_once(*, dry_run: bool = False) -> int:
|
||||
f"{sha[:10]} but HEAD is now {recheck_sha[:10]} on "
|
||||
f"{WATCH_BRANCH}; next cron tick will re-evaluate."
|
||||
)
|
||||
# HEAD drifted — close any stale main-red issue for the prior SHA
|
||||
# before returning, so we don't leave stale open issues when main
|
||||
# is no longer pointing at the red commit.
|
||||
close_open_red_issues_for_other_shas(recheck_sha, dry_run=dry_run)
|
||||
return 0
|
||||
|
||||
recheck_status = get_combined_status(sha)
|
||||
@@ -902,9 +711,6 @@ def run_once(*, dry_run: bool = False) -> int:
|
||||
f"{recheck_status.get('state')!r} on recheck; "
|
||||
f"initial red was a transient cancel-cascade."
|
||||
)
|
||||
# CI recovered on the same SHA — close any stale main-red issue
|
||||
# that was filed on a prior tick for this SHA.
|
||||
close_open_red_issues_for_other_shas(sha, dry_run=dry_run, close_same_sha=True)
|
||||
return 0
|
||||
|
||||
# Still red after settling — file/update. Use the recheck data
|
||||
@@ -920,68 +726,24 @@ def run_once(*, dry_run: bool = False) -> int:
|
||||
print(f"::warning::main is RED at {sha[:10]} on {WATCH_BRANCH}: "
|
||||
f"{len(failed)} failed context(s)")
|
||||
file_or_update_red(sha, failed, debug, dry_run=dry_run)
|
||||
stale_closed = close_stale_red_issues(sha, recheck_status, dry_run=dry_run)
|
||||
if stale_closed:
|
||||
emit_loki_event("main_red_stale_closed", sha, [])
|
||||
print(
|
||||
f"::notice::Closed {stale_closed} stale main-red issue(s) "
|
||||
f"whose contexts recovered at {sha[:10]}"
|
||||
)
|
||||
else:
|
||||
# Green or pending-with-no-real-failures. Close stale issues
|
||||
# from earlier SHAs when required CI has recovered.
|
||||
#
|
||||
# mc#1789: main often sits at combined `pending` because
|
||||
# scheduled/non-required contexts (Staging SaaS smoke,
|
||||
# Continuous synthetic E2E, main-red-watchdog itself,
|
||||
# ci-arm64-advisory) are still running. We close stale issues
|
||||
# as long as no *non-scheduled* context has failed and no
|
||||
# *non-scheduled* context is still pending — i.e. required CI
|
||||
# is effectively green.
|
||||
#
|
||||
# The success-only gate is preserved for the canonical green
|
||||
# path; the extended check below only fires when combined is
|
||||
# `pending` but all required work is done.
|
||||
combined_state = status.get("state")
|
||||
if combined_state == "success":
|
||||
should_close = True
|
||||
close_reason = "GREEN"
|
||||
else:
|
||||
statuses = status.get("statuses") or []
|
||||
non_scheduled_pending = [
|
||||
s for s in statuses
|
||||
if isinstance(s, dict)
|
||||
and (_entry_state(s) == "pending")
|
||||
and not _is_scheduled_context(s.get("context", ""))
|
||||
]
|
||||
non_scheduled_failed = [
|
||||
s for s in statuses
|
||||
if isinstance(s, dict)
|
||||
and (_entry_state(s) in {"failure", "error"})
|
||||
and not _is_scheduled_context(s.get("context", ""))
|
||||
]
|
||||
# Cancel-cascade already filtered by is_red(); red=False
|
||||
# here means no real failures. We additionally check that
|
||||
# no non-scheduled context is still pending.
|
||||
should_close = not non_scheduled_pending and not non_scheduled_failed
|
||||
close_reason = "pending-but-required-green"
|
||||
|
||||
if should_close:
|
||||
# Green (or pending — pending is treated as not-red so we don't
|
||||
# spam during the post-merge CI window). Close any stale issues
|
||||
# from earlier SHAs only when we're actually green; pending
|
||||
# means CI hasn't finished and the prior issue might still be
|
||||
# accurate.
|
||||
if status.get("state") == "success":
|
||||
closed = close_open_red_issues_for_other_shas(sha, dry_run=dry_run)
|
||||
if closed:
|
||||
emit_loki_event(
|
||||
"main_returned_to_green", sha,
|
||||
[],
|
||||
)
|
||||
print(
|
||||
f"::notice::main is {close_reason} at {sha[:10]} on {WATCH_BRANCH} "
|
||||
f"(closed {closed} stale issue(s))"
|
||||
)
|
||||
print(f"::notice::main is GREEN at {sha[:10]} on {WATCH_BRANCH} "
|
||||
f"(closed {closed} stale issue(s))")
|
||||
else:
|
||||
print(
|
||||
f"::notice::main has pending-or-failed required CI at {sha[:10]} "
|
||||
f"on {WATCH_BRANCH} (combined state={combined_state!r}; no action)"
|
||||
)
|
||||
print(f"::notice::main is PENDING at {sha[:10]} on {WATCH_BRANCH} "
|
||||
f"(combined state={status.get('state')!r}; no action)")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
TRUE_VALUES = {"1", "true", "yes", "on", "disabled", "disable"}
|
||||
PROD_CP_URL = "https://api.moleculesai.app"
|
||||
DEFAULT_REQUIRED_CONTEXTS = [
|
||||
@@ -24,7 +25,6 @@ DEFAULT_REQUIRED_CONTEXTS = [
|
||||
"Secret scan / Scan diff for credential-shaped strings (push)",
|
||||
]
|
||||
TERMINAL_FAILURE_STATES = {"failure", "error", "cancelled", "canceled", "skipped"}
|
||||
REDEPLOY_PATH = "/cp/admin/tenants/redeploy-fleet"
|
||||
|
||||
|
||||
def truthy_flag(value: str | None) -> bool:
|
||||
@@ -130,154 +130,6 @@ def required_contexts(env: dict[str, str]) -> list[str]:
|
||||
return [line.strip() for line in raw.replace(",", "\n").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def chunks(items: list[str], size: int) -> list[list[str]]:
|
||||
return [items[i : i + size] for i in range(0, len(items), size)]
|
||||
|
||||
|
||||
class RolloutFailed(RuntimeError):
|
||||
def __init__(self, message: str, response: dict):
|
||||
super().__init__(message)
|
||||
self.response = response
|
||||
|
||||
|
||||
def slugs_from_redeploy_response(body: dict) -> list[str]:
|
||||
slugs: list[str] = []
|
||||
for row in body.get("results") or []:
|
||||
slug = str(row.get("slug") or "").strip()
|
||||
if slug:
|
||||
slugs.append(slug)
|
||||
return slugs
|
||||
|
||||
|
||||
def scoped_redeploy_body(base: dict, slugs: list[str]) -> dict:
|
||||
body = dict(base)
|
||||
body.pop("canary_slug", None)
|
||||
body["only_slugs"] = slugs
|
||||
body["soak_seconds"] = 0
|
||||
body["batch_size"] = max(1, len(slugs))
|
||||
return body
|
||||
|
||||
|
||||
def cp_api_json(method: str, url: str, token: str, body: dict | None = None) -> tuple[int, dict]:
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=120) as resp:
|
||||
return resp.status, json.loads(resp.read())
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
parsed = {"error": raw[:500]}
|
||||
return exc.code, parsed
|
||||
|
||||
|
||||
def plan_rollout_slugs(cp_url: str, token: str, body: dict, redeploy=None) -> list[str]:
|
||||
if redeploy is None:
|
||||
redeploy = redeploy_scoped
|
||||
dry_run_body = dict(body)
|
||||
dry_run_body["dry_run"] = True
|
||||
status, resp = redeploy(cp_url, token, dry_run_body)
|
||||
if status != 200:
|
||||
raise RuntimeError(f"dry-run redeploy-fleet returned HTTP {status}: {resp.get('error', '')}")
|
||||
if resp.get("ok") is not True:
|
||||
raise RuntimeError(f"dry-run redeploy-fleet reported ok={resp.get('ok')}: {resp.get('error', '')}")
|
||||
slugs = slugs_from_redeploy_response(resp)
|
||||
if not slugs:
|
||||
raise RuntimeError("dry-run redeploy-fleet returned no rollout candidates")
|
||||
return slugs
|
||||
|
||||
|
||||
def redeploy_scoped(cp_url: str, token: str, body: dict) -> tuple[int, dict]:
|
||||
return cp_api_json("POST", f"{cp_url}{REDEPLOY_PATH}", token, body)
|
||||
|
||||
|
||||
def _raise_for_redeploy_result(status: int, body: dict, slugs: list[str]) -> None:
|
||||
if status != 200 or body.get("ok") is not True:
|
||||
raise RuntimeError(
|
||||
"redeploy scoped call failed for "
|
||||
f"{','.join(slugs)}: HTTP {status}, ok={body.get('ok')}"
|
||||
)
|
||||
|
||||
|
||||
def execute_scoped_rollout(
|
||||
plan: dict,
|
||||
token: str,
|
||||
list_slugs=plan_rollout_slugs,
|
||||
redeploy=redeploy_scoped,
|
||||
sleep=time.sleep,
|
||||
) -> dict:
|
||||
cp_url = plan["cp_url"]
|
||||
base_body = plan["body"]
|
||||
all_slugs = list_slugs(cp_url, token, base_body)
|
||||
batch_size = int(base_body.get("batch_size") or 1)
|
||||
canary_slug = str(base_body.get("canary_slug") or "").strip()
|
||||
dry_run = bool(base_body.get("dry_run"))
|
||||
aggregate = {"ok": True, "results": []}
|
||||
|
||||
if canary_slug:
|
||||
if canary_slug not in all_slugs:
|
||||
raise RuntimeError(f"configured canary slug {canary_slug!r} is not a running tenant")
|
||||
body = scoped_redeploy_body(base_body, [canary_slug])
|
||||
print(f"POST {cp_url}{REDEPLOY_PATH} only_slugs={','.join(body['only_slugs'])}")
|
||||
status, resp = redeploy(cp_url, token, body)
|
||||
aggregate["results"].extend(resp.get("results") or [])
|
||||
try:
|
||||
_raise_for_redeploy_result(status, resp, [canary_slug])
|
||||
except RuntimeError as exc:
|
||||
aggregate["ok"] = False
|
||||
aggregate["error"] = str(exc)
|
||||
raise RolloutFailed(str(exc), aggregate) from exc
|
||||
soak_seconds = int(base_body.get("soak_seconds") or 0)
|
||||
if soak_seconds > 0 and not dry_run:
|
||||
print(f"Canary passed; soaking locally for {soak_seconds}s")
|
||||
sleep(soak_seconds)
|
||||
|
||||
remaining = [slug for slug in all_slugs if slug != canary_slug]
|
||||
for group in chunks(remaining, batch_size):
|
||||
body = scoped_redeploy_body(base_body, group)
|
||||
print(f"POST {cp_url}{REDEPLOY_PATH} only_slugs={','.join(group)}")
|
||||
status, resp = redeploy(cp_url, token, body)
|
||||
aggregate["results"].extend(resp.get("results") or [])
|
||||
try:
|
||||
_raise_for_redeploy_result(status, resp, group)
|
||||
except RuntimeError as exc:
|
||||
aggregate["ok"] = False
|
||||
aggregate["error"] = str(exc)
|
||||
raise RolloutFailed(str(exc), aggregate) from exc
|
||||
|
||||
return aggregate
|
||||
|
||||
|
||||
def rollout_from_plan_file(plan_path: str, response_path: str, env: dict[str, str]) -> None:
|
||||
token = env.get("CP_ADMIN_API_TOKEN", "").strip()
|
||||
if not token:
|
||||
raise ValueError("CP_ADMIN_API_TOKEN is required for production auto-deploy")
|
||||
with open(plan_path, "r", encoding="utf-8") as fh:
|
||||
plan = json.load(fh)
|
||||
if not plan.get("enabled"):
|
||||
raise RuntimeError("production auto-deploy plan is disabled")
|
||||
try:
|
||||
response = execute_scoped_rollout(plan, token)
|
||||
except RolloutFailed as exc:
|
||||
response = exc.response
|
||||
with open(response_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(response, fh, sort_keys=True)
|
||||
fh.write("\n")
|
||||
raise
|
||||
with open(response_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(response, fh, sort_keys=True)
|
||||
fh.write("\n")
|
||||
|
||||
|
||||
def _api_json(url: str, token: str) -> dict:
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
try:
|
||||
@@ -379,9 +231,6 @@ def main() -> int:
|
||||
sub.add_parser("plan", help="print production deploy plan as JSON")
|
||||
sub.add_parser("assert-enabled", help="fail if production deploy is currently disabled")
|
||||
sub.add_parser("wait-ci", help="block until required CI context is green")
|
||||
rollout_parser = sub.add_parser("rollout", help="execute canary-first scoped production rollout")
|
||||
rollout_parser.add_argument("--plan", required=True, help="path to prod-auto-deploy plan JSON")
|
||||
rollout_parser.add_argument("--response", required=True, help="path to write aggregate response JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
@@ -394,9 +243,6 @@ def main() -> int:
|
||||
if args.command == "wait-ci":
|
||||
wait_for_ci_context(dict(os.environ))
|
||||
return 0
|
||||
if args.command == "rollout":
|
||||
rollout_from_plan_file(args.plan, args.response, dict(os.environ))
|
||||
return 0
|
||||
except Exception as exc: # noqa: BLE001 - CLI should render operator-friendly errors.
|
||||
print(f"::error::{exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
# ≥ 1 review on the PR where:
|
||||
# • state == APPROVED
|
||||
# • review.dismissed == false
|
||||
# • review.official != false (excludes draft/mis-filed APPROVED reviews)
|
||||
# • review.user.login != PR.user.login (non-author)
|
||||
# • review.user.login ∈ team-members
|
||||
#
|
||||
@@ -202,7 +201,6 @@ fi
|
||||
JQ_FILTER='.[]
|
||||
| select(.state == "APPROVED")
|
||||
| select(.dismissed != true)
|
||||
| select(.official != false)
|
||||
| select(.user.login != $author)'
|
||||
if [ "${REVIEW_CHECK_STRICT:-}" = "1" ]; then
|
||||
JQ_FILTER="${JQ_FILTER}
|
||||
@@ -306,15 +304,12 @@ for U in $CANDIDATES; do
|
||||
exit 0
|
||||
;;
|
||||
403)
|
||||
# Token owner is not in the team being probed; Gitea 1.22.6 refuses
|
||||
# to confirm membership in this case. Do NOT hard-fail the gate on a
|
||||
# 403 — doing so would fail the entire gate if ANY candidate triggers
|
||||
# a 403, even when other valid team-members exist. Instead skip this
|
||||
# candidate and continue checking others. If all candidates produce
|
||||
# 403 (token owner can't query any of them) the final exit fires.
|
||||
echo "::warning::team-probe for ${U} in ${TEAM} returned 403 (token owner not in ${TEAM} team — skipping; cannot confirm membership)"
|
||||
# Token owner is not in the team being probed; the API refuses to
|
||||
# confirm membership. This is the RFC#324 follow-up token-scope gap.
|
||||
# Fail closed — never grant approval on a 403; surface clearly.
|
||||
echo "::error::team-probe for ${U} in ${TEAM} returned 403 (token owner not in ${TEAM} team — RFC#324 token-scope follow-up). Cannot confirm membership; failing closed."
|
||||
cat "$TEAM_PROBE_TMP" >&2
|
||||
continue
|
||||
exit 1
|
||||
;;
|
||||
404)
|
||||
debug "${U} not a member of ${TEAM}"
|
||||
|
||||
@@ -338,6 +338,7 @@ 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():
|
||||
@@ -636,13 +637,8 @@ def load_config(path: str) -> dict[str, Any]:
|
||||
dep by keeping the config shape constrained.
|
||||
"""
|
||||
try:
|
||||
# yaml is an optional dep; the canonical loader is used when available,
|
||||
# but the SOP runs on runners that may not have PyYAML installed. The
|
||||
# fallback _load_config_minimal covers the same config shape without
|
||||
# requiring the dep, so the ignore is safe: if yaml loads, we use it;
|
||||
# otherwise we fall back silently.
|
||||
import yaml # type: ignore[import-not-found]
|
||||
with open(path, encoding="utf-8") as f:
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
except ImportError:
|
||||
return _load_config_minimal(path)
|
||||
@@ -656,19 +652,13 @@ def _load_config_minimal(path: str) -> dict[str, Any]:
|
||||
item map: scalars + lists of scalars. Does NOT support nested lists,
|
||||
YAML anchors, multi-doc, or flow style.
|
||||
"""
|
||||
with open(path, encoding="utf-8") as f:
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
return _parse_minimal_yaml(lines)
|
||||
|
||||
|
||||
def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]:
|
||||
"""Hand-rolled subset parser. See _load_config_minimal docstring.
|
||||
|
||||
C901: function is necessarily long — it implements a finite-state YAML
|
||||
subset (scalars, maps, lists of maps at fixed depth). No utility refactors
|
||||
meaningfully reduce length without degrading readability. All branches
|
||||
are exhaustively tested in test_parse_minimal_yaml.py.
|
||||
"""
|
||||
def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
|
||||
"""Hand-rolled subset parser. See _load_config_minimal docstring."""
|
||||
# Strip comments + blank lines but preserve indentation.
|
||||
cleaned: list[tuple[int, str]] = []
|
||||
for raw in lines:
|
||||
@@ -852,7 +842,7 @@ def render_status(
|
||||
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
|
||||
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
|
||||
labels = pr.get("labels") or []
|
||||
tier_labels = [label.get("name", "") for label in labels if (label.get("name", "") or "").startswith("tier:")]
|
||||
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
|
||||
mode_map = cfg.get("tier_failure_mode") or {}
|
||||
default_mode = cfg.get("default_mode", "hard")
|
||||
for tl in tier_labels:
|
||||
@@ -875,7 +865,7 @@ def is_high_risk(pr: dict[str, Any], cfg: dict[str, Any]) -> bool:
|
||||
Governance fix for internal#442 — closes the inconsistency between
|
||||
sop-tier-check (tier-aware) and sop-checklist (was tier-blind).
|
||||
"""
|
||||
label_set = {(label.get("name") or "") for label in (pr.get("labels") or [])}
|
||||
label_set = {(l.get("name") or "") for l in (pr.get("labels") or [])}
|
||||
if "tier:high" in label_set:
|
||||
return True
|
||||
high_risk_labels = set(cfg.get("high_risk_labels") or [])
|
||||
@@ -1026,14 +1016,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
tid = client.resolve_team_id(args.owner, tn)
|
||||
if tid is None:
|
||||
# Try the list endpoint as a fallback.
|
||||
code, data = client._req( # noqa: SLF001 # internal helper; called from loop in caller context
|
||||
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 # internal write-through cache
|
||||
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001
|
||||
break
|
||||
if tid is not None:
|
||||
team_ids.append(tid)
|
||||
|
||||
@@ -33,7 +33,7 @@ def scenario() -> str:
|
||||
p = os.path.join(STATE_DIR, "scenario")
|
||||
if not os.path.isfile(p):
|
||||
return "T1_success"
|
||||
with open(p, encoding="utf-8") as f:
|
||||
with open(p) as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ import re
|
||||
import sys
|
||||
import urllib.parse
|
||||
|
||||
|
||||
STATE_DIR = os.environ.get("FIXTURE_STATE_DIR", "/tmp")
|
||||
|
||||
|
||||
@@ -40,7 +41,7 @@ def scenario() -> str:
|
||||
p = os.path.join(STATE_DIR, "scenario")
|
||||
if not os.path.isfile(p):
|
||||
return "T1_pr_open"
|
||||
with open(p, encoding="utf-8") as f:
|
||||
with open(p) as f:
|
||||
return f.read().strip()
|
||||
|
||||
|
||||
@@ -80,7 +81,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# GET /repos/{owner}/{name}/pulls/{pr_number}
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)$", path)
|
||||
if m:
|
||||
pr_num = m.group(3)
|
||||
owner, name, pr_num = m.group(1), m.group(2), m.group(3)
|
||||
if sc == "T2_pr_closed":
|
||||
return self._json(200, {
|
||||
"number": int(pr_num),
|
||||
@@ -150,7 +151,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# GET /teams/{team_id}/members/{username}
|
||||
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
|
||||
if m:
|
||||
login = m.group(2)
|
||||
team_id, login = m.group(1), m.group(2)
|
||||
if sc == "T8_team_not_member":
|
||||
return self._empty(404)
|
||||
if sc == "T9_team_403":
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "ci-required-drift.py"
|
||||
spec = importlib.util.spec_from_file_location("ci_required_drift", SCRIPT)
|
||||
drift = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = drift
|
||||
spec.loader.exec_module(drift)
|
||||
|
||||
# Module-level constants are loaded from env at import time; set them
|
||||
# explicitly so unit tests can import without the full env contract.
|
||||
drift.SENTINEL_JOB = "all-required"
|
||||
drift.CI_WORKFLOW_PATH = ".gitea/workflows/ci.yml"
|
||||
drift.AUDIT_WORKFLOW_PATH = ".gitea/workflows/audit-force-merge.yml"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_ci_doc(jobs: dict) -> dict:
|
||||
return {"jobs": jobs}
|
||||
|
||||
|
||||
def _make_audit_doc(required_checks: list[str]) -> dict:
|
||||
return {
|
||||
"jobs": {
|
||||
"audit": {
|
||||
"steps": [
|
||||
{"env": {"REQUIRED_CHECKS": "\n".join(required_checks)}}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sentinel_needs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_sentinel_needs_returns_empty_when_absent():
|
||||
doc = _make_ci_doc({"all-required": {"runs-on": "ubuntu-latest"}})
|
||||
assert drift.sentinel_needs(doc) == set()
|
||||
|
||||
|
||||
def test_sentinel_needs_parses_list():
|
||||
doc = _make_ci_doc(
|
||||
{"all-required": {"needs": ["platform-build", "canvas-build"]}}
|
||||
)
|
||||
assert drift.sentinel_needs(doc) == {"platform-build", "canvas-build"}
|
||||
|
||||
|
||||
def test_sentinel_needs_parses_string():
|
||||
doc = _make_ci_doc({"all-required": {"needs": "platform-build"}})
|
||||
assert drift.sentinel_needs(doc) == {"platform-build"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ci_job_names / ci_jobs_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_ci_job_names_excludes_sentinel_and_event_gated():
|
||||
doc = _make_ci_doc(
|
||||
{
|
||||
"platform-build": {},
|
||||
"canvas-build": {"if": "github.event_name == 'pull_request'"},
|
||||
"main-push": {"if": "github.ref == 'refs/heads/main'"},
|
||||
"all-required": {},
|
||||
}
|
||||
)
|
||||
assert drift.ci_job_names(doc) == {"platform-build"}
|
||||
|
||||
|
||||
def test_ci_jobs_all_includes_event_gated():
|
||||
doc = _make_ci_doc(
|
||||
{
|
||||
"platform-build": {},
|
||||
"canvas-build": {"if": "github.event_name == 'pull_request'"},
|
||||
"all-required": {},
|
||||
}
|
||||
)
|
||||
assert drift.ci_jobs_all(doc) == {"platform-build", "canvas-build"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_drift — F1 / F1b with mocked I/O
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SAMPLE_PROTECTION = {
|
||||
"status_check_contexts": [
|
||||
"CI / all-required (pull_request)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (pull_request)",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_detect_drift_no_needs_sentinel_skips_f1():
|
||||
"""Post-#1766 contract: all-required has no needs: → F1 is a false positive."""
|
||||
ci = _make_ci_doc(
|
||||
{
|
||||
"platform-build": {},
|
||||
"canvas-build": {},
|
||||
"all-required": {},
|
||||
}
|
||||
)
|
||||
audit = _make_audit_doc(
|
||||
[
|
||||
"CI / all-required (pull_request)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (pull_request)",
|
||||
]
|
||||
)
|
||||
|
||||
with patch.object(drift, "load_yaml", side_effect=[ci, audit]):
|
||||
with patch.object(drift, "api", return_value=(200, SAMPLE_PROTECTION)):
|
||||
findings, debug = drift.detect_drift("main")
|
||||
|
||||
assert findings == []
|
||||
assert debug["sentinel_needs"] == []
|
||||
|
||||
|
||||
def test_detect_drift_typo_in_needs_triggers_f1b():
|
||||
"""F1b still catches typos when needs exists."""
|
||||
ci = _make_ci_doc(
|
||||
{
|
||||
"platform-build": {},
|
||||
"all-required": {"needs": ["platfom-build"]}, # typo
|
||||
}
|
||||
)
|
||||
audit = _make_audit_doc(["CI / all-required (pull_request)"])
|
||||
|
||||
with patch.object(drift, "load_yaml", side_effect=[ci, audit]):
|
||||
with patch.object(drift, "api", return_value=(200, SAMPLE_PROTECTION)):
|
||||
findings, _ = drift.detect_drift("main")
|
||||
|
||||
assert any("F1b" in f for f in findings)
|
||||
assert any("platfom-build" in f for f in findings)
|
||||
|
||||
|
||||
def test_detect_drift_missing_job_in_needs_triggers_f1():
|
||||
"""F1 still fires when needs is non-empty and jobs are missing."""
|
||||
ci = _make_ci_doc(
|
||||
{
|
||||
"platform-build": {},
|
||||
"canvas-build": {},
|
||||
"all-required": {"needs": ["platform-build"]},
|
||||
}
|
||||
)
|
||||
audit = _make_audit_doc(["CI / all-required (pull_request)"])
|
||||
|
||||
with patch.object(drift, "load_yaml", side_effect=[ci, audit]):
|
||||
with patch.object(drift, "api", return_value=(200, SAMPLE_PROTECTION)):
|
||||
findings, _ = drift.detect_drift("main")
|
||||
|
||||
assert any("F1 —" in f for f in findings)
|
||||
assert any("canvas-build" in f for f in findings)
|
||||
assert not any("F1b" in f for f in findings)
|
||||
|
||||
|
||||
def test_detect_drift_no_f1_when_needs_empty_even_with_jobs():
|
||||
"""Explicit regression guard: empty needs + existing jobs = no F1."""
|
||||
ci = _make_ci_doc(
|
||||
{
|
||||
"platform-build": {},
|
||||
"canvas-build": {},
|
||||
"all-required": {"needs": []},
|
||||
}
|
||||
)
|
||||
audit = _make_audit_doc(["CI / all-required (pull_request)"])
|
||||
|
||||
with patch.object(drift, "load_yaml", side_effect=[ci, audit]):
|
||||
with patch.object(drift, "api", return_value=(200, SAMPLE_PROTECTION)):
|
||||
findings, _ = drift.detect_drift("main")
|
||||
|
||||
assert not any("F1 —" in f for f in findings)
|
||||
@@ -2,6 +2,7 @@ import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "gitea-merge-queue.py"
|
||||
spec = importlib.util.spec_from_file_location("gitea_merge_queue", SCRIPT)
|
||||
mq = importlib.util.module_from_spec(spec)
|
||||
|
||||
@@ -15,6 +15,7 @@ Mirrors the pattern in scripts/ops/test_check_migration_collisions.py
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
SCRIPT = Path(__file__).resolve().parents[1] / "main-red-watchdog.py"
|
||||
spec = importlib.util.spec_from_file_location("main_red_watchdog", SCRIPT)
|
||||
wd = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = wd
|
||||
spec.loader.exec_module(wd)
|
||||
|
||||
# Module-level constants are loaded from env at import time; set them
|
||||
# explicitly so unit tests can import without the full env contract.
|
||||
wd.GITEA_TOKEN = "fake-token"
|
||||
wd.GITEA_HOST = "git.example.com"
|
||||
wd.REPO = "molecule-ai/molecule-core"
|
||||
wd.OWNER = "molecule-ai"
|
||||
wd.NAME = "molecule-core"
|
||||
wd.WATCH_BRANCH = "main"
|
||||
wd.RED_LABEL = "tier:high"
|
||||
wd.API = "https://git.example.com/api/v1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _is_scheduled_context
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_scheduled_context_matches_staging_saas_smoke():
|
||||
assert wd._is_scheduled_context("Staging SaaS smoke") is True
|
||||
|
||||
|
||||
def test_is_scheduled_context_matches_case_insensitive():
|
||||
assert wd._is_scheduled_context("continuous synthetic e2e") is True
|
||||
|
||||
|
||||
def test_is_scheduled_context_no_match_for_required_ci():
|
||||
assert wd._is_scheduled_context("CI / all-required") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _entry_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_entry_state_prefers_status_over_state():
|
||||
"""Gitea 1.22.6 per-entry key is `status`; `state` is fallback."""
|
||||
assert wd._entry_state({"status": "failure", "state": "success"}) == "failure"
|
||||
|
||||
|
||||
def test_entry_state_falls_back_to_state():
|
||||
assert wd._entry_state({"state": "pending"}) == "pending"
|
||||
|
||||
|
||||
def test_entry_state_empty_when_neither_key_present():
|
||||
assert wd._entry_state({"context": "foo"}) == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_red
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_is_red_combined_failure_no_statuses():
|
||||
"""Combined failure with empty statuses[] still trips red."""
|
||||
red, failed = wd.is_red({"state": "failure", "statuses": []})
|
||||
assert red is True
|
||||
assert failed == []
|
||||
|
||||
|
||||
def test_is_red_cancel_cascade_filtered():
|
||||
"""status=3 (cancelled) mapped to failure string must be filtered."""
|
||||
status = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{"context": "CI / build", "status": "failure", "description": "Has been cancelled"},
|
||||
],
|
||||
}
|
||||
red, failed = wd.is_red(status)
|
||||
assert red is False
|
||||
assert failed == []
|
||||
|
||||
|
||||
def test_is_red_real_failure_not_filtered():
|
||||
"""Real failures with different descriptions are kept."""
|
||||
status = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{"context": "CI / build", "status": "failure", "description": "Failing after 12s"},
|
||||
],
|
||||
}
|
||||
red, failed = wd.is_red(status)
|
||||
assert red is True
|
||||
assert len(failed) == 1
|
||||
assert failed[0]["context"] == "CI / build"
|
||||
|
||||
|
||||
def test_is_red_uses_entry_state_not_top_level_state():
|
||||
"""Regression: per-entry key is `status`, not `state`."""
|
||||
status = {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
# Only `status` present; pre-rev4 code read `state` and got None
|
||||
{"context": "CI / test", "status": "failure"},
|
||||
],
|
||||
}
|
||||
red, failed = wd.is_red(status)
|
||||
assert red is True
|
||||
assert len(failed) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_open_red_issues — pagination (mc#1789)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_list_open_red_issues_exhausts_pagination():
|
||||
"""Backlog can exceed 50 issues; all pages must be fetched."""
|
||||
calls = []
|
||||
|
||||
def fake_api(method, path, **kwargs):
|
||||
calls.append((method, path, kwargs))
|
||||
query = (kwargs.get("query") or {})
|
||||
page = int(query.get("page", "1"))
|
||||
limit = int(query.get("limit", "50"))
|
||||
# Page 1 returns full limit; page 2 returns partial → break
|
||||
if page == 1:
|
||||
return 200, [
|
||||
{"title": f"[main-red] molecule-ai/molecule-core: sha{i:04d}"}
|
||||
for i in range(limit)
|
||||
]
|
||||
if page == 2:
|
||||
return 200, [
|
||||
{"title": "[main-red] molecule-ai/molecule-core: extra1"},
|
||||
{"title": "[main-red] molecule-ai/molecule-core: extra2"},
|
||||
{"title": " unrelated issue "}, # filtered out
|
||||
]
|
||||
return 200, []
|
||||
|
||||
with patch.object(wd, "api", side_effect=fake_api):
|
||||
issues = wd.list_open_red_issues()
|
||||
|
||||
assert len(issues) == 52 # 50 + 2 matched
|
||||
titles = {i["title"] for i in issues}
|
||||
assert "[main-red] molecule-ai/molecule-core: extra1" in titles
|
||||
assert "[main-red] molecule-ai/molecule-core: extra2" in titles
|
||||
|
||||
|
||||
def test_list_open_red_issues_single_page():
|
||||
"""When results < limit, loop breaks after first page."""
|
||||
def fake_api(method, path, **kwargs):
|
||||
return 200, [
|
||||
{"title": "[main-red] molecule-ai/molecule-core: abc123"},
|
||||
]
|
||||
|
||||
with patch.object(wd, "api", side_effect=fake_api):
|
||||
issues = wd.list_open_red_issues()
|
||||
|
||||
assert len(issues) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# run_once — close logic (mc#1789)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_run_once_green_closes_stale_issues(monkeypatch):
|
||||
"""Combined success → close stale issues."""
|
||||
monkeypatch.setattr(wd, "get_head_sha", lambda b: "abc123")
|
||||
monkeypatch.setattr(wd, "get_combined_status", lambda s: {"state": "success", "statuses": []})
|
||||
monkeypatch.setattr(wd, "is_red", lambda s: (False, []))
|
||||
|
||||
closed = []
|
||||
|
||||
def capture_close(current_sha, *, dry_run=False, close_same_sha=False):
|
||||
closed.append(current_sha)
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(wd, "close_open_red_issues_for_other_shas", capture_close)
|
||||
monkeypatch.setattr(wd, "emit_loki_event", lambda *a, **k: None)
|
||||
|
||||
assert wd.run_once(dry_run=True) == 0
|
||||
assert closed == ["abc123"]
|
||||
|
||||
|
||||
def test_run_once_pending_scheduled_only_closes_stale_issues(monkeypatch):
|
||||
"""Combined pending, but only scheduled contexts pending → close stale."""
|
||||
monkeypatch.setattr(wd, "get_head_sha", lambda b: "abc123")
|
||||
monkeypatch.setattr(
|
||||
wd, "get_combined_status",
|
||||
lambda s: {
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
{"context": "CI / all-required", "status": "success"},
|
||||
{"context": "Staging SaaS smoke", "status": "pending"},
|
||||
],
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(wd, "is_red", lambda s: (False, []))
|
||||
|
||||
closed = []
|
||||
|
||||
def capture_close(current_sha, *, dry_run=False, close_same_sha=False):
|
||||
closed.append(current_sha)
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(wd, "close_open_red_issues_for_other_shas", capture_close)
|
||||
monkeypatch.setattr(wd, "emit_loki_event", lambda *a, **k: None)
|
||||
|
||||
assert wd.run_once(dry_run=True) == 0
|
||||
assert closed == ["abc123"]
|
||||
|
||||
|
||||
def test_run_once_pending_required_does_not_close(monkeypatch):
|
||||
"""Combined pending with a real required context still pending → no close."""
|
||||
monkeypatch.setattr(wd, "get_head_sha", lambda b: "abc123")
|
||||
monkeypatch.setattr(
|
||||
wd, "get_combined_status",
|
||||
lambda s: {
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
{"context": "CI / all-required", "status": "pending"},
|
||||
{"context": "Staging SaaS smoke", "status": "success"},
|
||||
],
|
||||
}
|
||||
)
|
||||
monkeypatch.setattr(wd, "is_red", lambda s: (False, []))
|
||||
|
||||
closed = []
|
||||
|
||||
def capture_close(current_sha, *, dry_run=False, close_same_sha=False):
|
||||
closed.append(current_sha)
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(wd, "close_open_red_issues_for_other_shas", capture_close)
|
||||
monkeypatch.setattr(wd, "emit_loki_event", lambda *a, **k: None)
|
||||
|
||||
assert wd.run_once(dry_run=True) == 0
|
||||
assert closed == []
|
||||
|
||||
|
||||
def test_run_once_failure_does_not_close(monkeypatch):
|
||||
"""Real failure in non-scheduled context → no close."""
|
||||
monkeypatch.setattr(wd, "get_head_sha", lambda b: "abc123")
|
||||
monkeypatch.setattr(
|
||||
wd, "get_combined_status",
|
||||
lambda s: {
|
||||
"state": "failure",
|
||||
"statuses": [
|
||||
{"context": "CI / all-required", "status": "failure"},
|
||||
],
|
||||
}
|
||||
)
|
||||
# is_red will return True, so we enter the red path, not the green close path
|
||||
monkeypatch.setattr(wd, "is_red", lambda s: (True, s.get("statuses", [])))
|
||||
monkeypatch.setattr(wd, "time", MagicMock(sleep=lambda x: None))
|
||||
monkeypatch.setattr(wd, "emit_loki_event", lambda *a, **k: None)
|
||||
|
||||
filed = []
|
||||
|
||||
def capture_file(sha, failed, debug, *, dry_run=False):
|
||||
filed.append(sha)
|
||||
|
||||
monkeypatch.setattr(wd, "file_or_update_red", capture_file)
|
||||
monkeypatch.setattr(wd, "close_open_red_issues_for_other_shas", lambda *a, **k: 0)
|
||||
monkeypatch.setattr(wd, "close_stale_red_issues", lambda *a, **k: 0)
|
||||
|
||||
assert wd.run_once(dry_run=True) == 0
|
||||
assert filed == ["abc123"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# title_for / find_open_issue_for_sha
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_title_for_uses_short_sha():
|
||||
assert wd.title_for("abcdef123456") == "[main-red] molecule-ai/molecule-core: abcdef1234"
|
||||
|
||||
|
||||
def test_find_open_issue_for_sha_matches_exact_title(monkeypatch):
|
||||
fake_issue = {"title": "[main-red] molecule-ai/molecule-core: abc1234567", "number": 42}
|
||||
monkeypatch.setattr(wd, "list_open_red_issues", lambda: [fake_issue])
|
||||
assert wd.find_open_issue_for_sha("abc1234567") == fake_issue
|
||||
|
||||
|
||||
def test_find_open_issue_for_sha_returns_none_when_no_match(monkeypatch):
|
||||
monkeypatch.setattr(wd, "list_open_red_issues", lambda: [])
|
||||
assert wd.find_open_issue_for_sha("abc123") is None
|
||||
@@ -153,205 +153,3 @@ def test_default_required_contexts_delegate_path_gating_to_all_required():
|
||||
"CI / all-required (push)",
|
||||
"Secret scan / Scan diff for credential-shaped strings (push)",
|
||||
]
|
||||
|
||||
|
||||
def test_slugs_from_redeploy_response_uses_controlplane_plan_rows():
|
||||
body = {
|
||||
"results": [
|
||||
{"slug": "hongming", "phase": "canary", "ssm_status": "DryRun"},
|
||||
{"slug": "tenant-a", "phase": "batch-1", "ssm_status": "DryRun"},
|
||||
{"slug": "", "phase": "batch-1", "ssm_status": "DryRun"},
|
||||
{"phase": "batch-1", "ssm_status": "DryRun"},
|
||||
]
|
||||
}
|
||||
|
||||
assert prod.slugs_from_redeploy_response(body) == ["hongming", "tenant-a"]
|
||||
|
||||
|
||||
def test_plan_rollout_slugs_asks_controlplane_for_dry_run_plan():
|
||||
calls = []
|
||||
|
||||
def fake_redeploy(_cp_url, _token, body):
|
||||
calls.append(body)
|
||||
return 200, {
|
||||
"ok": True,
|
||||
"results": [
|
||||
{"slug": "hongming", "phase": "canary", "ssm_status": "DryRun"},
|
||||
{"slug": "tenant-a", "phase": "batch-1", "ssm_status": "DryRun"},
|
||||
],
|
||||
}
|
||||
|
||||
slugs = prod.plan_rollout_slugs(
|
||||
"https://api.moleculesai.app",
|
||||
"secret",
|
||||
{
|
||||
"target_tag": "staging-abcdef1",
|
||||
"canary_slug": "hongming",
|
||||
"soak_seconds": 60,
|
||||
"batch_size": 3,
|
||||
"dry_run": False,
|
||||
"confirm": True,
|
||||
},
|
||||
redeploy=fake_redeploy,
|
||||
)
|
||||
|
||||
assert slugs == ["hongming", "tenant-a"]
|
||||
assert calls == [
|
||||
{
|
||||
"target_tag": "staging-abcdef1",
|
||||
"canary_slug": "hongming",
|
||||
"soak_seconds": 60,
|
||||
"batch_size": 3,
|
||||
"dry_run": True,
|
||||
"confirm": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_scoped_redeploy_body_removes_canary_and_local_soak():
|
||||
base = {
|
||||
"target_tag": "staging-abcdef1",
|
||||
"canary_slug": "hongming",
|
||||
"soak_seconds": 60,
|
||||
"batch_size": 3,
|
||||
"dry_run": False,
|
||||
"confirm": True,
|
||||
}
|
||||
|
||||
scoped = prod.scoped_redeploy_body(base, ["tenant-a", "tenant-b"])
|
||||
|
||||
assert scoped == {
|
||||
"target_tag": "staging-abcdef1",
|
||||
"soak_seconds": 0,
|
||||
"batch_size": 2,
|
||||
"dry_run": False,
|
||||
"confirm": True,
|
||||
"only_slugs": ["tenant-a", "tenant-b"],
|
||||
}
|
||||
|
||||
|
||||
def test_plan_scoped_rollout_preserves_canary_then_batches():
|
||||
calls, sleeps = [], []
|
||||
|
||||
def fake_list(_cp_url, _token, _body):
|
||||
return ["tenant-a", "hongming", "tenant-b", "tenant-c"]
|
||||
|
||||
def fake_redeploy(_cp_url, _token, body):
|
||||
calls.append(body)
|
||||
return 200, {
|
||||
"ok": True,
|
||||
"results": [{"slug": slug, "healthz_ok": True} for slug in body["only_slugs"]],
|
||||
}
|
||||
|
||||
aggregate = prod.execute_scoped_rollout(
|
||||
{
|
||||
"cp_url": "https://api.moleculesai.app",
|
||||
"body": {
|
||||
"target_tag": "staging-abcdef1",
|
||||
"canary_slug": "hongming",
|
||||
"soak_seconds": 60,
|
||||
"batch_size": 2,
|
||||
"dry_run": False,
|
||||
"confirm": True,
|
||||
},
|
||||
},
|
||||
token="secret",
|
||||
list_slugs=fake_list,
|
||||
redeploy=fake_redeploy,
|
||||
sleep=sleeps.append,
|
||||
)
|
||||
|
||||
assert [call["only_slugs"] for call in calls] == [
|
||||
["hongming"],
|
||||
["tenant-a", "tenant-b"],
|
||||
["tenant-c"],
|
||||
]
|
||||
assert sleeps == [60]
|
||||
assert aggregate["ok"] is True
|
||||
assert [result["slug"] for result in aggregate["results"]] == [
|
||||
"hongming",
|
||||
"tenant-a",
|
||||
"tenant-b",
|
||||
"tenant-c",
|
||||
]
|
||||
|
||||
|
||||
def test_scoped_rollout_halts_after_failed_canary():
|
||||
calls = []
|
||||
|
||||
def fake_redeploy(_cp_url, _token, body):
|
||||
calls.append(body)
|
||||
return 200, {"ok": False, "results": [{"slug": body["only_slugs"][0], "error": "bad"}]}
|
||||
|
||||
try:
|
||||
prod.execute_scoped_rollout(
|
||||
{
|
||||
"cp_url": "https://api.moleculesai.app",
|
||||
"body": {
|
||||
"target_tag": "staging-abcdef1",
|
||||
"canary_slug": "hongming",
|
||||
"soak_seconds": 60,
|
||||
"batch_size": 2,
|
||||
"dry_run": False,
|
||||
"confirm": True,
|
||||
},
|
||||
},
|
||||
token="secret",
|
||||
list_slugs=lambda _cp_url, _token, _body: ["hongming", "tenant-a"],
|
||||
redeploy=fake_redeploy,
|
||||
sleep=lambda _seconds: None,
|
||||
)
|
||||
except prod.RolloutFailed as exc:
|
||||
assert "redeploy scoped call failed" in str(exc)
|
||||
assert exc.response["ok"] is False
|
||||
assert exc.response["results"] == [{"slug": "hongming", "error": "bad"}]
|
||||
else:
|
||||
raise AssertionError("expected failed canary to halt rollout")
|
||||
|
||||
assert [call["only_slugs"] for call in calls] == [["hongming"]]
|
||||
|
||||
|
||||
def test_rollout_from_plan_file_writes_partial_response_on_failure(tmp_path):
|
||||
plan_path = tmp_path / "plan.json"
|
||||
response_path = tmp_path / "response.json"
|
||||
plan_path.write_text(
|
||||
"""
|
||||
{
|
||||
"enabled": true,
|
||||
"cp_url": "https://api.moleculesai.app",
|
||||
"body": {"target_tag": "staging-abcdef1", "confirm": true}
|
||||
}
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
original = prod.execute_scoped_rollout
|
||||
|
||||
def fake_execute(_plan, _token):
|
||||
raise prod.RolloutFailed(
|
||||
"redeploy scoped call failed for hongming: HTTP 500, ok=false",
|
||||
{
|
||||
"ok": False,
|
||||
"error": "redeploy scoped call failed for hongming: HTTP 500, ok=false",
|
||||
"results": [{"slug": "hongming", "error": "bad"}],
|
||||
},
|
||||
)
|
||||
|
||||
prod.execute_scoped_rollout = fake_execute
|
||||
try:
|
||||
try:
|
||||
prod.rollout_from_plan_file(
|
||||
str(plan_path),
|
||||
str(response_path),
|
||||
{"CP_ADMIN_API_TOKEN": "secret"},
|
||||
)
|
||||
except prod.RolloutFailed:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("expected rollout failure")
|
||||
finally:
|
||||
prod.execute_scoped_rollout = original
|
||||
|
||||
assert response_path.read_text(encoding="utf-8").strip()
|
||||
assert '"ok": false' in response_path.read_text(encoding="utf-8")
|
||||
assert '"slug": "hongming"' in response_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Resolve sibling script regardless of where pytest is invoked from.
|
||||
|
||||
@@ -54,6 +54,5 @@ jobs:
|
||||
# read-only by design (least-privilege).
|
||||
REQUIRED_CHECKS: |
|
||||
CI / all-required (pull_request)
|
||||
E2E API Smoke Test / E2E API Smoke Test (pull_request)
|
||||
Handlers Postgres Integration / Handlers Postgres Integration (pull_request)
|
||||
sop-checklist / all-items-acked (pull_request)
|
||||
run: bash .gitea/scripts/audit-force-merge.sh
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
# the PR. Follow-up PR flips this off after surfaced defects are
|
||||
# triaged.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
# the PR. Follow-up PR flips this off after surfaced defects are
|
||||
# triaged.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
# AND-set: only the Mac arm64 runner advertises macos-self-hosted.
|
||||
# See "RUNNER TARGETING" header note for why bare self-hosted is unsafe.
|
||||
runs-on: [self-hosted, macos-self-hosted]
|
||||
# ADVISORY: never blocks. See safety contract point 3. mc#1982
|
||||
# ADVISORY: never blocks. See safety contract point 3. mc#774
|
||||
# internal#418 — tracked: arm64 advisory pilot, non-gating by design.
|
||||
continue-on-error: true
|
||||
# event_name gate: functional (only meaningful on push/PR) AND keeps
|
||||
|
||||
@@ -57,7 +57,7 @@ permissions:
|
||||
# can produce duplicate comments before the title-search dedup wins.
|
||||
concurrency:
|
||||
group: ci-required-drift
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
drift:
|
||||
|
||||
+7
-15
@@ -161,23 +161,15 @@ jobs:
|
||||
echo "::group::pendinguploads exit=$pu_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-pu.log
|
||||
echo "::endgroup::"
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Run tests with coverage (blocking gate)
|
||||
# Removed -race from the blocking gate per #1184: cold runners
|
||||
# take 13-25 min to compile with race instrumentation, exceeding
|
||||
# the 10m step timeout and causing false failures. Race detection
|
||||
# now runs as a non-blocking advisory step below.
|
||||
run: go test -timeout 10m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Race detection (advisory, non-blocking)
|
||||
# mc#1184: runs race detector as an advisory check so cold-runner
|
||||
# compile-time spikes don't block merges. Failures here surface in
|
||||
# the run log but do not fail the build.
|
||||
run: go test -race -timeout 10m ./...
|
||||
continue-on-error: true
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
# lets the suite complete on cold cache (~5-7m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: ${{ needs.changes.outputs.platform == 'true' }}
|
||||
name: Per-file coverage report
|
||||
|
||||
@@ -92,7 +92,7 @@ permissions:
|
||||
# stacking up.
|
||||
concurrency:
|
||||
group: continuous-synth-e2e
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
name: Synthetic E2E against staging
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
# Bumped from 12 → 20 (2026-05-04). Tenant user-data install phase
|
||||
# (apt-get update + install docker.io/jq/awscli/caddy + snap install
|
||||
|
||||
@@ -101,7 +101,7 @@ concurrency:
|
||||
# See e2e-staging-canvas.yml's identical concurrency block for the full
|
||||
# rationale and the 2026-04-28 incident reference.
|
||||
group: e2e-api-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
# integration). See internal#512 for the class defect.
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
api: ${{ steps.decide.outputs.api }}
|
||||
@@ -160,7 +160,7 @@ jobs:
|
||||
# detect-changes for the full rationale.
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
|
||||
@@ -32,7 +32,7 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: e2e-chat-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
# defect.
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
chat: ${{ steps.decide.outputs.chat }}
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
# Must land on operator-host Linux (docker-host).
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
name: E2E Legacy Advisory
|
||||
|
||||
# Advisory lane for older/manual E2E scripts that are too broad or
|
||||
# environment-dependent for required PR CI. This intentionally does not run on
|
||||
# pull_request or push so it cannot block merges/deploys; scheduled/manual reds
|
||||
# still surface drift in scripts that would otherwise only be shellchecked.
|
||||
#
|
||||
# Gitea 1.22.6 rejects workflow_dispatch.inputs, so keep dispatch input-free.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Stagger after the staging smoke/canvas morning lanes.
|
||||
- cron: '15 9 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-legacy-advisory
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
legacy-local-platform:
|
||||
name: Legacy local-platform E2E
|
||||
runs-on: docker-host
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
PG_CONTAINER: pg-e2e-legacy-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
REDIS_CONTAINER: redis-e2e-legacy-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
MOLECULE_ENV: development
|
||||
BIND_ADDR: 127.0.0.1
|
||||
MOLECULE_IN_DOCKER: "false"
|
||||
A2A_TIMEOUT: "30"
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
|
||||
- name: Prepare local platform dependencies
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker pull postgres:16 >/dev/null
|
||||
docker pull redis:7 >/dev/null
|
||||
docker pull alpine:latest >/dev/null
|
||||
docker network create molecule-core-net >/dev/null 2>&1 || true
|
||||
|
||||
- name: Start Postgres
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$PG_CONTAINER" \
|
||||
-e POSTGRES_USER=dev -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=molecule \
|
||||
-p 0:5432 postgres:16 >/dev/null
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
if [ -z "$PG_PORT" ]; then
|
||||
PG_PORT=$(docker port "$PG_CONTAINER" 5432/tcp | head -1 | awk -F: '{print $NF}')
|
||||
fi
|
||||
if [ -z "$PG_PORT" ]; then
|
||||
echo "::error::Could not resolve host port for $PG_CONTAINER"
|
||||
docker port "$PG_CONTAINER" 5432/tcp || true
|
||||
docker logs "$PG_CONTAINER" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "DATABASE_URL=postgres://dev:dev@127.0.0.1:${PG_PORT}/molecule?sslmode=disable" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 30); do
|
||||
docker exec "$PG_CONTAINER" pg_isready -U dev >/dev/null 2>&1 && exit 0
|
||||
sleep 1
|
||||
done
|
||||
docker logs "$PG_CONTAINER" || true
|
||||
exit 1
|
||||
|
||||
- name: Start Redis
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
docker run -d --name "$REDIS_CONTAINER" -p 0:6379 redis:7 >/dev/null
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | awk -F: '/^0\.0\.0\.0:/ {print $2; exit}')
|
||||
if [ -z "$REDIS_PORT" ]; then
|
||||
REDIS_PORT=$(docker port "$REDIS_CONTAINER" 6379/tcp | head -1 | awk -F: '{print $NF}')
|
||||
fi
|
||||
if [ -z "$REDIS_PORT" ]; then
|
||||
echo "::error::Could not resolve host port for $REDIS_CONTAINER"
|
||||
docker port "$REDIS_CONTAINER" 6379/tcp || true
|
||||
docker logs "$REDIS_CONTAINER" || true
|
||||
exit 1
|
||||
fi
|
||||
echo "REDIS_URL=redis://127.0.0.1:${REDIS_PORT}" >> "$GITHUB_ENV"
|
||||
for i in $(seq 1 15); do
|
||||
docker exec "$REDIS_CONTAINER" redis-cli ping 2>/dev/null | grep -q PONG && exit 0
|
||||
sleep 1
|
||||
done
|
||||
docker logs "$REDIS_CONTAINER" || true
|
||||
exit 1
|
||||
|
||||
- name: Pick platform port
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PLATFORM_PORT=$(python3 - <<'PY'
|
||||
import socket
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
print(s.getsockname()[1])
|
||||
PY
|
||||
)
|
||||
echo "PORT=${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||
echo "BASE=http://127.0.0.1:${PLATFORM_PORT}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build platform
|
||||
working-directory: workspace-server
|
||||
run: go build -o platform-server ./cmd/server
|
||||
|
||||
- name: Populate template manifests for dev-mode E2E
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
bash scripts/clone-manifest.sh manifest.json workspace-configs-templates org-templates plugins
|
||||
else
|
||||
echo "::warning::jq unavailable; dev-mode template assertion may fail if templates are absent"
|
||||
fi
|
||||
|
||||
- name: Start platform
|
||||
run: |
|
||||
set -euo pipefail
|
||||
./workspace-server/platform-server > workspace-server/platform.log 2>&1 &
|
||||
echo $! > workspace-server/platform.pid
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf "$BASE/health" >/dev/null && exit 0
|
||||
sleep 1
|
||||
done
|
||||
cat workspace-server/platform.log || true
|
||||
exit 1
|
||||
|
||||
- name: Run comprehensive E2E
|
||||
run: bash tests/e2e/test_comprehensive_e2e.sh
|
||||
|
||||
- name: Run workspace abilities E2E
|
||||
run: bash tests/e2e/test_workspace_abilities_e2e.sh
|
||||
|
||||
- name: Run dev-mode E2E
|
||||
run: bash tests/e2e/test_dev_mode.sh
|
||||
|
||||
- name: Start stub A2A agents
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat > /tmp/molecule-stub-a2a.py <<'PY'
|
||||
import json
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_POST(self):
|
||||
length = int(self.headers.get("content-length", "0"))
|
||||
raw = self.rfile.read(length) if length else b"{}"
|
||||
try:
|
||||
req = json.loads(raw)
|
||||
except Exception:
|
||||
req = {}
|
||||
method = req.get("method")
|
||||
if method not in ("message/send", None):
|
||||
body = {"jsonrpc": "2.0", "id": req.get("id"), "error": {"code": -32601, "message": "method not found"}}
|
||||
else:
|
||||
body = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.get("id", "stub"),
|
||||
"result": {
|
||||
"role": "agent",
|
||||
"parts": [{"kind": "text", "type": "text", "text": "stub agent response"}],
|
||||
},
|
||||
}
|
||||
data = json.dumps(body, separators=(",", ":")).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("content-type", "application/json")
|
||||
self.send_header("content-length", str(len(data)))
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
def log_message(self, *_):
|
||||
return
|
||||
|
||||
HTTPServer(("127.0.0.1", 18080), Handler).serve_forever()
|
||||
PY
|
||||
python3 /tmp/molecule-stub-a2a.py > /tmp/molecule-stub-a2a.log 2>&1 &
|
||||
echo $! > /tmp/molecule-stub-a2a.pid
|
||||
|
||||
- name: Seed external agents for legacy A2A/activity scripts
|
||||
run: |
|
||||
set -euo pipefail
|
||||
create_agent() {
|
||||
local name="$1" role="$2"
|
||||
curl -sS -X POST "$BASE/workspaces" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"${name}\",\"role\":\"${role}\",\"tier\":1,\"runtime\":\"external\",\"external\":true,\"url\":\"http://127.0.0.1:18080\"}" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)['id'])"
|
||||
}
|
||||
ECHO_ID=$(create_agent "Echo Agent" "Echo")
|
||||
SEO_ID=$(create_agent "SEO Agent" "SEO")
|
||||
curl -sS -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$ECHO_ID\",\"url\":\"http://127.0.0.1:18080\",\"agent_card\":{\"name\":\"Echo Agent\",\"skills\":[{\"id\":\"echo\",\"name\":\"Echo\"}]}}" >/dev/null
|
||||
curl -sS -X POST "$BASE/registry/register" -H "Content-Type: application/json" \
|
||||
-d "{\"id\":\"$SEO_ID\",\"url\":\"http://127.0.0.1:18080\",\"agent_card\":{\"name\":\"SEO Agent\",\"skills\":[{\"id\":\"seo\",\"name\":\"SEO\"}]}}" >/dev/null
|
||||
|
||||
- name: Run activity E2E
|
||||
run: bash tests/e2e/test_activity_e2e.sh
|
||||
|
||||
- name: Run A2A E2E
|
||||
run: bash tests/e2e/test_a2a_e2e.sh
|
||||
|
||||
- name: Runtime-dependent legacy E2E preflight
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -f workspace-configs-templates/claude-code-default/.auth-token ] && docker image inspect workspace:latest >/dev/null 2>&1; then
|
||||
bash tests/e2e/test_claude_code_e2e.sh
|
||||
bash tests/e2e/test_chat_upload_e2e.sh
|
||||
else
|
||||
echo "::notice::Skipping test_claude_code_e2e.sh and test_chat_upload_e2e.sh: require workspace:latest plus workspace-configs-templates/claude-code-default/.auth-token"
|
||||
fi
|
||||
|
||||
- name: Dump platform log on failure
|
||||
if: failure()
|
||||
run: cat workspace-server/platform.log || true
|
||||
|
||||
- name: Stop platform and stub agents
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f workspace-server/platform.pid ]; then
|
||||
kill "$(cat workspace-server/platform.pid)" 2>/dev/null || true
|
||||
fi
|
||||
if [ -f /tmp/molecule-stub-a2a.pid ]; then
|
||||
kill "$(cat /tmp/molecule-stub-a2a.pid)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
- name: Stop service containers
|
||||
if: always()
|
||||
run: |
|
||||
docker rm -f "$PG_CONTAINER" 2>/dev/null || true
|
||||
docker rm -f "$REDIS_CONTAINER" 2>/dev/null || true
|
||||
@@ -115,7 +115,7 @@ concurrency:
|
||||
# would let a queued staging/main push behind a PR run get cancelled,
|
||||
# leaving any gate that reads "completed run at SHA" stuck.
|
||||
group: e2e-peer-visibility-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
@@ -62,7 +62,7 @@ concurrency:
|
||||
# wasted CI is acceptable given the alternative is losing staging-tip
|
||||
# data that auto-promote-staging needs.
|
||||
group: e2e-staging-canvas-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
canvas: ${{ steps.decide.outputs.canvas }}
|
||||
@@ -140,7 +140,7 @@ jobs:
|
||||
name: Canvas tabs E2E
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 40
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
name: E2E Staging External Runtime
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
|
||||
|
||||
@@ -92,20 +92,20 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
- name: YAML validation (best-effort)
|
||||
run: |
|
||||
echo "e2e-staging-saas.yml — PR validation: workflow YAML is valid."
|
||||
echo "E2E step runs only when provisioning-critical files change."
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
# Actual E2E: runs on trunk pushes and PRs that touch provisioning-critical
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
name: E2E Staging SaaS
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 45
|
||||
permissions:
|
||||
|
||||
@@ -26,7 +26,7 @@ env:
|
||||
|
||||
concurrency:
|
||||
group: e2e-staging-sanity
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
name: Intentional-failure teardown sanity
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 20
|
||||
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
# PR_NUMBER — set via ${{ github.event.pull_request.number }} from the trigger
|
||||
# POST_COMMENT — "true" to post/update comment on PR
|
||||
#
|
||||
# Gating logic (MVP signals 1,2,3,4,6):
|
||||
# Gating logic (MVP signals 1,2,3,6):
|
||||
# 1. Author-aware agent-tag comment scan
|
||||
# 2. REQUEST_CHANGES reviews state machine
|
||||
# 3. Staleness detection (SOP-12: review.commit_id != PR.head_sha + >1 working day)
|
||||
# 4. Branch divergence / scope-creep guard (base-sha vs target HEAD; mc#365)
|
||||
# 6. CI required-checks awareness
|
||||
#
|
||||
# Exit code: 0=CLEAR, 1=BLOCKED, 2=ERROR
|
||||
@@ -66,7 +65,7 @@ jobs:
|
||||
# bp-exempt: PR advisory bot; merge blocking is enforced by CI status and branch protection.
|
||||
gate-check:
|
||||
runs-on: ubuntu-latest
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true # Never block on our own detector failing
|
||||
steps:
|
||||
- name: Check out BASE ref (never PR-head under pull_request_target)
|
||||
|
||||
@@ -69,7 +69,7 @@ on:
|
||||
branches: [main, staging]
|
||||
concurrency:
|
||||
group: handlers-pg-integ-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
@@ -87,8 +87,8 @@ jobs:
|
||||
# both jobs on the same label avoids workspace-volume cross-host
|
||||
# surprises and keeps the routing rule discoverable in one place.
|
||||
runs-on: docker-host
|
||||
# mc#1982 Phase 3 (RFC §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774 Phase 3 (RFC §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
handlers: ${{ steps.filter.outputs.handlers }}
|
||||
@@ -118,8 +118,8 @@ jobs:
|
||||
# mc#1529 §1: must run on operator-host (where `molecule-core-net`
|
||||
# exists). See detect-changes for the full routing rationale.
|
||||
runs-on: docker-host
|
||||
# mc#1982 Phase 3 (RFC §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774 Phase 3 (RFC §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
env:
|
||||
# Unique name per run so concurrent jobs don't collide on the
|
||||
|
||||
@@ -54,7 +54,7 @@ concurrency:
|
||||
# cancellation deadlock — see e2e-api.yml's concurrency block for
|
||||
# the 2026-04-28 incident that codified this pattern.
|
||||
group: harness-replays-${{ github.event.pull_request.head.sha || github.sha }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
# of mc#1543; see internal#512 for class defect.
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
run: ${{ steps.decide.outputs.run }}
|
||||
@@ -172,7 +172,7 @@ jobs:
|
||||
# beta containers. Must run on operator-host Linux (docker-host).
|
||||
runs-on: docker-host
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface drift without blocking. After 7
|
||||
# clean scheduled runs on main, flip to false so a scheduled
|
||||
# failure is a hard CI signal.
|
||||
continue-on-error: true # mc#1982 Phase 3 — flip to false after 7 clean main runs
|
||||
continue-on-error: true # mc#774 Phase 3 — flip to false after 7 clean main runs
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: lint-continue-on-error-tracking
|
||||
|
||||
# Tier 2e hard-gate lint (per mc#1982) — every
|
||||
# Tier 2e hard-gate lint (per mc#774) — every
|
||||
# `continue-on-error: true` in `.gitea/workflows/*.yml` must carry a
|
||||
# `# mc#NNNN` or `# internal#NNNN` tracker comment within 2 lines,
|
||||
# the referenced issue must be OPEN, and ≤14 days old.
|
||||
@@ -8,7 +8,7 @@ name: lint-continue-on-error-tracking
|
||||
# Why this exists
|
||||
# ---------------
|
||||
# `continue-on-error: true` on `platform-build` had been hiding
|
||||
# mc#1982-class regressions for ~3 weeks before #656 surfaced them on
|
||||
# mc#774-class regressions for ~3 weeks before #656 surfaced them on
|
||||
# 2026-05-12. A 14-day cap on tracker age forces a review cycle and
|
||||
# surfaces mask-drift within at most 14 days of the original defect.
|
||||
# Each `continue-on-error: true` gets a paper trail — close or renew.
|
||||
@@ -97,9 +97,9 @@ jobs:
|
||||
# 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
|
||||
# follow-up after main is clean for 3 days. mc#1982.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true # mc#1982 Phase 3 mask — 14d forced-renewal cadence
|
||||
# follow-up after main is clean for 3 days. mc#774.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true # mc#774 Phase 3 mask — 14d forced-renewal cadence
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
# the PR. Follow-up PR flips this off after surfaced defects are
|
||||
# triaged.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -92,8 +92,8 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken shapes without blocking
|
||||
# PRs. Follow-up PR flips this to `false` once recent runs on main
|
||||
# are confirmed clean (eat-our-own-dogfood discipline mirrors
|
||||
# PR#673's same-shape comment). Tracking: mc#1982.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# PR#673's same-shape comment). Tracking: mc#774.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out PR head with full history (need base SHA blobs)
|
||||
|
||||
@@ -4,7 +4,7 @@ name: Lint pre-flip continue-on-error
|
||||
# on any job in `.gitea/workflows/*.yml` WITHOUT proof that the affected
|
||||
# job's recent runs on the target branch (PR base) are actually green.
|
||||
#
|
||||
# Empirical class: PR #656 / mc#1982. PR #656 (RFC internal#219 Phase 4)
|
||||
# Empirical class: PR #656 / mc#774. PR #656 (RFC internal#219 Phase 4)
|
||||
# flipped 5 platform-build-class jobs `continue-on-error: true → false`
|
||||
# on the basis of a "verified green on main via combined-status check".
|
||||
# But that "green" was the LIE the prior `continue-on-error: true`
|
||||
@@ -99,8 +99,8 @@ jobs:
|
||||
timeout-minutes: 8
|
||||
# Phase 3 (RFC internal#219 §1): surface broken flips without blocking
|
||||
# the PR yet. Follow-up flips this to `false` once the workflow itself
|
||||
# has clean recent runs on main. mc#1982 interim — remove when CoE→false.
|
||||
continue-on-error: true # mc#1982
|
||||
# has clean recent runs on main. mc#774 interim — remove when CoE→false.
|
||||
continue-on-error: true # mc#774
|
||||
steps:
|
||||
- name: Check out PR head (full history for base-SHA access)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -83,8 +83,8 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
# Phase 3 (RFC #219 §1): surface the pattern without blocking PRs
|
||||
# while the directive convention beds in. Follow-up flip to false
|
||||
# after 7 clean days on main. mc#1982.
|
||||
continue-on-error: true # mc#1982 Phase 3 — flip to false after 7 clean main runs
|
||||
# after 7 clean days on main. mc#774.
|
||||
continue-on-error: true # mc#774 Phase 3 — flip to false after 7 clean main runs
|
||||
steps:
|
||||
- name: Check out PR head with full history (need base SHA blobs)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -3,26 +3,11 @@ name: Lint shellcheck (arm64 pilot)
|
||||
# Mac-CI dual-track pilot (#233). ADDITIVE / NOT REQUIRED.
|
||||
#
|
||||
# Validates the arm64 self-hosted lane (no docker.sock, no privileged
|
||||
# ops) before any required gate moves onto it.
|
||||
# ops) before any required gate moves onto it. Until a Mac arm64 runner
|
||||
# is registered with the `arm64` label, this workflow sits PENDING —
|
||||
# that is FINE: `arm64` is NOT in branch_protections required contexts.
|
||||
#
|
||||
# Runner label mapping (2026-05-22 fix): the actual Mac mini runner
|
||||
# registered in this Gitea ships labels
|
||||
# ["self-hosted","macos-self-hosted-arm64","arm64-darwin"]
|
||||
# — no plain `arm64`. The earlier `runs-on: [self-hosted, arm64]`
|
||||
# could not match any registered runner so every fire of this workflow
|
||||
# was assigned task_id=0 / runner_id=NULL → Gitea cancelled it. The
|
||||
# rows showed up as Cancelled in the action status feed (not Failed)
|
||||
# but the lane never actually ran. Workflow now selects on
|
||||
# `arm64-darwin` which is the canonical Mac-arm64 label per the
|
||||
# Mac mini's registration (per internal#494 capability-honest labels).
|
||||
#
|
||||
# If we later want to add a Linux-arm64 runner to the same lane, add
|
||||
# both labels to that runner's registration AND broaden the selector
|
||||
# here — don't rename `arm64-darwin` (it's Mac-specific by design and
|
||||
# `feedback_pc2_runner_labels_must_stay_narrow` rule applies).
|
||||
#
|
||||
# Pairs with internal#543 (RFC: Mac arm64 multi-arch runner-base) and
|
||||
# internal#494 (multi-arch runner-base capability-honest labels).
|
||||
# Pairs with internal#543 (RFC: Mac arm64 multi-arch runner-base).
|
||||
# No paths: filter on purpose (feedback_path_filtered_workflow_cant_be_required).
|
||||
|
||||
on:
|
||||
@@ -97,15 +82,7 @@ jobs:
|
||||
echo "WARN: shellcheck binary not found — skipping (pilot mode)"
|
||||
exit 0
|
||||
fi
|
||||
# NOTE: macOS ships Bash 3.2 (Apple license), no `mapfile`
|
||||
# (Bash 4+ builtin). Mac mini runner empirically failed at
|
||||
# `mapfile: command not found` (run 79275 / task 145654).
|
||||
# Use the portable `while read` pattern instead — works on
|
||||
# both Bash 3.2 (macOS) and Bash 4+ (Linux).
|
||||
TARGETS=()
|
||||
while IFS= read -r f; do
|
||||
TARGETS+=("$f")
|
||||
done < <(find .gitea/scripts -maxdepth 2 -type f -name '*.sh' | sort)
|
||||
mapfile -t TARGETS < <(find .gitea/scripts -maxdepth 2 -type f -name '*.sh' | sort)
|
||||
if [ "${#TARGETS[@]}" -eq 0 ]; then
|
||||
echo "No .sh files found under .gitea/scripts — nothing to check"
|
||||
exit 0
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
# Phase 3 (RFC #219 §1): surface broken shapes without blocking PRs.
|
||||
# Follow-up PR flips this off after the 4 existing-on-main rule-2
|
||||
# (workflow_run) violations are migrated to a supported trigger.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -67,7 +67,7 @@ jobs:
|
||||
# in this rollout (internal#462) so the precondition holds.
|
||||
runs-on: publish
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -234,18 +234,17 @@ jobs:
|
||||
name: Production auto-deploy
|
||||
needs: build-and-push
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# Side-effect deploy only; image publish success is the durable artifact. mc#1982
|
||||
# Side-effect deploy only; image publish success is the durable artifact. mc#774
|
||||
continue-on-error: true
|
||||
# Publish/release lane (internal#462) — production deploy of a merged
|
||||
# fix; reserved capacity, never queued behind PR-CI.
|
||||
runs-on: publish
|
||||
timeout-minutes: 90
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
||||
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
GITEA_TOKEN: ${{ secrets.PROD_AUTO_DEPLOY_CONTROL_TOKEN || secrets.AUTO_SYNC_TOKEN }}
|
||||
CI_STATUS_TIMEOUT_SECONDS: "3600"
|
||||
PROD_AUTO_DEPLOY_DISABLED: ${{ vars.PROD_AUTO_DEPLOY_DISABLED || secrets.PROD_AUTO_DEPLOY_DISABLED || '' }}
|
||||
PROD_AUTO_DEPLOY_CANARY_SLUG: ${{ vars.PROD_AUTO_DEPLOY_CANARY_SLUG || 'hongming' }}
|
||||
PROD_AUTO_DEPLOY_SOAK_SECONDS: ${{ vars.PROD_AUTO_DEPLOY_SOAK_SECONDS || '60' }}
|
||||
@@ -304,19 +303,26 @@ jobs:
|
||||
python3 .gitea/scripts/prod-auto-deploy.py assert-enabled
|
||||
PLAN="$RUNNER_TEMP/prod-auto-deploy-plan.json"
|
||||
TARGET_TAG="$(jq -r '.target_tag' "$PLAN")"
|
||||
BODY="$(jq -c '.body' "$PLAN")"
|
||||
|
||||
echo "POST $CP_URL/cp/admin/tenants/redeploy-fleet"
|
||||
echo " target_tag: $TARGET_TAG"
|
||||
echo " body: $BODY"
|
||||
|
||||
HTTP_RESPONSE="$RUNNER_TEMP/prod-redeploy-response.json"
|
||||
HTTP_CODE_FILE="$RUNNER_TEMP/prod-redeploy-http-code.txt"
|
||||
set +e
|
||||
python3 .gitea/scripts/prod-auto-deploy.py rollout \
|
||||
--plan "$PLAN" \
|
||||
--response "$HTTP_RESPONSE"
|
||||
ROLLOUT_EXIT=$?
|
||||
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
|
||||
-m 1200 \
|
||||
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
|
||||
-d "$BODY" > "$HTTP_CODE_FILE"
|
||||
set -e
|
||||
|
||||
if [ ! -s "$HTTP_RESPONSE" ]; then
|
||||
jq -nc --arg error "rollout command exited $ROLLOUT_EXIT before writing a response" \
|
||||
'{ok:false, results:[], error:$error}' > "$HTTP_RESPONSE"
|
||||
fi
|
||||
HTTP_CODE="$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")"
|
||||
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
|
||||
echo "HTTP $HTTP_CODE"
|
||||
jq '{ok, result_count: (.results // [] | length)}' "$HTTP_RESPONSE" || true
|
||||
|
||||
{
|
||||
@@ -324,6 +330,7 @@ jobs:
|
||||
echo ""
|
||||
echo "**Commit:** \`${GITHUB_SHA:0:7}\`"
|
||||
echo "**Target tag:** \`$TARGET_TAG\`"
|
||||
echo "**HTTP:** $HTTP_CODE"
|
||||
echo ""
|
||||
echo "### Per-tenant result"
|
||||
echo ""
|
||||
@@ -332,15 +339,15 @@ jobs:
|
||||
jq -r '.results[]? | "| \(.slug) | \(.phase) | \(.ssm_status // "-") | \(.ssm_exit_code) | \(.healthz_ok) | \((.error // "") != "") |"' "$HTTP_RESPONSE" || true
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::redeploy-fleet returned HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
OK="$(jq -r '.ok' "$HTTP_RESPONSE")"
|
||||
if [ "$OK" != "true" ]; then
|
||||
echo "::error::redeploy-fleet reported ok=false; production rollout halted."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$ROLLOUT_EXIT" -ne 0 ]; then
|
||||
echo "::error::redeploy-fleet rollout failed with exit code $ROLLOUT_EXIT."
|
||||
exit "$ROLLOUT_EXIT"
|
||||
fi
|
||||
|
||||
- name: Verify reachable tenants report this SHA
|
||||
if: ${{ steps.plan.outputs.enabled == 'true' }}
|
||||
|
||||
@@ -40,7 +40,7 @@ env:
|
||||
|
||||
concurrency:
|
||||
group: railway-pin-audit
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
name: Audit Railway env vars for drift-prone pins
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 10
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
# it never queues behind PR-CI. `publish` -> molecule-runner-publish-*.
|
||||
runs-on: publish
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
# `publish` -> molecule-runner-publish-* sub-pool.
|
||||
runs-on: publish
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
# 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).
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if apt-get update -qq && apt-get install -y -qq jq; then
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
name: Detect SECRET_PATTERNS drift
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
# window closed. continue-on-error: true has been removed from the
|
||||
# tier-check job; AND-composition is now fully enforced. If you need
|
||||
# to temporarily re-introduce a mask, file a tracker and follow the
|
||||
# mc#1982 protocol (Tier 2e lint requires a current tracker within
|
||||
# mc#774 protocol (Tier 2e lint requires a current tracker within
|
||||
# 2 lines of any continue-on-error: true).
|
||||
|
||||
name: sop-tier-check
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
# runners). The sop-tier-check script has its own fallback as a
|
||||
# third line of defense. continue-on-error: true ensures this step
|
||||
# failing does not block the job.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# apt-get is the primary method — Ubuntu package mirrors are reliably
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
# continue-on-error: true at step level — job-level is ignored by Gitea
|
||||
# Actions (quirk #10, internal runbooks). Belt-and-suspenders with
|
||||
# SOP_FAIL_OPEN=1 + || true below.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -38,7 +38,7 @@ on:
|
||||
# full run, but two smoke runs SHOULD queue against each other.
|
||||
concurrency:
|
||||
group: staging-smoke
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
# Needed to open / close the alerting issue.
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
staging-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
sha: ${{ steps.compute.outputs.sha }}
|
||||
@@ -212,7 +212,7 @@ jobs:
|
||||
if: ${{ needs.staging-smoke.result == 'success' && needs.staging-smoke.outputs.smoke_ran == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
env:
|
||||
SHA: ${{ needs.staging-smoke.outputs.sha }}
|
||||
|
||||
@@ -50,7 +50,7 @@ on:
|
||||
# Don't let two sweeps race the same AWS account.
|
||||
concurrency:
|
||||
group: sweep-aws-secrets
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -58,7 +58,7 @@ on:
|
||||
# scheduled run would otherwise issue duplicate DELETE calls.
|
||||
concurrency:
|
||||
group: sweep-cf-orphans
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -71,7 +71,7 @@ jobs:
|
||||
name: Sweep CF orphans
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
# 3 min surfaces hangs (CF API stall, AWS describe-instances stuck)
|
||||
# within one cron interval instead of burning a full tick. Realistic
|
||||
|
||||
@@ -42,7 +42,7 @@ on:
|
||||
# Don't let two sweeps race the same account.
|
||||
concurrency:
|
||||
group: sweep-cf-tunnels
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
name: Sweep CF tunnels
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
# 30 min cap. Was 5 min on the theory that the only thing that
|
||||
# could take >5min is a CF-API hang — but on 2026-05-02 a backlog
|
||||
|
||||
@@ -51,7 +51,7 @@ on:
|
||||
# on a manual trigger; queue rather than parallel-delete.
|
||||
concurrency:
|
||||
group: sweep-stale-e2e-orgs
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
name: sync-providers-yaml
|
||||
|
||||
# Cross-repo canonical↔synced-copy drift gate (internal#718 P2-A, CTO
|
||||
# 2026-05-27 "Distribution = SDK via codegen + verify-CI", multi-repo branch:
|
||||
# "codegen-checked-into-each-repo + verify-CI").
|
||||
#
|
||||
# The canonical provider-registry SSOT is molecule-controlplane
|
||||
# internal/providers/providers.yaml. molecule-core has NO Go module dependency
|
||||
# on controlplane, so instead of importing it we carry a SYNCED COPY at
|
||||
# workspace-server/internal/providers/providers.yaml and gate it.
|
||||
#
|
||||
# This workflow fetches the canonical providers.yaml from controlplane (via the
|
||||
# Gitea raw endpoint, read-only) and byte-compares it against core's synced
|
||||
# copy. RED if they differ — meaning the canonical moved and core's copy must be
|
||||
# re-synced (copy verbatim + `go generate ./...` + bump
|
||||
# canonicalProvidersYAMLSHA256 in sync_canonical_test.go).
|
||||
#
|
||||
# Pairs with:
|
||||
# * sync_canonical_test.go — hermetic sha pin (catches a hand-edit of core's
|
||||
# copy even with no network); runs in the normal `go test ./...`.
|
||||
# * verify-providers-gen.yml — artifact ↔ synced-copy drift.
|
||||
#
|
||||
# ENFORCEMENT GATING: standalone workflow, NOT a job in ci.yml and NOT in
|
||||
# branch protection (same soak-then-promote posture as verify-providers-gen).
|
||||
# It is intentionally absent from ci.yml's job set so the ci-required-drift
|
||||
# sentinel does not fire on it.
|
||||
#
|
||||
# AUTH: uses AUTO_SYNC_TOKEN (the existing cross-repo read token used to sync
|
||||
# template/provider content from sibling repos). If the secret is absent the
|
||||
# job emits a clear ::warning:: and exits 0 — the hermetic sha pin in
|
||||
# sync_canonical_test.go is the always-on backstop, so a missing cross-repo
|
||||
# token degrades to "hand-edit still caught, live canonical drift not caught"
|
||||
# rather than a hard red that blocks unrelated PRs.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- 'workspace-server/internal/providers/providers.yaml'
|
||||
- '.gitea/workflows/sync-providers-yaml.yml'
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- 'workspace-server/internal/providers/providers.yaml'
|
||||
- '.gitea/workflows/sync-providers-yaml.yml'
|
||||
schedule:
|
||||
# Daily at :23 — catch a canonical change in controlplane that landed
|
||||
# without a paired core re-sync PR (off-zero to spread cron load).
|
||||
- cron: '23 4 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: sync-providers-yaml-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
compare:
|
||||
name: Compare synced providers.yaml against controlplane canonical
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 6
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Fetch canonical providers.yaml from controlplane and byte-compare
|
||||
env:
|
||||
AUTO_SYNC_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
|
||||
API_ROOT: ${{ github.server_url }}/api/v1
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${AUTO_SYNC_TOKEN:-}" ]; then
|
||||
echo "::warning::AUTO_SYNC_TOKEN secret missing — skipping the live cross-repo compare."
|
||||
echo "The hermetic sha pin (sync_canonical_test.go) still gates hand-edits of core's copy."
|
||||
echo "Provision AUTO_SYNC_TOKEN (read scope on molecule-controlplane) to enable live canonical-drift detection."
|
||||
exit 0
|
||||
fi
|
||||
CANON_URL="${API_ROOT}/repos/molecule-ai/molecule-controlplane/raw/internal/providers/providers.yaml?ref=main"
|
||||
# Use the /raw endpoint: it returns the file bytes directly. (The
|
||||
# /contents endpoint ignores Accept: application/vnd.gitea.raw on
|
||||
# Gitea 1.22.6 and returns the JSON+base64 envelope, which made this
|
||||
# diff a permanent false RED.)
|
||||
curl -fsS \
|
||||
-H "Authorization: token ${AUTO_SYNC_TOKEN}" \
|
||||
"${CANON_URL}" -o /tmp/canonical-providers.yaml
|
||||
LOCAL=workspace-server/internal/providers/providers.yaml
|
||||
if diff -u /tmp/canonical-providers.yaml "$LOCAL"; then
|
||||
echo "OK — core's synced providers.yaml is byte-identical to the controlplane canonical."
|
||||
else
|
||||
echo "::error::core's synced providers.yaml DRIFTED from the controlplane canonical (SSOT)."
|
||||
echo "Re-sync: copy controlplane internal/providers/providers.yaml verbatim over"
|
||||
echo " $LOCAL, run 'go generate ./...' in workspace-server/, and bump"
|
||||
echo " canonicalProvidersYAMLSHA256 in internal/providers/sync_canonical_test.go."
|
||||
exit 1
|
||||
fi
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
name: Ops scripts (unittest)
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
name: verify-providers-gen
|
||||
|
||||
# Provider-registry SSOT enforcement gate — molecule-core side (internal#718
|
||||
# P2-A, CTO 2026-05-27 "Distribution = SDK via codegen + verify-CI").
|
||||
#
|
||||
# The canonical schema SSOT is molecule-controlplane
|
||||
# internal/providers/providers.yaml. molecule-core carries a SYNCED COPY at
|
||||
# workspace-server/internal/providers/providers.yaml (kept in sync by the
|
||||
# companion sync-providers-yaml.yml gate), and cmd/gen-providers emits the
|
||||
# checked-in Go projection workspace-server/internal/providers/gen/registry_gen.go.
|
||||
#
|
||||
# This workflow regenerates the artifact into the working tree and fails RED if
|
||||
# it differs from what is committed — catching BOTH:
|
||||
# * a providers.yaml (synced-copy) change that wasn't followed by `go generate ./...`, and
|
||||
# * a hand-edit of the generated artifact (it carries a DO NOT EDIT header).
|
||||
#
|
||||
# It is the molecule-core mirror of molecule-controlplane's verify-providers-gen
|
||||
# workflow. Together with sync-providers-yaml (canonical↔synced-copy drift) it
|
||||
# closes the codegen-checked-into-each-repo + verify-CI loop the RFC mandates.
|
||||
#
|
||||
# ENFORCEMENT GATING (deliberate, per dev-SOP "implementation gating"):
|
||||
# this is a STANDALONE workflow, NOT a job inside ci.yml, and is NOT yet in any
|
||||
# branch-protection status_check_contexts. Rationale (identical to the CP P0
|
||||
# rollout):
|
||||
# * It runs + reports RED on every PR/push immediately (visible signal).
|
||||
# * It is intentionally absent from ci.yml's job set so the ci-required-drift
|
||||
# sentinel (jobs ↔ branch-protection ↔ audit-env) does NOT fire on it, and
|
||||
# from branch protection (turning it into a hard merge gate has blast radius
|
||||
# — operator GO required, same pattern as sop-tier-check / verify-providers-gen
|
||||
# on controlplane). Promote it into branch protection in a follow-up once
|
||||
# P2 has soaked.
|
||||
# Until then it behaves like secret-scan / block-internal-paths: a standalone
|
||||
# advisory-to-hard gate the author is expected to keep green.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
push:
|
||||
branches: [main, staging]
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: verify-providers-gen-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Regenerate providers artifact and fail on drift
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
|
||||
with:
|
||||
go-version: 'stable'
|
||||
cache: true
|
||||
cache-dependency-path: workspace-server/go.sum
|
||||
|
||||
- name: Verify generated artifact is in sync with providers.yaml
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# -check regenerates in memory and byte-compares against the
|
||||
# checked-in artifact; exit 1 (RED) on any drift. This is the
|
||||
# single source of the gate's verdict — the same code path
|
||||
# `go test ./cmd/gen-providers` exercises.
|
||||
go run ./cmd/gen-providers -check
|
||||
|
||||
- name: Belt-and-braces — regenerate in place and assert clean tree
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Independent confirmation that does not trust the -check path:
|
||||
# actually write the artifact and assert git sees no change. If
|
||||
# this and the step above ever disagree, the gate is suspect.
|
||||
go generate ./...
|
||||
if ! git diff --quiet -- internal/providers/gen/registry_gen.go; then
|
||||
echo "::error::workspace-server/internal/providers/gen/registry_gen.go drifted from providers.yaml."
|
||||
echo "Run 'go generate ./...' (or 'go run ./cmd/gen-providers') in workspace-server/ and commit the result."
|
||||
git --no-pager diff -- internal/providers/gen/registry_gen.go | head -80
|
||||
exit 1
|
||||
fi
|
||||
echo "OK — generated providers artifact is in sync with the schema SSOT."
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
name: Weekly Platform-Go Surface
|
||||
runs-on: ubuntu-latest
|
||||
# continue-on-error: surface only, never block
|
||||
# mc#1982: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -46,18 +46,6 @@
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://git.moleculesai.app/molecule-ai/molecule-monorepo.git
|
||||
cd molecule-monorepo
|
||||
./scripts/dev-start.sh
|
||||
```
|
||||
|
||||
Then open [http://localhost:3000](http://localhost:3000), add your model API key in **Config → Secrets & API Keys → Global**, and create a workspace from a template.
|
||||
|
||||
See the full [Quickstart Guide](./docs/quickstart.md) for prerequisites, manual setup, and troubleshooting.
|
||||
|
||||
## The Pitch
|
||||
|
||||
Molecule AI is the most powerful way to govern an AI agent organization in production.
|
||||
|
||||
@@ -41,12 +41,6 @@ describe("buildCsp — production", () => {
|
||||
expect(csp).toContain("object-src 'none'");
|
||||
});
|
||||
|
||||
it("allows blob: in frame-src for authenticated PDF previews", () => {
|
||||
const frameSrc = csp.match(/frame-src[^;]*/)?.[0] ?? "";
|
||||
expect(frameSrc).toContain("'self'");
|
||||
expect(frameSrc).toContain("blob:");
|
||||
});
|
||||
|
||||
it("locks base-uri to 'self' (prevents base-tag injection)", () => {
|
||||
expect(csp).toContain("base-uri 'self'");
|
||||
});
|
||||
|
||||
@@ -5,13 +5,6 @@ import * as Dialog from "@radix-ui/react-dialog";
|
||||
import { api } from "@/lib/api";
|
||||
import { isSaaSTenant } from "@/lib/tenant";
|
||||
import { ExternalConnectModal, type ExternalConnectionInfo } from "./ExternalConnectModal";
|
||||
import {
|
||||
ProviderModelSelector,
|
||||
buildProviderCatalog,
|
||||
findProviderForModel,
|
||||
type SelectorModel,
|
||||
type SelectorValue,
|
||||
} from "./ProviderModelSelector";
|
||||
|
||||
interface WorkspaceOption {
|
||||
id: string;
|
||||
@@ -29,29 +22,96 @@ interface TemplateSpec {
|
||||
id: string;
|
||||
name?: string;
|
||||
runtime?: string;
|
||||
model?: string;
|
||||
models?: SelectorModel[];
|
||||
providers?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_RUNTIME = "claude-code";
|
||||
const RUNTIME_OPTIONS = [
|
||||
{ value: "claude-code", label: "Claude Code" },
|
||||
{ value: "codex", label: "OpenAI Codex CLI" },
|
||||
{ value: "hermes", label: "Hermes" },
|
||||
{ value: "openclaw", label: "OpenClaw" },
|
||||
interface HermesProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
envVar: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
}
|
||||
|
||||
type LLMAuthMode = "platform" | "api_key" | "oauth";
|
||||
|
||||
interface NativeLLMProvider {
|
||||
id: string;
|
||||
label: string;
|
||||
envVar?: string;
|
||||
defaultModel: string;
|
||||
models: string[];
|
||||
authModes: LLMAuthMode[];
|
||||
}
|
||||
|
||||
export const NATIVE_LLM_PROVIDERS: NativeLLMProvider[] = [
|
||||
{
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
envVar: "MINIMAX_API_KEY",
|
||||
defaultModel: "MiniMax-M2.7",
|
||||
models: ["MiniMax-M2.7", "MiniMax-M2.7-highspeed", "MiniMax-M2.5"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "kimi-coding",
|
||||
label: "Kimi",
|
||||
envVar: "KIMI_API_KEY",
|
||||
defaultModel: "kimi-for-coding",
|
||||
models: ["kimi-for-coding", "kimi-k2.5", "kimi-k2"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "anthropic",
|
||||
label: "Anthropic",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
defaultModel: "claude-sonnet-4-6",
|
||||
models: ["claude-sonnet-4-6", "claude-opus-4-7", "claude-haiku-4-5"],
|
||||
authModes: ["platform", "api_key"],
|
||||
},
|
||||
{
|
||||
id: "anthropic-oauth",
|
||||
label: "Claude OAuth",
|
||||
envVar: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
defaultModel: "sonnet",
|
||||
models: ["sonnet", "opus", "haiku"],
|
||||
authModes: ["oauth"],
|
||||
},
|
||||
];
|
||||
const BASE_RUNTIME_TEMPLATE_IDS = new Set(["claude-code-default", "codex", "hermes", "openclaw"]);
|
||||
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
|
||||
const DEFAULT_HEADLESS_ROOT_GB = 30;
|
||||
const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge";
|
||||
const DEFAULT_DISPLAY_ROOT_GB = 80;
|
||||
|
||||
// All providers supported by Hermes runtime via providers.resolve_provider().
|
||||
// `defaultModel` is the slug injected into the workspace provision request
|
||||
// when the user picks this provider — template-hermes's derive-provider.sh
|
||||
// maps the prefix back to the provider name at install time, so this is
|
||||
// the canonical handshake. `models` are additional suggestions surfaced in
|
||||
// the datalist so the user can pick a different size without typing the
|
||||
// whole slug.
|
||||
export const HERMES_PROVIDERS: HermesProvider[] = [
|
||||
{ id: "anthropic", label: "Anthropic (Claude)", envVar: "ANTHROPIC_API_KEY", defaultModel: "anthropic/claude-sonnet-4-5", models: ["anthropic/claude-opus-4-5", "anthropic/claude-sonnet-4-5", "anthropic/claude-haiku-4-5"] },
|
||||
{ id: "openai", label: "OpenAI", envVar: "OPENAI_API_KEY", defaultModel: "openai/gpt-4o", models: ["openai/gpt-4o", "openai/gpt-4o-mini", "openai/o3-mini"] },
|
||||
{ id: "openrouter", label: "OpenRouter", envVar: "OPENROUTER_API_KEY", defaultModel: "openrouter/auto", models: ["openrouter/auto", "openrouter/anthropic/claude-sonnet-4", "openrouter/meta-llama/llama-3.3-70b"] },
|
||||
{ id: "xai", label: "xAI (Grok)", envVar: "XAI_API_KEY", defaultModel: "xai/grok-4", models: ["xai/grok-4", "xai/grok-4-mini"] },
|
||||
{ id: "gemini", label: "Google Gemini", envVar: "GEMINI_API_KEY", defaultModel: "gemini/gemini-2.5-pro", models: ["gemini/gemini-2.5-pro", "gemini/gemini-2.5-flash"] },
|
||||
{ id: "qwen", label: "Qwen (Alibaba)", envVar: "QWEN_API_KEY", defaultModel: "alibaba/qwen3-max", models: ["alibaba/qwen3-max", "alibaba/qwen3-coder"] },
|
||||
{ id: "glm", label: "GLM (Zhipu AI)", envVar: "GLM_API_KEY", defaultModel: "zai/glm-4.6", models: ["zai/glm-4.6", "zai/glm-4.5-air"] },
|
||||
{ id: "kimi", label: "Kimi (Moonshot)", envVar: "KIMI_API_KEY", defaultModel: "kimi-coding/kimi-k2", models: ["kimi-coding/kimi-k2", "kimi-coding/kimi-k1.5"] },
|
||||
{ id: "minimax", label: "MiniMax", envVar: "MINIMAX_API_KEY", defaultModel: "minimax/MiniMax-M2.7", models: ["minimax/MiniMax-M2.7", "minimax/MiniMax-M2.7-highspeed", "minimax/MiniMax-M1"] },
|
||||
{ id: "deepseek", label: "DeepSeek", envVar: "DEEPSEEK_API_KEY", defaultModel: "deepseek/deepseek-chat", models: ["deepseek/deepseek-chat", "deepseek/deepseek-reasoner"] },
|
||||
{ id: "groq", label: "Groq", envVar: "GROQ_API_KEY", defaultModel: "openrouter/groq/llama-3.3-70b", models: ["openrouter/groq/llama-3.3-70b"] },
|
||||
{ id: "mistral", label: "Mistral", envVar: "MISTRAL_API_KEY", defaultModel: "openrouter/mistralai/mistral-large", models: ["openrouter/mistralai/mistral-large"] },
|
||||
{ id: "together", label: "Together AI", envVar: "TOGETHER_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] },
|
||||
{ id: "fireworks", label: "Fireworks AI", envVar: "FIREWORKS_API_KEY", defaultModel: "openrouter/meta-llama/llama-3.3-70b", models: ["openrouter/meta-llama/llama-3.3-70b"] },
|
||||
{ id: "hermes", label: "Hermes / Nous (legacy)", envVar: "HERMES_API_KEY", defaultModel: "nousresearch/Hermes-3-Llama-3.1-405B", models: ["nousresearch/Hermes-3-Llama-3.1-405B", "nousresearch/Hermes-4-14B"] },
|
||||
];
|
||||
|
||||
export function CreateWorkspaceButton() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [role, setRole] = useState("");
|
||||
const [runtime, setRuntime] = useState(DEFAULT_RUNTIME);
|
||||
const [template, setTemplate] = useState("");
|
||||
const [parentId, setParentId] = useState("");
|
||||
const [budgetLimit, setBudgetLimit] = useState("");
|
||||
@@ -66,22 +126,32 @@ export function CreateWorkspaceButton() {
|
||||
// filter below. Same data source ConfigTab uses (PR #2454). When the
|
||||
// selected template declares `runtime_config.providers` in its
|
||||
// config.yaml, the modal surfaces only those providers in the
|
||||
// <select>. Provider/model options are derived from template models.
|
||||
// <select>. Empty/missing list falls back to the full HERMES_PROVIDERS
|
||||
// catalog so older templates without the field keep working.
|
||||
const [templateSpecs, setTemplateSpecs] = useState<TemplateSpec[]>([]);
|
||||
// External-runtime path: skip docker provision, mint a workspace_auth_token,
|
||||
// and surface the connection snippet in a modal after create. When
|
||||
// isExternal is true the template and model fields are hidden (they're
|
||||
// meaningless for BYO-compute agents).
|
||||
// isExternal is true the template / model / hermes-provider fields are
|
||||
// hidden (they're meaningless for BYO-compute agents).
|
||||
const [isExternal, setIsExternal] = useState(false);
|
||||
const [externalRuntime, setExternalRuntime] = useState("external");
|
||||
const [externalConnection, setExternalConnection] =
|
||||
useState<ExternalConnectionInfo | null>(null);
|
||||
|
||||
const [llmSelection, setLLMSelection] = useState<SelectorValue>({
|
||||
providerId: "",
|
||||
model: "",
|
||||
envVars: [],
|
||||
});
|
||||
// Hermes-specific state
|
||||
const [hermesProvider, setHermesProvider] = useState("anthropic");
|
||||
const [hermesApiKey, setHermesApiKey] = useState("");
|
||||
// Model slug is sent to CP as `model` and plumbed to the workspace EC2
|
||||
// as HERMES_DEFAULT_MODEL env var. template-hermes's derive-provider.sh
|
||||
// reads the prefix (`minimax/…`, `anthropic/…`) to set
|
||||
// HERMES_INFERENCE_PROVIDER at install time. Missing model → provider
|
||||
// falls back to "auto" and hermes picks its compiled-in default
|
||||
// (Anthropic), which 401s if the user's key is for a different
|
||||
// provider. Hence: require model when template=hermes.
|
||||
const [hermesModel, setHermesModel] = useState("");
|
||||
const [llmAuthMode, setLLMAuthMode] = useState<LLMAuthMode>("platform");
|
||||
const [llmProvider, setLLMProvider] = useState("minimax");
|
||||
const [llmModel, setLLMModel] = useState("MiniMax-M2.7");
|
||||
const [llmSecret, setLLMSecret] = useState("");
|
||||
|
||||
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
|
||||
@@ -138,65 +208,93 @@ export function CreateWorkspaceButton() {
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRuntimeChange = useCallback((nextRuntime: string) => {
|
||||
setRuntime(nextRuntime);
|
||||
setTemplate("");
|
||||
setLLMSelection({ providerId: "", model: "", envVars: [] });
|
||||
setLLMSecret("");
|
||||
}, []);
|
||||
const isHermes = template.trim().toLowerCase() === "hermes";
|
||||
const nativeLLMProviders = useMemo(
|
||||
() => NATIVE_LLM_PROVIDERS.filter((p) => p.authModes.includes(llmAuthMode)),
|
||||
[llmAuthMode],
|
||||
);
|
||||
const selectedNativeProvider = useMemo(
|
||||
() => nativeLLMProviders.find((p) => p.id === llmProvider) ?? nativeLLMProviders[0],
|
||||
[llmProvider, nativeLLMProviders],
|
||||
);
|
||||
|
||||
// Resolve the selected workspace template from /templates. Runtime is
|
||||
// deliberately separate: "SEO Agent" is a workspace template, not a
|
||||
// runtime, so it must never appear in the runtime selector.
|
||||
// Resolve the selected template's spec from the /templates response.
|
||||
// The `template` input is free-text; templates can be matched by id,
|
||||
// name, or runtime so any of those work. Lower-cased compare keeps
|
||||
// "Hermes" / "hermes" / "HERMES" interchangeable.
|
||||
const selectedTemplateSpec = useMemo<TemplateSpec | null>(() => {
|
||||
if (!template) return null;
|
||||
return templateSpecs.find((s) => s.id === template) ?? null;
|
||||
const t = template.trim().toLowerCase();
|
||||
if (!t) return null;
|
||||
return (
|
||||
templateSpecs.find(
|
||||
(s) =>
|
||||
(s.id || "").toLowerCase() === t ||
|
||||
(s.name || "").toLowerCase() === t ||
|
||||
(s.runtime || "").toLowerCase() === t,
|
||||
) ?? null
|
||||
);
|
||||
}, [template, templateSpecs]);
|
||||
const selectedRuntimeTemplateSpec = useMemo<TemplateSpec | null>(() => (
|
||||
templateSpecs.find((s) => {
|
||||
if (!BASE_RUNTIME_TEMPLATE_IDS.has(s.id)) return false;
|
||||
const specRuntime = (s.runtime ?? s.id).trim().toLowerCase();
|
||||
return s.id === runtime || specRuntime === runtime;
|
||||
}) ?? null
|
||||
), [runtime, templateSpecs]);
|
||||
const visibleTemplateSpecs = useMemo(
|
||||
() => templateSpecs.filter((spec) => {
|
||||
if (BASE_RUNTIME_TEMPLATE_IDS.has(spec.id)) return false;
|
||||
const specRuntime = (spec.runtime ?? DEFAULT_RUNTIME).trim().toLowerCase();
|
||||
return specRuntime === runtime;
|
||||
}),
|
||||
[runtime, templateSpecs],
|
||||
);
|
||||
const llmModels = useMemo(
|
||||
() => {
|
||||
const sourceSpec = selectedTemplateSpec ?? selectedRuntimeTemplateSpec;
|
||||
if (!sourceSpec?.models?.length) return [];
|
||||
return sourceSpec.models;
|
||||
},
|
||||
[selectedRuntimeTemplateSpec, selectedTemplateSpec],
|
||||
);
|
||||
const llmCatalog = useMemo(() => buildProviderCatalog(llmModels), [llmModels]);
|
||||
const selectedLLMProvider = useMemo(
|
||||
() => llmCatalog.find((p) => p.id === llmSelection.providerId) ?? llmCatalog[0],
|
||||
[llmCatalog, llmSelection.providerId],
|
||||
);
|
||||
|
||||
// Filter HERMES_PROVIDERS by what the template declares it supports.
|
||||
// Empty/missing declared list → fall back to the full catalog so
|
||||
// templates that haven't migrated to the explicit `providers:` field
|
||||
// (and self-hosted setups without /templates) keep working unchanged.
|
||||
const availableProviders = useMemo<HermesProvider[]>(() => {
|
||||
const declared = selectedTemplateSpec?.providers;
|
||||
if (!declared || declared.length === 0) return HERMES_PROVIDERS;
|
||||
const allowed = new Set(declared.map((p) => p.toLowerCase()));
|
||||
const filtered = HERMES_PROVIDERS.filter((p) => allowed.has(p.id.toLowerCase()));
|
||||
// Defensive: if the template's declared list doesn't match anything
|
||||
// in our static catalog (e.g. brand-new provider id we don't have
|
||||
// metadata for yet), fall back to the full list rather than render
|
||||
// an empty <select>. Better to over-show than to lock the user out.
|
||||
return filtered.length > 0 ? filtered : HERMES_PROVIDERS;
|
||||
}, [selectedTemplateSpec]);
|
||||
|
||||
// If the currently-selected provider is filtered out by a template
|
||||
// change, snap back to the first available. Without this, the
|
||||
// hermesProvider state could refer to a provider not in the dropdown
|
||||
// — confusing UI + the API key field's envVar would be wrong.
|
||||
useEffect(() => {
|
||||
if (!isHermes) return;
|
||||
if (availableProviders.length === 0) return;
|
||||
if (!availableProviders.some((p) => p.id === hermesProvider)) {
|
||||
setHermesProvider(availableProviders[0].id);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [availableProviders, isHermes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (llmCatalog.length === 0) return;
|
||||
const sourceDefault = (selectedTemplateSpec ?? selectedRuntimeTemplateSpec)?.model?.trim();
|
||||
const platformProvider = llmCatalog.find((p) => p.vendor === "platform");
|
||||
const matched = sourceDefault ? findProviderForModel(llmCatalog, sourceDefault) : null;
|
||||
const next = platformProvider ?? matched ?? llmCatalog[0];
|
||||
const defaultModel = next.models.find((model) => model.id === sourceDefault)?.id
|
||||
?? next.models[0]?.id
|
||||
?? "";
|
||||
setLLMSelection({
|
||||
providerId: next.id,
|
||||
model: next.wildcard ? "" : defaultModel,
|
||||
envVars: next.envVars,
|
||||
});
|
||||
setLLMSecret("");
|
||||
}, [llmCatalog, selectedRuntimeTemplateSpec, selectedTemplateSpec]);
|
||||
if (isHermes) return;
|
||||
if (nativeLLMProviders.length === 0) return;
|
||||
if (!nativeLLMProviders.some((p) => p.id === llmProvider)) {
|
||||
setLLMProvider(nativeLLMProviders[0].id);
|
||||
setLLMModel(nativeLLMProviders[0].defaultModel);
|
||||
}
|
||||
}, [isHermes, llmProvider, nativeLLMProviders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isHermes || !selectedNativeProvider) return;
|
||||
if (!selectedNativeProvider.models.includes(llmModel)) {
|
||||
setLLMModel(selectedNativeProvider.defaultModel);
|
||||
}
|
||||
}, [isHermes, llmModel, selectedNativeProvider]);
|
||||
|
||||
// Auto-fill hermesModel with the provider's defaultModel whenever the
|
||||
// provider changes, but only if the user hasn't already typed their own
|
||||
// slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap.
|
||||
useEffect(() => {
|
||||
if (!isHermes) return;
|
||||
const p = HERMES_PROVIDERS.find((x) => x.id === hermesProvider);
|
||||
if (!p) return;
|
||||
// Replace model only if current value matches another provider's
|
||||
// default (user hasn't customized it) OR is empty.
|
||||
const isUntouched =
|
||||
hermesModel === "" ||
|
||||
HERMES_PROVIDERS.some((x) => x.defaultModel === hermesModel);
|
||||
if (isUntouched) setHermesModel(p.defaultModel);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hermesProvider, isHermes]);
|
||||
|
||||
// Reset form and load workspaces whenever dialog opens
|
||||
useEffect(() => {
|
||||
@@ -204,7 +302,6 @@ export function CreateWorkspaceButton() {
|
||||
setName("");
|
||||
setRole("");
|
||||
setTier(defaultTier);
|
||||
setRuntime(DEFAULT_RUNTIME);
|
||||
setTemplate("");
|
||||
setParentId("");
|
||||
setBudgetLimit("");
|
||||
@@ -213,8 +310,13 @@ export function CreateWorkspaceButton() {
|
||||
setDisplayInstanceType(DEFAULT_DISPLAY_INSTANCE_TYPE);
|
||||
setDisplayRootGB(String(DEFAULT_DISPLAY_ROOT_GB));
|
||||
setDisplayResolution("1920x1080");
|
||||
setHermesProvider("anthropic");
|
||||
setExternalRuntime("external");
|
||||
setLLMSelection({ providerId: "", model: "", envVars: [] });
|
||||
setHermesApiKey("");
|
||||
setHermesModel("");
|
||||
setLLMAuthMode("platform");
|
||||
setLLMProvider("minimax");
|
||||
setLLMModel("MiniMax-M2.7");
|
||||
setLLMSecret("");
|
||||
api
|
||||
.get<WorkspaceOption[]>("/workspaces")
|
||||
@@ -223,7 +325,7 @@ export function CreateWorkspaceButton() {
|
||||
api
|
||||
.get<TemplateSpec[]>("/templates")
|
||||
.then((rows) => setTemplateSpecs(Array.isArray(rows) ? rows : []))
|
||||
.catch(() => { /* keep empty; create stays blocked until the catalog loads */ });
|
||||
.catch(() => { /* keep empty — HERMES_PROVIDERS fallback below */ });
|
||||
// defaultTier is stable for the session (derived from window.location),
|
||||
// safe to omit from deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -234,18 +336,29 @@ export function CreateWorkspaceButton() {
|
||||
setError("Name is required");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && !llmSelection.model.trim()) {
|
||||
if (isHermes && !hermesApiKey.trim()) {
|
||||
setError("API key is required for Hermes workspaces");
|
||||
return;
|
||||
}
|
||||
if (isHermes && !hermesModel.trim()) {
|
||||
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && !isHermes && !llmModel.trim()) {
|
||||
setError("Model is required");
|
||||
return;
|
||||
}
|
||||
if (!isExternal && selectedLLMProvider?.envVars.length && !llmSecret.trim()) {
|
||||
setError("Provider credential is required");
|
||||
if (!isExternal && !isHermes && llmAuthMode !== "platform" && !llmSecret.trim()) {
|
||||
setError(llmAuthMode === "oauth" ? "Claude OAuth token is required" : "API key is required");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
|
||||
const nativeProvider = selectedLLMProvider;
|
||||
const provider = isHermes
|
||||
? HERMES_PROVIDERS.find((p) => p.id === hermesProvider)
|
||||
: undefined;
|
||||
const nativeProvider = !isHermes ? selectedNativeProvider : undefined;
|
||||
|
||||
try {
|
||||
const parsedBudget = budgetLimit.trim()
|
||||
@@ -269,12 +382,12 @@ export function CreateWorkspaceButton() {
|
||||
tier,
|
||||
parent_id: parentId || undefined,
|
||||
budget_limit: parsedBudget,
|
||||
...(!isExternal && nativeProvider
|
||||
...(!isExternal && !isHermes && nativeProvider
|
||||
? {
|
||||
model: llmSelection.model.trim(),
|
||||
llm_provider: nativeProvider.vendor,
|
||||
...(nativeProvider.envVars.length > 0
|
||||
? { secrets: { [nativeProvider.envVars[0]]: llmSecret.trim() } }
|
||||
model: llmModel.trim(),
|
||||
llm_provider: nativeProvider.id,
|
||||
...(llmAuthMode !== "platform" && nativeProvider.envVar
|
||||
? { secrets: { [nativeProvider.envVar]: llmSecret.trim() } }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
@@ -302,7 +415,13 @@ export function CreateWorkspaceButton() {
|
||||
// Runtime=external flips the backend into awaiting-agent mode:
|
||||
// no container provisioning, token minted, connection payload
|
||||
// returned in the response for the modal below.
|
||||
...(isExternal ? { runtime: externalRuntime } : { runtime }),
|
||||
...(isExternal ? { runtime: externalRuntime } : {}),
|
||||
...(!isExternal && isHermes && provider
|
||||
? {
|
||||
secrets: { [provider.envVar]: hermesApiKey.trim() },
|
||||
model: hermesModel.trim(),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
// External path: keep the create dialog open just long enough to
|
||||
// hand control to the connect modal, then close. The connect
|
||||
@@ -414,64 +533,77 @@ export function CreateWorkspaceButton() {
|
||||
)}
|
||||
|
||||
{!isExternal && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label htmlFor="runtime-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Runtime
|
||||
</label>
|
||||
<select
|
||||
id="runtime-select"
|
||||
value={runtime}
|
||||
onChange={(e) => handleRuntimeChange(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
{RUNTIME_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="workspace-template-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Workspace Template
|
||||
</label>
|
||||
<select
|
||||
id="workspace-template-select"
|
||||
value={template}
|
||||
onChange={(e) => setTemplate(e.target.value)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="">Blank workspace</option>
|
||||
{visibleTemplateSpecs.map((spec) => (
|
||||
<option key={spec.id} value={spec.id}>
|
||||
{spec.name || spec.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<InputField
|
||||
label="Template"
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
|
||||
mono
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isExternal && selectedLLMProvider && (
|
||||
{!isExternal && !isHermes && selectedNativeProvider && (
|
||||
<div className="rounded-lg border border-line/50 bg-surface-card/40 p-3 space-y-3">
|
||||
<div className="text-[11px] font-medium text-ink-mid">
|
||||
LLM
|
||||
</div>
|
||||
<ProviderModelSelector
|
||||
models={llmModels}
|
||||
value={llmSelection}
|
||||
onChange={(next) => {
|
||||
setLLMSelection(next);
|
||||
setLLMSecret("");
|
||||
}}
|
||||
idPrefix="create-workspace-llm"
|
||||
variant="stack"
|
||||
/>
|
||||
{selectedLLMProvider.envVars.length > 0 && (
|
||||
<div>
|
||||
<label htmlFor="llm-auth-mode" className="text-[11px] text-ink-mid block mb-1">
|
||||
Auth Mode
|
||||
</label>
|
||||
<select
|
||||
id="llm-auth-mode"
|
||||
value={llmAuthMode}
|
||||
onChange={(e) => setLLMAuthMode(e.target.value as LLMAuthMode)}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
<option value="platform">Platform provided</option>
|
||||
<option value="api_key">API key</option>
|
||||
<option value="oauth">Claude OAuth</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-provider-select" className="text-[11px] text-ink-mid block mb-1">
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
id="llm-provider-select"
|
||||
value={selectedNativeProvider.id}
|
||||
onChange={(e) => {
|
||||
const next = nativeLLMProviders.find((p) => p.id === e.target.value);
|
||||
setLLMProvider(e.target.value);
|
||||
if (next) setLLMModel(next.defaultModel);
|
||||
}}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
>
|
||||
{nativeLLMProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="llm-model-input" className="text-[11px] text-ink-mid block mb-1">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
id="llm-model-input"
|
||||
type="text"
|
||||
value={llmModel}
|
||||
onChange={(e) => setLLMModel(e.target.value)}
|
||||
list="llm-model-suggestions"
|
||||
spellCheck={false}
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
|
||||
/>
|
||||
<datalist id="llm-model-suggestions">
|
||||
{selectedNativeProvider.models.map((m) => <option key={m} value={m} />)}
|
||||
</datalist>
|
||||
</div>
|
||||
{llmAuthMode !== "platform" && (
|
||||
<div>
|
||||
<label htmlFor="llm-secret-input" className="text-[11px] text-ink-mid block mb-1">
|
||||
{selectedLLMProvider.envVars[0]}
|
||||
{llmAuthMode === "oauth" ? "OAuth Token" : "API Key"}
|
||||
</label>
|
||||
<input
|
||||
id="llm-secret-input"
|
||||
@@ -609,6 +741,100 @@ export function CreateWorkspaceButton() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hermes provider configuration — shown only when template === "hermes" */}
|
||||
{isHermes && (
|
||||
<div
|
||||
className="mt-4 rounded-xl border border-violet-700/40 bg-violet-950/20 p-4 space-y-3"
|
||||
data-testid="hermes-provider-section"
|
||||
>
|
||||
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
|
||||
Hermes Provider
|
||||
</p>
|
||||
<p className="text-[11px] text-ink-mid -mt-1">
|
||||
Choose the AI provider and paste your API key. The key is
|
||||
stored as an encrypted workspace secret.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-provider-select"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
>
|
||||
Provider
|
||||
</label>
|
||||
<select
|
||||
id="hermes-provider-select"
|
||||
value={hermesProvider}
|
||||
onChange={(e) => setHermesProvider(e.target.value)}
|
||||
aria-label="Hermes provider"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors"
|
||||
>
|
||||
{availableProviders.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-api-key-input"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
>
|
||||
API Key{" "}
|
||||
<span aria-hidden="true" className="text-bad">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<input
|
||||
id="hermes-api-key-input"
|
||||
type="password"
|
||||
value={hermesApiKey}
|
||||
onChange={(e) => setHermesApiKey(e.target.value)}
|
||||
placeholder="sk-…"
|
||||
aria-label="Hermes API key"
|
||||
autoComplete="off"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="hermes-model-input"
|
||||
className="text-[11px] text-ink-mid block mb-1"
|
||||
>
|
||||
Model{" "}
|
||||
<span aria-hidden="true" className="text-bad">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only"> (required)</span>
|
||||
</label>
|
||||
<input
|
||||
id="hermes-model-input"
|
||||
type="text"
|
||||
value={hermesModel}
|
||||
onChange={(e) => setHermesModel(e.target.value)}
|
||||
placeholder="e.g. minimax/MiniMax-M2.7"
|
||||
aria-label="Hermes model slug"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
list="hermes-model-suggestions"
|
||||
className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink placeholder-ink-soft focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors font-mono"
|
||||
/>
|
||||
<datalist id="hermes-model-suggestions">
|
||||
{HERMES_PROVIDERS.find((p) => p.id === hermesProvider)?.models.map(
|
||||
(m) => <option key={m} value={m} />,
|
||||
)}
|
||||
</datalist>
|
||||
<p className="text-[10px] text-ink-mid mt-1">
|
||||
Slug determines which provider hermes routes to at install time.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { OrgTemplatesSection } from "./TemplatePalette";
|
||||
import { isUserVisibleWorkspaceTemplate, type Template } from "@/lib/deploy-preflight";
|
||||
import { type Template } from "@/lib/deploy-preflight";
|
||||
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
|
||||
import { Spinner } from "./Spinner";
|
||||
import { TIER_CONFIG } from "@/lib/design-tokens";
|
||||
@@ -18,7 +18,7 @@ export function EmptyState() {
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<Template[]>("/templates")
|
||||
.then((t) => setTemplates(t.filter(isUserVisibleWorkspaceTemplate)))
|
||||
.then((t) => setTemplates(t))
|
||||
.catch(() => setTemplates([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
@@ -23,8 +23,6 @@ interface Props {
|
||||
/** Grouped provider options derived from the template's models[] /
|
||||
* required_env. When length ≥ 2 the modal shows a radio picker. */
|
||||
providers?: ProviderChoice[];
|
||||
/** Optional keys to offer in the deploy modal without blocking Deploy. */
|
||||
optionalKeys?: string[];
|
||||
/** Runtime slug — used only for the "The <runtime> runtime …"
|
||||
* headline; behavior is driven by providers/missingKeys. */
|
||||
runtime: string;
|
||||
@@ -96,13 +94,13 @@ export function MissingKeysModal({
|
||||
open,
|
||||
missingKeys,
|
||||
providers,
|
||||
optionalKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
@@ -116,13 +114,13 @@ export function MissingKeysModal({
|
||||
<ProviderPickerModal
|
||||
open={open}
|
||||
providers={pickerProviders}
|
||||
optionalKeys={optionalKeys ?? []}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
configuredKeys={configuredKeys}
|
||||
modelSuggestions={modelSuggestions}
|
||||
models={models}
|
||||
initialModel={initialModel}
|
||||
title={title}
|
||||
@@ -140,15 +138,11 @@ export function MissingKeysModal({
|
||||
<AllKeysModal
|
||||
open={open}
|
||||
missingKeys={keys}
|
||||
optionalKeys={optionalKeys ?? []}
|
||||
runtime={runtime}
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={onCancel}
|
||||
onOpenSettings={onOpenSettings}
|
||||
workspaceId={workspaceId}
|
||||
configuredKeys={configuredKeys}
|
||||
title={title}
|
||||
description={description}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -176,13 +170,13 @@ export function providerIdForModel(
|
||||
function ProviderPickerModal({
|
||||
open,
|
||||
providers,
|
||||
optionalKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
modelSuggestions,
|
||||
models,
|
||||
initialModel,
|
||||
title,
|
||||
@@ -190,13 +184,13 @@ function ProviderPickerModal({
|
||||
}: {
|
||||
open: boolean;
|
||||
providers: ProviderChoice[];
|
||||
optionalKeys: string[];
|
||||
runtime: string;
|
||||
onKeysAdded: (model?: string) => void;
|
||||
onCancel: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
workspaceId?: string;
|
||||
configuredKeys?: Set<string>;
|
||||
modelSuggestions?: string[];
|
||||
models?: ModelSpec[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
@@ -256,9 +250,16 @@ function ProviderPickerModal({
|
||||
|
||||
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
|
||||
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Legacy compat: map the selector value back into the old `selected`/
|
||||
// `model` shape for the rest of the modal body (footer copy, etc.).
|
||||
const selected = useMemo(
|
||||
() =>
|
||||
providers.find((p) => p.id === selectorValue.providerId) ??
|
||||
providers[0],
|
||||
[providers, selectorValue.providerId],
|
||||
);
|
||||
const model = selectorValue.model;
|
||||
const showModelInput = catalog.length > 0;
|
||||
|
||||
@@ -281,18 +282,7 @@ function ProviderPickerModal({
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
setOptionalEntries(
|
||||
optionalKeys
|
||||
.filter((key) => !selectorValue.envVars.includes(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: configuredKeys?.has(key) ?? false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
}, [open, selectorValue.envVars, configuredKeys, optionalKeys]);
|
||||
}, [open, selectorValue.envVars, configuredKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -346,43 +336,6 @@ function ProviderPickerModal({
|
||||
[entries, updateEntry, workspaceId],
|
||||
);
|
||||
|
||||
const updateOptionalEntry = useCallback(
|
||||
(index: number, updates: Partial<KeyEntry>) => {
|
||||
setOptionalEntries((prev) =>
|
||||
prev.map((e, i) => (i === index ? { ...e, ...updates } : e)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSaveOptionalKey = useCallback(
|
||||
async (index: number) => {
|
||||
const entry = optionalEntries[index];
|
||||
if (!entry.value.trim()) return;
|
||||
updateOptionalEntry(index, { saving: true, error: null });
|
||||
try {
|
||||
if (workspaceId) {
|
||||
await api.put(`/workspaces/${workspaceId}/secrets`, {
|
||||
key: entry.key,
|
||||
value: entry.value.trim(),
|
||||
});
|
||||
} else {
|
||||
await api.put("/settings/secrets", {
|
||||
key: entry.key,
|
||||
value: entry.value.trim(),
|
||||
});
|
||||
}
|
||||
updateOptionalEntry(index, { saved: true, saving: false });
|
||||
} catch (e) {
|
||||
updateOptionalEntry(index, {
|
||||
saving: false,
|
||||
error: e instanceof Error ? e.message : "Failed to save",
|
||||
});
|
||||
}
|
||||
},
|
||||
[optionalEntries, updateOptionalEntry, workspaceId],
|
||||
);
|
||||
|
||||
if (!open) return null;
|
||||
// Portal to document.body for the same reason as
|
||||
// OrgImportPreflightModal — several callers (TemplatePalette,
|
||||
@@ -512,62 +465,6 @@ function ProviderPickerModal({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{optionalEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
|
||||
Optional
|
||||
</div>
|
||||
{optionalEntries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div>
|
||||
<div className="text-[11px] text-ink-mid font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{!entry.saved && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
aria-label={`Optional value for ${entry.key}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
handleSaveOptionalKey(index);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveOptionalKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{entry.error && (
|
||||
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
|
||||
@@ -615,30 +512,21 @@ function ProviderPickerModal({
|
||||
function AllKeysModal({
|
||||
open,
|
||||
missingKeys,
|
||||
optionalKeys,
|
||||
runtime,
|
||||
onKeysAdded,
|
||||
onCancel,
|
||||
onOpenSettings,
|
||||
workspaceId,
|
||||
configuredKeys,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
open: boolean;
|
||||
missingKeys: string[];
|
||||
optionalKeys: string[];
|
||||
runtime: string;
|
||||
onKeysAdded: () => void;
|
||||
onCancel: () => void;
|
||||
onOpenSettings?: () => void;
|
||||
workspaceId?: string;
|
||||
configuredKeys?: Set<string>;
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
||||
const [optionalEntries, setOptionalEntries] = useState<KeyEntry[]>([]);
|
||||
const [globalError, setGlobalError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -647,24 +535,13 @@ function AllKeysModal({
|
||||
missingKeys.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: configuredKeys?.has(key) ?? false,
|
||||
saved: false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
setOptionalEntries(
|
||||
optionalKeys
|
||||
.filter((key) => !missingKeys.includes(key))
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: "",
|
||||
saved: configuredKeys?.has(key) ?? false,
|
||||
saving: false,
|
||||
error: null,
|
||||
})),
|
||||
);
|
||||
setGlobalError(null);
|
||||
}, [open, missingKeys, optionalKeys, configuredKeys]);
|
||||
}, [open, missingKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -714,45 +591,6 @@ function AllKeysModal({
|
||||
[entries, updateEntry, workspaceId],
|
||||
);
|
||||
|
||||
const updateOptionalEntry = useCallback(
|
||||
(index: number, updates: Partial<KeyEntry>) => {
|
||||
setOptionalEntries((prev) =>
|
||||
prev.map((entry, i) => (i === index ? { ...entry, ...updates } : entry)),
|
||||
);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSaveOptionalKey = useCallback(
|
||||
async (index: number) => {
|
||||
const entry = optionalEntries[index];
|
||||
if (!entry.value.trim()) return;
|
||||
|
||||
updateOptionalEntry(index, { saving: true, error: null });
|
||||
|
||||
try {
|
||||
if (workspaceId) {
|
||||
await api.put(`/workspaces/${workspaceId}/secrets`, {
|
||||
key: entry.key,
|
||||
value: entry.value.trim(),
|
||||
});
|
||||
} else {
|
||||
await api.put("/settings/secrets", {
|
||||
key: entry.key,
|
||||
value: entry.value.trim(),
|
||||
});
|
||||
}
|
||||
updateOptionalEntry(index, { saved: true, saving: false });
|
||||
} catch (e) {
|
||||
updateOptionalEntry(index, {
|
||||
saving: false,
|
||||
error: e instanceof Error ? e.message : "Failed to save",
|
||||
});
|
||||
}
|
||||
},
|
||||
[optionalEntries, updateOptionalEntry, workspaceId],
|
||||
);
|
||||
|
||||
const handleAddKeysAndDeploy = useCallback(() => {
|
||||
const anySaving = entries.some((e) => e.saving);
|
||||
if (anySaving) {
|
||||
@@ -818,16 +656,12 @@ function AllKeysModal({
|
||||
</svg>
|
||||
</div>
|
||||
<h3 id="missing-keys-title" className="text-sm font-semibold text-ink">
|
||||
{title ?? "Missing API Keys"}
|
||||
Missing API Keys
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-[12px] text-ink-mid leading-relaxed">
|
||||
{description ?? (
|
||||
<>
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
</>
|
||||
)}
|
||||
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
|
||||
runtime requires the following keys to be configured before deploying.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -885,62 +719,6 @@ function AllKeysModal({
|
||||
</div>
|
||||
))}
|
||||
|
||||
{optionalEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold">
|
||||
Optional
|
||||
</div>
|
||||
{optionalEntries.map((entry, index) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="bg-surface-card/30 rounded-lg px-3 py-2.5 border border-line/40"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div>
|
||||
<div className="text-[11px] text-ink-mid font-medium">
|
||||
{getKeyLabel(entry.key)}
|
||||
</div>
|
||||
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
|
||||
</div>
|
||||
{entry.saved && (
|
||||
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded">
|
||||
Saved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!entry.saved && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<input
|
||||
value={entry.value}
|
||||
onChange={(e) => updateOptionalEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
aria-label={`Optional value for ${entry.key}`}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
handleSaveOptionalKey(index);
|
||||
}
|
||||
}}
|
||||
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSaveOptionalKey(index)}
|
||||
disabled={!entry.value.trim() || entry.saving}
|
||||
className="px-3 py-1.5 bg-surface-card hover:bg-surface-card/80 text-[11px] rounded text-ink border border-line disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
{entry.saving ? "..." : "Save"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{globalError && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
|
||||
{globalError}
|
||||
|
||||
@@ -28,7 +28,6 @@ import { useId, useMemo } from "react";
|
||||
export interface SelectorModel {
|
||||
id: string;
|
||||
name?: string;
|
||||
provider?: string;
|
||||
required_env?: string[];
|
||||
}
|
||||
|
||||
@@ -89,7 +88,6 @@ interface Props {
|
||||
/** Vendor keys → human label. Add new vendors here when templates pick
|
||||
* up new model families. */
|
||||
const VENDOR_LABELS: Record<string, string> = {
|
||||
"platform": "Platform",
|
||||
"anthropic-oauth": "Claude Code subscription",
|
||||
anthropic: "Anthropic API",
|
||||
minimax: "MiniMax",
|
||||
@@ -120,8 +118,6 @@ const VENDOR_LABELS: Record<string, string> = {
|
||||
|
||||
/** Optional per-vendor tooltip shown on hover. */
|
||||
const VENDOR_TOOLTIPS: Record<string, string> = {
|
||||
"platform":
|
||||
"Use the Molecule platform-managed LLM proxy. No vendor API key is required.",
|
||||
"anthropic-oauth":
|
||||
"Use your Claude.ai (Pro/Max/Team) subscription via OAuth. Run `claude login` in the workspace terminal to mint the token, then paste it here. No API spend.",
|
||||
anthropic:
|
||||
@@ -169,9 +165,6 @@ const BARE_VENDOR_PATTERNS: Array<{ test: (id: string) => boolean; vendor: strin
|
||||
/** Infer a vendor key from a model spec. Combines id-prefix and env
|
||||
* signals. Exported for tests. */
|
||||
export function inferVendor(model: SelectorModel): string {
|
||||
const explicitProvider = model.provider?.trim().toLowerCase();
|
||||
if (explicitProvider) return explicitProvider;
|
||||
|
||||
const id = model.id || "";
|
||||
const envSet = new Set(model.required_env ?? []);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { flushSync } from "react-dom";
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import type { WorkspaceData } from "@/store/socket";
|
||||
import { isUserVisibleWorkspaceTemplate, type Template } from "@/lib/deploy-preflight";
|
||||
import { type Template } from "@/lib/deploy-preflight";
|
||||
import { useTemplateDeploy } from "@/hooks/useTemplateDeploy";
|
||||
import {
|
||||
OrgImportPreflightModal,
|
||||
@@ -446,7 +446,7 @@ export function TemplatePalette() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get<Template[]>("/templates");
|
||||
setTemplates(data.filter(isUserVisibleWorkspaceTemplate));
|
||||
setTemplates(data);
|
||||
} catch {
|
||||
setTemplates([]);
|
||||
} finally {
|
||||
|
||||
@@ -224,14 +224,12 @@ export function Toolbar() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key !== "?") return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-display-stream="true"]')) return;
|
||||
const tag = target.tagName;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
const inInput =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target.isContentEditable;
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
if (inInput) return;
|
||||
// Don't fire when a modal/dialog is already mounted (canvas modals,
|
||||
// side panel, etc. use z-50 or above).
|
||||
|
||||
@@ -201,13 +201,15 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
|
||||
expect(label?.textContent).toContain("Budget limit");
|
||||
});
|
||||
|
||||
it("Workspace Template select has a <label> whose htmlFor matches the select id", async () => {
|
||||
it("Template input has a <label> whose htmlFor matches the input id", async () => {
|
||||
await openDialog();
|
||||
const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement;
|
||||
expect(templateSelect.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${templateSelect.id}"]`);
|
||||
const templateInput = screen.getByPlaceholderText(
|
||||
"e.g. seo-agent (from workspace-configs-templates/)"
|
||||
) as HTMLInputElement;
|
||||
expect(templateInput.id).toBeTruthy();
|
||||
const label = document.querySelector(`label[for="${templateInput.id}"]`);
|
||||
expect(label).toBeTruthy();
|
||||
expect(label?.textContent).toContain("Workspace Template");
|
||||
expect(label?.textContent).toContain("Template");
|
||||
});
|
||||
|
||||
it("each InputField generates a distinct id (no id collisions)", async () => {
|
||||
@@ -216,16 +218,13 @@ describe("CreateWorkspaceDialog — WCAG SC 1.3.1 label/input association", () =
|
||||
screen.getByPlaceholderText("e.g. SEO Agent"),
|
||||
screen.getByPlaceholderText("e.g. SEO Specialist"),
|
||||
screen.getByPlaceholderText("e.g. 100"),
|
||||
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
|
||||
] as HTMLInputElement[];
|
||||
const selects = [
|
||||
screen.getByLabelText("Runtime"),
|
||||
screen.getByLabelText("Workspace Template"),
|
||||
] as HTMLSelectElement[];
|
||||
|
||||
const ids = [...inputs, ...selects].map((i) => i.id).filter(Boolean);
|
||||
const ids = inputs.map((i) => i.id).filter(Boolean);
|
||||
const unique = new Set(ids);
|
||||
expect(unique.size).toBe(ids.length); // no duplicates
|
||||
expect(ids.length).toBe(5);
|
||||
expect(ids.length).toBe(4);
|
||||
});
|
||||
|
||||
it("Name label text contains the required asterisk indicator", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { CreateWorkspaceButton } from "../CreateWorkspaceDialog";
|
||||
import { CreateWorkspaceButton, HERMES_PROVIDERS } from "../CreateWorkspaceDialog";
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
@@ -20,63 +20,10 @@ const SAMPLE_WORKSPACES = [
|
||||
{ id: "ws-2", name: "Research Agent", tier: 2 },
|
||||
];
|
||||
|
||||
const SAMPLE_TEMPLATES = [
|
||||
{
|
||||
id: "claude-code-default",
|
||||
name: "Claude Code Agent",
|
||||
runtime: "claude-code",
|
||||
model: "moonshot/kimi-k2.6",
|
||||
providers: ["platform", "minimax", "kimi-coding", "anthropic", "anthropic-oauth"],
|
||||
models: [
|
||||
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] },
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] },
|
||||
{ id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "opus", name: "Claude Opus", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "haiku", name: "Claude Haiku", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "seo-agent",
|
||||
name: "SEO Agent",
|
||||
runtime: "claude-code",
|
||||
model: "moonshot/kimi-k2.6",
|
||||
providers: ["platform", "minimax", "kimi-coding", "anthropic", "anthropic-oauth"],
|
||||
models: [
|
||||
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] },
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] },
|
||||
{ id: "kimi-k2-turbo-preview", name: "Kimi K2 Turbo Preview", required_env: ["KIMI_API_KEY"] },
|
||||
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "opus", name: "Claude Opus", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "haiku", name: "Claude Haiku", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
runtime: "hermes",
|
||||
model: "openai/gpt-4o",
|
||||
providers: ["openai", "anthropic", "platform"],
|
||||
models: [
|
||||
{ id: "openai/gpt-4o", name: "GPT-4o", required_env: ["OPENAI_API_KEY"] },
|
||||
{ id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "moonshot/kimi-k2.6", name: "Kimi K2.6", provider: "platform", required_env: [] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGet.mockImplementation(async (url: string) => {
|
||||
if (url === "/templates") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return SAMPLE_TEMPLATES as any;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return SAMPLE_WORKSPACES as any;
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockGet.mockResolvedValue(SAMPLE_WORKSPACES as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mockPost.mockResolvedValue({} as any);
|
||||
});
|
||||
@@ -95,14 +42,7 @@ async function openDialog() {
|
||||
|
||||
async function setTemplate(value: string) {
|
||||
fireEvent.change(
|
||||
screen.getByLabelText("Workspace Template"),
|
||||
{ target: { value } }
|
||||
);
|
||||
}
|
||||
|
||||
async function setRuntime(value: string) {
|
||||
fireEvent.change(
|
||||
screen.getByLabelText("Runtime"),
|
||||
screen.getByPlaceholderText("e.g. seo-agent (from workspace-configs-templates/)"),
|
||||
{ target: { value } }
|
||||
);
|
||||
}
|
||||
@@ -199,33 +139,11 @@ describe("CreateWorkspaceDialog", () => {
|
||||
volume: { root_gb: 30 },
|
||||
display: { mode: "none" },
|
||||
});
|
||||
expect(body.model).toBe("moonshot/kimi-k2.6");
|
||||
expect(body.llm_provider).toBe("platform");
|
||||
expect(body.runtime).toBe("claude-code");
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
expect(body.secrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps runtime and workspace template as separate selectors", async () => {
|
||||
await openDialog();
|
||||
|
||||
const runtimeSelect = screen.getByLabelText("Runtime") as HTMLSelectElement;
|
||||
const runtimeTexts = Array.from(runtimeSelect.options).map((o) => o.text.trim());
|
||||
expect(runtimeTexts).toEqual([
|
||||
"Claude Code",
|
||||
"OpenAI Codex CLI",
|
||||
"Hermes",
|
||||
"OpenClaw",
|
||||
]);
|
||||
expect(runtimeTexts).not.toContain("SEO Agent");
|
||||
|
||||
await waitFor(() => {
|
||||
const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement;
|
||||
const templateTexts = Array.from(templateSelect.options).map((o) => o.text.trim());
|
||||
expect(templateTexts).toContain("SEO Agent");
|
||||
expect(templateTexts).not.toContain("Hermes");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not send managed compute for external agents", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
@@ -254,8 +172,8 @@ describe("CreateWorkspaceDialog", () => {
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.model).toBe("moonshot/kimi-k2.6");
|
||||
expect(body.llm_provider).toBe("platform");
|
||||
expect(body.model).toBe("MiniMax-M2.7");
|
||||
expect(body.llm_provider).toBe("minimax");
|
||||
expect(body.compute).toEqual({
|
||||
instance_type: "t3.xlarge",
|
||||
volume: { root_gb: 80 },
|
||||
@@ -273,8 +191,8 @@ describe("CreateWorkspaceDialog", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "BYOK Agent" },
|
||||
});
|
||||
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
|
||||
target: { value: "minimax|MINIMAX_API_KEY" },
|
||||
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
|
||||
target: { value: "api_key" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
|
||||
target: { value: "sk-minimax-test" },
|
||||
@@ -295,11 +213,8 @@ describe("CreateWorkspaceDialog", () => {
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "OAuth Agent" },
|
||||
});
|
||||
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
|
||||
target: { value: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" },
|
||||
});
|
||||
fireEvent.change(document.querySelector("[data-testid='model-select']") as HTMLSelectElement, {
|
||||
target: { value: "sonnet" },
|
||||
fireEvent.change(document.getElementById("llm-auth-mode") as HTMLSelectElement, {
|
||||
target: { value: "oauth" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
|
||||
target: { value: "oauth-token" },
|
||||
@@ -315,18 +230,6 @@ describe("CreateWorkspaceDialog", () => {
|
||||
expect(body.secrets).toEqual({ CLAUDE_CODE_OAUTH_TOKEN: "oauth-token" });
|
||||
});
|
||||
|
||||
it("lists all Claude Code subscription aliases for blank workspaces", async () => {
|
||||
await openDialog();
|
||||
|
||||
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
|
||||
target: { value: "anthropic-oauth|CLAUDE_CODE_OAUTH_TOKEN" },
|
||||
});
|
||||
|
||||
const modelSelect = document.querySelector("[data-testid='model-select']") as HTMLSelectElement;
|
||||
const optionValues = Array.from(modelSelect.options).map((option) => option.value);
|
||||
expect(optionValues).toEqual(expect.arrayContaining(["sonnet", "opus", "haiku"]));
|
||||
});
|
||||
|
||||
it("renders gracefully when GET /workspaces fails", async () => {
|
||||
mockGet.mockRejectedValueOnce(new Error("Network error"));
|
||||
await openDialog();
|
||||
@@ -341,103 +244,225 @@ describe("CreateWorkspaceDialog", () => {
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dynamic runtime provider picker tests
|
||||
// Hermes provider picker tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("CreateWorkspaceDialog — dynamic runtime provider picker", () => {
|
||||
it("does not render the old Hermes-only provider section", async () => {
|
||||
describe("CreateWorkspaceDialog — Hermes provider picker", () => {
|
||||
it("does NOT show hermes provider section for non-hermes templates", async () => {
|
||||
await openDialog();
|
||||
await setRuntime("hermes");
|
||||
await setTemplate("seo-agent");
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull();
|
||||
});
|
||||
|
||||
it("derives Hermes provider and model options from the /templates runtime row", async () => {
|
||||
it("shows hermes provider section when template is 'hermes'", async () => {
|
||||
await openDialog();
|
||||
await setRuntime("hermes");
|
||||
|
||||
const providerSelect = document.querySelector("[data-testid='provider-select']") as HTMLSelectElement;
|
||||
await waitFor(() => expect(providerSelect.options.length).toBe(4));
|
||||
|
||||
const providerValues = Array.from(providerSelect.options).map((option) => option.value);
|
||||
expect(providerValues).toEqual(expect.arrayContaining([
|
||||
"platform|",
|
||||
"openai|OPENAI_API_KEY",
|
||||
"anthropic|ANTHROPIC_API_KEY",
|
||||
]));
|
||||
expect(providerValues).not.toContain("gemini|GEMINI_API_KEY");
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the template-declared default provider/model for Hermes", async () => {
|
||||
it("shows hermes provider section for template 'HERMES' (case-insensitive)", async () => {
|
||||
await openDialog();
|
||||
await setRuntime("hermes");
|
||||
|
||||
await waitFor(() => {
|
||||
const providerSelect = document.querySelector("[data-testid='provider-select']") as HTMLSelectElement;
|
||||
expect(providerSelect.value).toBe("platform|");
|
||||
});
|
||||
const modelSelect = document.querySelector("[data-testid='model-select']") as HTMLSelectElement;
|
||||
expect(modelSelect.value).toBe("moonshot/kimi-k2.6");
|
||||
await setTemplate("HERMES");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
});
|
||||
|
||||
it("prompts for the provider credential required by the selected Hermes model", async () => {
|
||||
it("hermes provider dropdown defaults to 'anthropic'", async () => {
|
||||
await openDialog();
|
||||
await setRuntime("hermes");
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement;
|
||||
expect(providerSelect).toBeTruthy();
|
||||
expect(providerSelect.value).toBe("anthropic");
|
||||
});
|
||||
|
||||
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
|
||||
target: { value: "openai|OPENAI_API_KEY" },
|
||||
it("hermes provider dropdown lists all 15 providers", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement;
|
||||
expect(providerSelect.options.length).toBe(HERMES_PROVIDERS.length);
|
||||
const ids = Array.from(providerSelect.options).map((o) => o.value);
|
||||
expect(ids).toContain("anthropic");
|
||||
expect(ids).toContain("openai");
|
||||
expect(ids).toContain("gemini");
|
||||
expect(ids).toContain("deepseek");
|
||||
expect(ids).toContain("hermes");
|
||||
});
|
||||
|
||||
// Pins the dynamic-providers behavior: when the matched template's
|
||||
// /templates row declares `providers`, the dropdown filters to that
|
||||
// subset instead of showing the full HERMES_PROVIDERS catalog. Same
|
||||
// data source ConfigTab uses (PR #2454) — keeps the modal and the
|
||||
// settings tab honest about which providers a template supports.
|
||||
it("hermes provider dropdown filters to template-declared providers when /templates ships them", async () => {
|
||||
// Per-URL mock: /workspaces returns the existing fixture, /templates
|
||||
// returns a hermes row that only allows anthropic + minimax + openai.
|
||||
mockGet.mockImplementation(async (url: string) => {
|
||||
if (url === "/templates") {
|
||||
return [
|
||||
{ id: "hermes", name: "Hermes", runtime: "hermes", providers: ["anthropic", "minimax", "openai"] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return SAMPLE_WORKSPACES as any;
|
||||
});
|
||||
|
||||
const keyInput = document.getElementById("llm-secret-input") as HTMLInputElement;
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement;
|
||||
// Filtered list arrives async after /templates fetch resolves —
|
||||
// keep waiting until the dropdown shrinks below the full catalog.
|
||||
await waitFor(() => expect(providerSelect.options.length).toBe(3));
|
||||
const ids = Array.from(providerSelect.options).map((o) => o.value);
|
||||
expect(ids).toEqual(expect.arrayContaining(["anthropic", "minimax", "openai"]));
|
||||
expect(ids).not.toContain("gemini");
|
||||
expect(ids).not.toContain("deepseek");
|
||||
});
|
||||
|
||||
// Back-compat: a template that hasn't migrated to runtime_config.providers
|
||||
// (older templates, self-hosted setups without /templates server) keeps
|
||||
// showing the full provider catalog. Operators picking from those
|
||||
// templates can't be locked out of providers we know hermes supports.
|
||||
it("hermes provider dropdown falls back to all providers when template declares no providers list", async () => {
|
||||
mockGet.mockImplementation(async (url: string) => {
|
||||
if (url === "/templates") {
|
||||
// No `providers` field — empty/missing → fall back to full catalog.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return [{ id: "hermes", name: "Hermes", runtime: "hermes" }] as any;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return SAMPLE_WORKSPACES as any;
|
||||
});
|
||||
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement;
|
||||
expect(providerSelect.options.length).toBe(HERMES_PROVIDERS.length);
|
||||
});
|
||||
|
||||
// Defensive: a template's declared list with NO matches against our
|
||||
// static catalog (e.g. a brand-new provider id we don't have label/
|
||||
// envVar metadata for yet) must not render an empty <select> — the
|
||||
// operator can't pick a provider, the form locks. Component falls
|
||||
// back to the full catalog so the user can still proceed.
|
||||
it("hermes provider dropdown falls back to all providers when template declares only unknown providers", async () => {
|
||||
mockGet.mockImplementation(async (url: string) => {
|
||||
if (url === "/templates") {
|
||||
return [
|
||||
{ id: "hermes", name: "Hermes", runtime: "hermes", providers: ["totally-new-provider-2030"] },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any;
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return SAMPLE_WORKSPACES as any;
|
||||
});
|
||||
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement;
|
||||
// Stays at full catalog length — no flapping to 0 then back.
|
||||
expect(providerSelect.options.length).toBe(HERMES_PROVIDERS.length);
|
||||
});
|
||||
|
||||
it("hermes API key field is a password input (masked)", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
const keyInput = document.getElementById("hermes-api-key-input") as HTMLInputElement;
|
||||
expect(keyInput).toBeTruthy();
|
||||
expect(keyInput.type).toBe("password");
|
||||
});
|
||||
|
||||
it("shows an error if the selected runtime provider requires a credential", async () => {
|
||||
it("shows an error if hermes template is set but API key is empty on submit", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Hermes Agent" },
|
||||
});
|
||||
await setRuntime("hermes");
|
||||
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
|
||||
target: { value: "openai|OPENAI_API_KEY" },
|
||||
});
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
|
||||
// Submit without API key
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => {
|
||||
const alert = screen.getByRole("alert");
|
||||
expect(alert.textContent).toContain("Provider credential");
|
||||
expect(alert.textContent).toContain("API key");
|
||||
});
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes runtime-derived provider/model/secrets in POST body", async () => {
|
||||
it("includes secrets in POST body with correct env var for selected provider", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Hermes OpenAI" },
|
||||
});
|
||||
await setRuntime("hermes");
|
||||
fireEvent.change(document.querySelector("[data-testid='provider-select']") as HTMLSelectElement, {
|
||||
target: { value: "openai|OPENAI_API_KEY" },
|
||||
});
|
||||
fireEvent.change(document.getElementById("llm-secret-input") as HTMLInputElement, {
|
||||
target: { value: "sk-openai-test" },
|
||||
target: { value: "Hermes Agent" },
|
||||
});
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
|
||||
// Fill in the API key
|
||||
const keyInput = document.getElementById("hermes-api-key-input") as HTMLInputElement;
|
||||
fireEvent.change(keyInput, { target: { value: "sk-test-anthropic-key" } });
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.secrets).toEqual({ ANTHROPIC_API_KEY: "sk-test-anthropic-key" });
|
||||
expect(body.template).toBe("hermes");
|
||||
});
|
||||
|
||||
it("uses the correct env var when a non-default provider is selected", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Hermes OpenAI" },
|
||||
});
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
|
||||
// Switch to openai
|
||||
const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement;
|
||||
fireEvent.change(providerSelect, { target: { value: "openai" } });
|
||||
|
||||
const keyInput = document.getElementById("hermes-api-key-input") as HTMLInputElement;
|
||||
fireEvent.change(keyInput, { target: { value: "sk-openai-test" } });
|
||||
|
||||
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
|
||||
fireEvent.click(createBtn!);
|
||||
|
||||
await waitFor(() => expect(mockPost).toHaveBeenCalled());
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.runtime).toBe("hermes");
|
||||
expect(body.template).toBeUndefined();
|
||||
expect(body.model).toBe("openai/gpt-4o");
|
||||
expect(body.llm_provider).toBe("openai");
|
||||
expect(body.secrets).toEqual({ OPENAI_API_KEY: "sk-openai-test" });
|
||||
});
|
||||
|
||||
it("does NOT include secrets field when provider is platform-managed", async () => {
|
||||
it("does NOT include secrets field when template is not hermes", async () => {
|
||||
await openDialog();
|
||||
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
|
||||
target: { value: "Normal Agent" },
|
||||
@@ -451,6 +476,20 @@ describe("CreateWorkspaceDialog — dynamic runtime provider picker", () => {
|
||||
const body = mockPost.mock.calls[0][1] as Record<string, unknown>;
|
||||
expect(body.secrets).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides hermes section and resets state when template is cleared", async () => {
|
||||
await openDialog();
|
||||
await setTemplate("hermes");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy()
|
||||
);
|
||||
|
||||
// Clear template
|
||||
await setTemplate("");
|
||||
await waitFor(() =>
|
||||
expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -96,12 +96,12 @@ vi.mock("@/lib/design-tokens", () => ({
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const TEMPLATE = {
|
||||
id: "seo-agent",
|
||||
name: "SEO Agent",
|
||||
description: "SEO workspace template",
|
||||
id: "tpl-1",
|
||||
name: "Claude Code Agent",
|
||||
description: "A general-purpose coding assistant",
|
||||
tier: 2,
|
||||
skill_count: 3,
|
||||
model: "MiniMax-M2.7",
|
||||
model: "claude-opus-4-5",
|
||||
};
|
||||
|
||||
function template(overrides: Partial<typeof TEMPLATE> = {}): typeof TEMPLATE {
|
||||
@@ -159,7 +159,7 @@ describe("EmptyState — loading", () => {
|
||||
it("does not render template buttons while loading", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByText("SEO Agent")).toBeNull();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -183,8 +183,8 @@ describe("EmptyState — templates", () => {
|
||||
it("renders template buttons with name and description", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("SEO Agent")).toBeTruthy();
|
||||
expect(screen.getByText("SEO workspace template")).toBeTruthy();
|
||||
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
|
||||
expect(screen.getByText("A general-purpose coding assistant")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders tier badge and skill count", async () => {
|
||||
@@ -198,42 +198,25 @@ describe("EmptyState — templates", () => {
|
||||
it("renders model name when present", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText(/MiniMax-M2.7/i)).toBeTruthy();
|
||||
expect(screen.getByText(/claude-opus/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls deploy with the template on click", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("SEO Agent"));
|
||||
fireEvent.click(screen.getByText("Claude Code Agent"));
|
||||
expect(_deploy.deployFn).toHaveBeenCalledWith(template());
|
||||
});
|
||||
|
||||
it("hides runtime-default templates from the product template grid", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
template({ id: "claude-code-default", name: "Claude Code Agent" }),
|
||||
template({ id: "codex", name: "OpenAI Codex CLI" }),
|
||||
template({ id: "hermes", name: "Hermes Agent" }),
|
||||
template({ id: "openclaw", name: "OpenClaw Agent" }),
|
||||
template(),
|
||||
]);
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("SEO Agent")).toBeTruthy();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
expect(screen.queryByText("OpenAI Codex CLI")).toBeNull();
|
||||
expect(screen.queryByText("Hermes Agent")).toBeNull();
|
||||
expect(screen.queryByText("OpenClaw Agent")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows 'Deploying...' on the button of the template being deployed", async () => {
|
||||
_deploy.deploying = "seo-agent";
|
||||
_deploy.deploying = "tpl-1";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByText("Deploying...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables the template button of the deploying template", async () => {
|
||||
_deploy.deploying = "seo-agent";
|
||||
_deploy.deploying = "tpl-1";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
const btn = screen.getByText("Deploying...").closest("button") as HTMLButtonElement;
|
||||
@@ -241,7 +224,7 @@ describe("EmptyState — templates", () => {
|
||||
});
|
||||
|
||||
it("disables 'create blank' while a template is deploying", async () => {
|
||||
_deploy.deploying = "seo-agent";
|
||||
_deploy.deploying = "tpl-1";
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: "+ Create blank workspace" }).disabled).toBe(true);
|
||||
@@ -262,7 +245,7 @@ describe("EmptyState — fetch failure / empty templates", () => {
|
||||
it("does not render template grid when GET /templates returns []", async () => {
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByText("SEO Agent")).toBeNull();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders 'create blank' button when templates list is empty", async () => {
|
||||
@@ -275,7 +258,7 @@ describe("EmptyState — fetch failure / empty templates", () => {
|
||||
mockApiGet.mockReset().mockRejectedValue(new Error("Network failure"));
|
||||
renderEmpty();
|
||||
await flush();
|
||||
expect(screen.queryByText("SEO Agent")).toBeNull();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -333,7 +316,7 @@ describe("EmptyState — create blank", () => {
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" }));
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect((screen.getByText("SEO Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByText("Claude Code Agent").closest("button") as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("shows error banner when POST /workspaces fails", async () => {
|
||||
|
||||
@@ -402,31 +402,6 @@ describe("MissingKeysModal — add keys and deploy", () => {
|
||||
expect(onKeysAdded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows optional keys without blocking deploy", () => {
|
||||
const onKeysAdded = vi.fn();
|
||||
render(
|
||||
<MissingKeysModal
|
||||
open={true}
|
||||
missingKeys={[]}
|
||||
optionalKeys={["GOOGLE_GSC_SITE"]}
|
||||
runtime="claude-code"
|
||||
title="Configure Workspace"
|
||||
onKeysAdded={onKeysAdded}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Optional")).toBeTruthy();
|
||||
expect(screen.getAllByText("GOOGLE_GSC_SITE").length).toBeGreaterThan(0);
|
||||
const deployBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Deploy",
|
||||
);
|
||||
expect(deployBtn).toBeTruthy();
|
||||
expect(deployBtn!.disabled).toBe(false);
|
||||
act(() => { fireEvent.click(deployBtn!); });
|
||||
expect(onKeysAdded).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows global error when not all keys saved", async () => {
|
||||
const onKeysAdded = vi.fn();
|
||||
render(
|
||||
@@ -554,4 +529,4 @@ describe("MissingKeysModal — cancel and settings", () => {
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: /open settings/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -44,14 +44,6 @@ const HERMES_MODELS: SelectorModel[] = [
|
||||
];
|
||||
|
||||
describe("inferVendor", () => {
|
||||
it("uses explicit provider metadata before slug heuristics", () => {
|
||||
expect(inferVendor({
|
||||
id: "moonshot/kimi-k2.6",
|
||||
provider: "platform",
|
||||
required_env: [],
|
||||
})).toBe("platform");
|
||||
});
|
||||
|
||||
it("uses slash prefix when present", () => {
|
||||
expect(inferVendor({ id: "nousresearch/hermes-4-70b", required_env: ["HERMES_API_KEY"] }))
|
||||
.toBe("nousresearch");
|
||||
@@ -113,22 +105,6 @@ describe("buildProviderCatalog", () => {
|
||||
expect(oauth!.models.map((m) => m.id).sort()).toEqual(["haiku", "opus", "sonnet"]);
|
||||
});
|
||||
|
||||
it("labels explicit platform-managed providers", () => {
|
||||
const catalog = buildProviderCatalog([
|
||||
{
|
||||
id: "moonshot/kimi-k2.6",
|
||||
name: "Kimi K2.6",
|
||||
provider: "platform",
|
||||
required_env: [],
|
||||
},
|
||||
]);
|
||||
expect(catalog[0]).toMatchObject({
|
||||
vendor: "platform",
|
||||
label: "Platform",
|
||||
envVars: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("flags wildcard providers", () => {
|
||||
const catalog = buildProviderCatalog(HERMES_MODELS);
|
||||
const hf = catalog.find((p) => p.vendor === "huggingface");
|
||||
|
||||
@@ -189,23 +189,6 @@ describe("TemplatePalette — sidebar", () => {
|
||||
expect(screen.getByText("Researcher")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides runtime-default templates from the deployable product template list", async () => {
|
||||
mockGet.mockResolvedValue([
|
||||
{ id: "claude-code-default", name: "Claude Code Agent", description: "", tier: 4, skills: [] },
|
||||
{ id: "codex", name: "OpenAI Codex CLI", description: "", tier: 4, skills: [] },
|
||||
{ id: "hermes", name: "Hermes Agent", description: "", tier: 4, skills: [] },
|
||||
{ id: "openclaw", name: "OpenClaw Agent", description: "", tier: 4, skills: [] },
|
||||
{ id: "seo-agent", name: "SEO Agent", description: "SEO workspace template", tier: 4, skills: ["seo"] },
|
||||
]);
|
||||
render(<TemplatePalette />);
|
||||
await openSidebar();
|
||||
expect(screen.getByText("SEO Agent")).toBeTruthy();
|
||||
expect(screen.queryByText("Claude Code Agent")).toBeNull();
|
||||
expect(screen.queryByText("OpenAI Codex CLI")).toBeNull();
|
||||
expect(screen.queryByText("Hermes Agent")).toBeNull();
|
||||
expect(screen.queryByText("OpenClaw Agent")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows template description", async () => {
|
||||
mockGet.mockResolvedValue(MOCK_TEMPLATES);
|
||||
render(<TemplatePalette />);
|
||||
|
||||
@@ -68,11 +68,7 @@ afterEach(() => {
|
||||
|
||||
function ShortcutTestComponent() {
|
||||
useKeyboardShortcuts();
|
||||
return (
|
||||
<div data-testid="canvas-root">
|
||||
<div data-testid="display-stream" data-display-stream="true" />
|
||||
</div>
|
||||
);
|
||||
return <div data-testid="canvas-root" />;
|
||||
}
|
||||
|
||||
function renderWithProvider() {
|
||||
@@ -82,13 +78,6 @@ function renderWithProvider() {
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Esc — deselect / close context menu", () => {
|
||||
it("does not handle keys targeted at the display stream", () => {
|
||||
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
|
||||
const { getByTestId } = renderWithProvider();
|
||||
fireEvent.keyDown(getByTestId("display-stream"), { key: "Escape" });
|
||||
expect(mockStoreState.closeContextMenu).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the context menu when one is open", () => {
|
||||
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
|
||||
renderWithProvider();
|
||||
|
||||
@@ -28,14 +28,12 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
|
||||
export function useKeyboardShortcuts() {
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-display-stream="true"]')) return;
|
||||
const tag = target.tagName;
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
const inInput =
|
||||
tag === "INPUT" ||
|
||||
tag === "TEXTAREA" ||
|
||||
tag === "SELECT" ||
|
||||
target.isContentEditable;
|
||||
(e.target as HTMLElement).isContentEditable;
|
||||
|
||||
if (e.key === "Escape") {
|
||||
const state = useCanvasStore.getState();
|
||||
|
||||
@@ -131,7 +131,7 @@ export function OrgTokensTab() {
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-3 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="px-3 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{creating ? (
|
||||
<>
|
||||
@@ -175,7 +175,7 @@ export function OrgTokensTab() {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -152,7 +152,7 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
|
||||
className="secret-row__action-btn"
|
||||
title="Edit"
|
||||
>
|
||||
<span aria-hidden="true">✏</span>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -161,7 +161,7 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
|
||||
className="secret-row__action-btn secret-row__action-btn--delete"
|
||||
title="Delete"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -121,7 +121,7 @@ function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={creating}
|
||||
className="px-3 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="px-3 py-1.5 bg-accent-strong/20 hover:bg-accent-strong/30 border border-accent/30 rounded-lg text-[11px] text-accent font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1.5"
|
||||
>
|
||||
{creating ? <><Spinner size="sm" /> Creating...</> : '+ New Token'}
|
||||
</button>
|
||||
@@ -155,7 +155,7 @@ function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./config/form-inputs";
|
||||
import { parseYaml, toYaml } from "./config/yaml-utils";
|
||||
import { SecretsSection } from "./config/secrets-section";
|
||||
import { LLMBillingSection } from "./config/llm-billing-section";
|
||||
import { ExternalConnectionSection } from "./ExternalConnectionSection";
|
||||
import {
|
||||
ProviderModelSelector,
|
||||
@@ -288,40 +287,6 @@ export function deriveProvidersFromModels(models: ModelSpec[]): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// billingModeForProvider — maps a selected PROVIDER (vendor key) to the
|
||||
// LLM billing_mode it implies (internal#703 Gap 2).
|
||||
//
|
||||
// Today, picking a non-Platform provider in the Config tab writes the
|
||||
// credential env (CLAUDE_CODE_OAUTH_TOKEN / vendor key) but leaves
|
||||
// llm_billing_mode at its resolved default (`platform_managed`). The CP
|
||||
// tenant_config endpoint then keeps injecting the platform proxy base
|
||||
// URLs, so the OAuth token / vendor key is never actually used — BYOK
|
||||
// silently no-ops (the live SEO-Agent symptom in #703). The workspace-
|
||||
// server even hard-blocks vendor-key writes on platform_managed
|
||||
// workspaces (secrets.go:87), pointing the user at this exact billing-
|
||||
// mode switch. Wiring the provider change to also set billing_mode is
|
||||
// the UI half that makes BYOK take (the CP/workspace-server backend half
|
||||
// is being fixed in parallel — internal#703 Gap 1).
|
||||
//
|
||||
// Mapping:
|
||||
// - "platform" (the Platform-managed proxy) OR "" (no explicit
|
||||
// provider override → inherit, defaults to platform) → "platform_managed".
|
||||
// - any other vendor key ("anthropic-oauth" = Claude Code subscription
|
||||
// OAuth, "anthropic" = Anthropic API key, "minimax", "openrouter",
|
||||
// etc.) → "byok".
|
||||
//
|
||||
// Returns the billing_mode string the PUT body should carry. The valid
|
||||
// set is fixed by workspace-server's recognizer (platform_managed | byok
|
||||
// | disabled); "disabled" is never auto-selected by a provider choice —
|
||||
// it's an explicit operator action via the LLM Billing section.
|
||||
export type LLMBillingMode = "platform_managed" | "byok";
|
||||
|
||||
export function billingModeForProvider(provider: string): LLMBillingMode {
|
||||
const v = provider.trim().toLowerCase();
|
||||
if (v === "" || v === "platform") return "platform_managed";
|
||||
return "byok";
|
||||
}
|
||||
|
||||
// Fallback used when /templates can't be fetched (offline, older backend).
|
||||
// Keep in sync with manifest.json workspace_templates as a defensive default.
|
||||
// Model + env suggestions only flow when the backend is reachable.
|
||||
@@ -355,24 +320,15 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
const [rawMode, setRawMode] = useState(false);
|
||||
const [rawDraft, setRawDraft] = useState("");
|
||||
const [runtimeOptions, setRuntimeOptions] = useState<RuntimeOption[]>(FALLBACK_RUNTIME_OPTIONS);
|
||||
// internal#718 P4 closure: the explicit provider override
|
||||
// (LLM_PROVIDER workspace_secret, surfaced via GET/PUT
|
||||
// /workspaces/:id/provider) has been RETIRED. The provider is
|
||||
// derived at every decision point from (runtime, model) via the
|
||||
// registry — no stored row remains. The `provider` / `originalProvider`
|
||||
// state and the provider dropdown survive in this component for
|
||||
// backwards-compat (display only) but are no longer persisted:
|
||||
// - loadConfig no longer GETs /workspaces/:id/provider (the
|
||||
// endpoint returns 410 Gone). The state initializes to ""
|
||||
// and stays there.
|
||||
// - handleSave no longer PUTs /workspaces/:id/provider.
|
||||
// - The dropdown still updates the local `provider` state so the
|
||||
// user can preview the derived value; the value never leaves
|
||||
// the browser.
|
||||
// This is the canvas-side complement to the backend retirement of
|
||||
// SetProvider/GetProvider/setProviderSecret. Older canvases that
|
||||
// still call PUT /provider hit the 410 Gone with a structured
|
||||
// PROVIDER_ENDPOINT_RETIRED code — loud failure, no silent miss.
|
||||
// Provider override (Option B PR-5): stored separately from config.yaml
|
||||
// because the value lives in workspace_secrets (encrypted), not in the
|
||||
// platform-managed config.yaml. The two endpoints are GET/PUT
|
||||
// /workspaces/:id/provider on workspace-server (handlers/secrets.go).
|
||||
// Empty = "auto-derive from model slug prefix" — pre-Option-B behavior
|
||||
// and what most users want. Setting to a non-empty value writes
|
||||
// LLM_PROVIDER into workspace_secrets and triggers an auto-restart so
|
||||
// the workspace boots with the new provider in env (and via CP user-
|
||||
// data, written into /configs/config.yaml on next provision too).
|
||||
const [provider, setProvider] = useState("");
|
||||
const [originalProvider, setOriginalProvider] = useState("");
|
||||
// Track the model the form first rendered, so handleSave can detect
|
||||
@@ -423,23 +379,26 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
//
|
||||
// See GH #1894 for the workspace-row-as-source-of-truth rationale
|
||||
// that motivated splitting from a single config.yaml read.
|
||||
// internal#718 P4 closure: the GET /workspaces/:id/provider leg is
|
||||
// RETIRED — the endpoint returns 410 Gone. Provider is now derived
|
||||
// from (runtime, model) via the registry; no stored value exists
|
||||
// to load. Always seed the local state to "" so the dropdown
|
||||
// initializes to "auto-derive".
|
||||
const [wsRes, modelRes] = await Promise.all([
|
||||
const [wsRes, modelRes, providerRes] = await Promise.all([
|
||||
api.get<{ runtime?: string; tier?: number }>(`/workspaces/${workspaceId}`)
|
||||
.catch(() => ({} as { runtime?: string; tier?: number })),
|
||||
api.get<{ model?: string }>(`/workspaces/${workspaceId}/model`)
|
||||
.catch(() => ({} as { model?: string })),
|
||||
api.get<{ provider?: string }>(`/workspaces/${workspaceId}/provider`)
|
||||
.catch(() => null),
|
||||
]);
|
||||
const wsMetadataRuntime = (wsRes.runtime || "").trim();
|
||||
const wsMetadataModel = (modelRes.model || "").trim();
|
||||
const wsMetadataTier: number | null =
|
||||
typeof wsRes.tier === "number" ? wsRes.tier : null;
|
||||
setProvider("");
|
||||
setOriginalProvider("");
|
||||
if (providerRes !== null) {
|
||||
const loadedProvider = (providerRes.provider || "").trim();
|
||||
setProvider(loadedProvider);
|
||||
setOriginalProvider(loadedProvider);
|
||||
} else {
|
||||
setProvider("");
|
||||
setOriginalProvider("");
|
||||
}
|
||||
// originalModel is set further down once the YAML has been parsed —
|
||||
// we want it to reflect what the form ACTUALLY rendered, which may
|
||||
// be the YAML's runtime_config.model fallback when MODEL_PROVIDER
|
||||
@@ -724,27 +683,23 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
}
|
||||
}
|
||||
|
||||
// internal#718 P4 closure: provider override save is RETIRED. The
|
||||
// /workspaces/:id/provider endpoint returns 410 Gone; the provider
|
||||
// is derived from (runtime, model) at every decision point via the
|
||||
// registry. The local dropdown state still updates so the user can
|
||||
// see the predicted provider, but it never round-trips to the
|
||||
// server. Variables retained as locals (set to constants) so the
|
||||
// downstream restart-suppress logic below has clear semantics
|
||||
// and the diff against the prior shape stays small.
|
||||
const providerSaveError: string | null = null;
|
||||
const providerChanged = false;
|
||||
|
||||
// internal#718 P4 closure: provider → billing_mode linkage is also
|
||||
// RETIRED. P2-B (#1972) moved the billing decision to
|
||||
// ResolveLLMBillingModeDerived, which DERIVES the provider from
|
||||
// (runtime, model) at every read. The canvas can no longer
|
||||
// override it via a separate PUT, by design — the runtime+model
|
||||
// selection IS the billing-mode selection. The
|
||||
// /admin/workspaces/:id/llm-billing-mode endpoint still exists
|
||||
// as the operator override surface (workspaces.llm_billing_mode
|
||||
// column); it is no longer driven by the provider dropdown.
|
||||
const billingModeSaveError: string | null = null;
|
||||
// Provider override save (Option B PR-5). PUT only when the user
|
||||
// changed the dropdown — otherwise an unrelated Save (e.g. tier
|
||||
// edit) would re-write the provider unchanged and the server-
|
||||
// side auto-restart would fire on every Save, costing the user a
|
||||
// ~30s reboot for a no-op change. Server endpoint accepts an
|
||||
// empty string to clear the override (deletes the
|
||||
// workspace_secrets row); we forward whatever the form holds.
|
||||
let providerSaveError: string | null = null;
|
||||
const providerChanged = provider !== originalProvider;
|
||||
if (providerChanged) {
|
||||
try {
|
||||
await api.put(`/workspaces/${workspaceId}/provider`, { provider });
|
||||
setOriginalProvider(provider);
|
||||
} catch (e) {
|
||||
providerSaveError = e instanceof Error ? e.message : "Provider update was rejected";
|
||||
}
|
||||
}
|
||||
|
||||
setOriginalYaml(content);
|
||||
if (rawMode) {
|
||||
@@ -753,29 +708,28 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
} else {
|
||||
setRawDraft(content);
|
||||
}
|
||||
// internal#718 P4 closure: providerWillAutoRestart is always
|
||||
// false now (provider PUT is retired; no server-side auto-restart
|
||||
// can fire). Save+Restart flows through the canvas store
|
||||
// restart path the same way it did pre-#718 for non-provider
|
||||
// edits.
|
||||
const providerWillAutoRestart = providerChanged && !providerSaveError
|
||||
// SetProvider on the server already triggers an auto-restart for
|
||||
// the workspace whenever the value actually changed (see
|
||||
// workspace-server/internal/handlers/secrets.go:SetProvider). If
|
||||
// the user also clicked Save+Restart we'd kick off a SECOND
|
||||
// restart here and the two would race in the canvas store —
|
||||
// suppress the redundant call and rely on the server-side one.
|
||||
const providerWillAutoRestart = providerChanged && !providerSaveError;
|
||||
if (restart && !providerWillAutoRestart) {
|
||||
await useCanvasStore.getState().restartWorkspace(workspaceId);
|
||||
} else if (!restart) {
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: !providerWillAutoRestart });
|
||||
}
|
||||
// Aggregate partial-save errors. With provider+billing-mode PUTs
|
||||
// retired, only modelSaveError can fire from the secret-mint side
|
||||
// — the provider/billing branches are dead code retained as
|
||||
// constant nils to keep the diff small. They are surfaced
|
||||
// defensively in case a future re-enablement needs the wiring.
|
||||
// Aggregate partial-save errors. Both modelSaveError and
|
||||
// providerSaveError describe rejected updates from independent
|
||||
// endpoints — show whichever fired so the user knows which
|
||||
// field reverts on next reload (otherwise they'd see "Saved" and
|
||||
// be confused why Provider snapped back).
|
||||
const partialError = providerSaveError
|
||||
? `Other fields saved, but provider update failed: ${providerSaveError}`
|
||||
: billingModeSaveError
|
||||
? `Provider saved, but switching billing mode failed — your own provider key/OAuth may not take effect until billing mode is set: ${billingModeSaveError}`
|
||||
: modelSaveError
|
||||
? `Other fields saved, but model update failed: ${modelSaveError}`
|
||||
: null;
|
||||
: modelSaveError
|
||||
? `Other fields saved, but model update failed: ${modelSaveError}`
|
||||
: null;
|
||||
if (partialError) {
|
||||
setError(partialError);
|
||||
} else {
|
||||
@@ -1154,8 +1108,6 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<LLMBillingSection workspaceId={workspaceId} />
|
||||
|
||||
<SecretsSection
|
||||
workspaceId={workspaceId}
|
||||
requiredEnv={config.runtime_config?.required_env}
|
||||
|
||||
@@ -313,21 +313,11 @@ function DisplayControlBar({
|
||||
|
||||
function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const rfbRef = useRef<RFB | null>(null);
|
||||
const [streamError, setStreamError] = useState<string | null>(null);
|
||||
const [clipboardStatus, setClipboardStatus] = useState<string | null>(null);
|
||||
const [remoteClipboardText, setRemoteClipboardText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let rfb: RFB | null = null;
|
||||
let clipboardTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const setTemporaryClipboardStatus = (message: string) => {
|
||||
setClipboardStatus(message);
|
||||
if (clipboardTimer) clearTimeout(clipboardTimer);
|
||||
clipboardTimer = setTimeout(() => setClipboardStatus(null), 2500);
|
||||
};
|
||||
|
||||
async function connect() {
|
||||
setStreamError(null);
|
||||
@@ -338,19 +328,9 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
rfb = new mod.default(containerRef.current, stream.url, {
|
||||
wsProtocols: ["binary", `molecule-display-token.${stream.token}`],
|
||||
});
|
||||
rfbRef.current = rfb;
|
||||
rfb.scaleViewport = true;
|
||||
rfb.resizeSession = true;
|
||||
rfb.focusOnClick = true;
|
||||
rfb.focus({ preventScroll: true });
|
||||
rfb.addEventListener("clipboard", (event: Event) => {
|
||||
const text = (event as CustomEvent<{ text?: string }>).detail?.text ?? "";
|
||||
if (!text) return;
|
||||
setRemoteClipboardText(text);
|
||||
void navigator.clipboard?.writeText(text)
|
||||
.then(() => setTemporaryClipboardStatus("Copied remote clipboard"))
|
||||
.catch(() => setTemporaryClipboardStatus("Remote clipboard ready"));
|
||||
});
|
||||
rfb.addEventListener("disconnect", (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ clean?: boolean }>).detail;
|
||||
if (!cancelled && !detail?.clean) setStreamError("Desktop stream disconnected.");
|
||||
@@ -363,83 +343,13 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
connect();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (clipboardTimer) clearTimeout(clipboardTimer);
|
||||
rfbRef.current = null;
|
||||
rfb?.disconnect();
|
||||
};
|
||||
}, [sessionUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
const onPaste = (event: ClipboardEvent) => {
|
||||
if (!isDisplayEventTarget(containerRef.current, event.target)) return;
|
||||
const text = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!text) return;
|
||||
event.preventDefault();
|
||||
rfbRef.current?.clipboardPasteFrom(text);
|
||||
rfbRef.current?.focus({ preventScroll: true });
|
||||
setClipboardStatus("Pasted to desktop");
|
||||
};
|
||||
window.addEventListener("paste", onPaste, true);
|
||||
return () => window.removeEventListener("paste", onPaste, true);
|
||||
}, []);
|
||||
|
||||
const pasteLocalClipboard = async () => {
|
||||
try {
|
||||
const text = await navigator.clipboard?.readText();
|
||||
if (!text) {
|
||||
setClipboardStatus("Clipboard is empty");
|
||||
return;
|
||||
}
|
||||
rfbRef.current?.clipboardPasteFrom(text);
|
||||
rfbRef.current?.focus({ preventScroll: true });
|
||||
setClipboardStatus("Pasted to desktop");
|
||||
} catch {
|
||||
setClipboardStatus("Press Ctrl/Cmd+V while the desktop is focused");
|
||||
}
|
||||
};
|
||||
|
||||
const copyRemoteClipboard = async () => {
|
||||
if (!remoteClipboardText) {
|
||||
setClipboardStatus("No remote clipboard yet");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(remoteClipboardText);
|
||||
setClipboardStatus("Copied remote clipboard");
|
||||
} catch {
|
||||
setClipboardStatus("Browser blocked clipboard copy");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-display-stream="true"
|
||||
className="relative min-h-0 flex-1 bg-black"
|
||||
onMouseDown={() => rfbRef.current?.focus({ preventScroll: true })}
|
||||
>
|
||||
<div className="relative min-h-0 flex-1 bg-black">
|
||||
<div ref={containerRef} title="Workspace desktop" className="h-full w-full overflow-hidden bg-black" />
|
||||
<div className="absolute right-3 top-3 flex items-center gap-2">
|
||||
{clipboardStatus && (
|
||||
<span className="rounded border border-line/50 bg-black/80 px-2 py-1 text-[10px] text-white">
|
||||
{clipboardStatus}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={pasteLocalClipboard}
|
||||
className="h-7 rounded border border-line/50 bg-black/75 px-2 text-[10px] font-medium text-white hover:bg-black"
|
||||
>
|
||||
Paste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyRemoteClipboard}
|
||||
className="h-7 rounded border border-line/50 bg-black/75 px-2 text-[10px] font-medium text-white hover:bg-black disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!remoteClipboardText}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
{streamError && (
|
||||
<div className="absolute inset-x-4 top-4 rounded border border-red-500/30 bg-red-950/80 px-3 py-2 text-[11px] text-red-100">
|
||||
{streamError}
|
||||
@@ -449,13 +359,6 @@ function DesktopStream({ sessionUrl }: { sessionUrl: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function isDisplayEventTarget(container: HTMLElement | null, target: EventTarget | null): boolean {
|
||||
if (!container) return false;
|
||||
if (target instanceof Node && container.contains(target)) return true;
|
||||
const active = document.activeElement;
|
||||
return active instanceof Node && container.contains(active);
|
||||
}
|
||||
|
||||
function displayWebSocketConnection(sessionUrl: string): { url: string; token: string } {
|
||||
const url = new URL(sessionUrl, window.location.href);
|
||||
const token = new URLSearchParams(url.hash.replace(/^#/, "")).get("token") ?? "";
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// internal#718 P4 closure — ConfigTab.billingMode.test.tsx is retired.
|
||||
//
|
||||
// This suite (255 lines, 8 tests) pinned the canvas-side provider →
|
||||
// llm_billing_mode linkage from internal#703 Gap 2: when the operator
|
||||
// changed the PROVIDER in the Config tab, ConfigTab.handleSave would
|
||||
// PUT /admin/workspaces/:id/llm-billing-mode so the platform-vs-byok
|
||||
// decision tracked the dropdown.
|
||||
//
|
||||
// That linkage is retired together with the LLM_PROVIDER override flow
|
||||
// (see ConfigTab.provider.test.tsx retirement note). P2-B (#1972)
|
||||
// moved the platform-vs-byok decision to
|
||||
// `ResolveLLMBillingModeDerived(runtime, model, authEnv)` in
|
||||
// workspace-server — the canvas can no longer override it via the
|
||||
// provider dropdown, by design. The runtime+model selection IS the
|
||||
// billing-mode selection now.
|
||||
//
|
||||
// The `/admin/workspaces/:id/llm-billing-mode` endpoint still exists
|
||||
// as the operator override surface (`workspaces.llm_billing_mode`
|
||||
// column); it is no longer driven by the provider dropdown.
|
||||
// Coverage for the derived billing flow lives in
|
||||
// workspace-server/internal/handlers/llm_billing_mode_derived_test.go.
|
||||
//
|
||||
// Restore from git history if the canvas-side provider→billing linkage
|
||||
// needs to be revisited (it should not — the derived resolver is the
|
||||
// single decision point).
|
||||
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
describe("ConfigTab — provider → llm_billing_mode linkage (retired internal#718 P4)", () => {
|
||||
it.skip("LLM_PROVIDER → billing_mode wiring is retired; see file header for the replacement coverage", () => {
|
||||
// intentionally empty
|
||||
});
|
||||
});
|
||||
@@ -1,45 +1,574 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// internal#718 P4 closure — ConfigTab.provider.test.tsx is retired.
|
||||
// Regression tests for ConfigTab Provider override (Option B PR-5).
|
||||
//
|
||||
// This 574-line suite exercised the canvas-side LLM provider override
|
||||
// flow: load the existing override from GET /workspaces/:id/provider,
|
||||
// edit the dropdown, Save → PUT /workspaces/:id/provider, and the
|
||||
// provider→billing_mode linkage on Save. All three server endpoints
|
||||
// behind those flows are retired in internal#718 P4 closure:
|
||||
// What this pins: a free-text Provider combobox in the Runtime section
|
||||
// that lets the operator override the model→provider derivation hermes-
|
||||
// agent does internally. Without this UI, a fresh signup whose Hermes
|
||||
// workspace defaults to a model with no clean vendor prefix (e.g.
|
||||
// `nousresearch/hermes-4-70b`) hits the runtime's own preflight error:
|
||||
// "No LLM provider configured. Run `hermes model` to select a
|
||||
// provider, or run `hermes setup` for first-time configuration."
|
||||
// — even though tasks #195-198 wired the entire downstream pipe so a
|
||||
// non-empty provider WOULD flow through canvas → workspace-server →
|
||||
// CP user-data → workspace config.yaml → hermes adapter.
|
||||
//
|
||||
// - workspace-server SetProvider / GetProvider (PUT/GET
|
||||
// /workspaces/:id/provider) → both return 410 Gone with a
|
||||
// PROVIDER_ENDPOINT_RETIRED structured body.
|
||||
// - workspace-server setProviderSecret (the writer into
|
||||
// workspace_secrets.LLM_PROVIDER) — removed; row never written.
|
||||
// - The LLM_PROVIDER workspace_secret itself — migrated away in
|
||||
// 20260528000000_drop_llm_provider_workspace_secret.up.sql.
|
||||
// Hongming Wang hit this on hongming.moleculesai.app at signup
|
||||
// 2026-05-01T17:35Z. Backend PRs were green, the gap was the missing
|
||||
// UI to set the value.
|
||||
//
|
||||
// ConfigTab still renders the provider dropdown for display (the user
|
||||
// can preview the derived provider locally), but Save no longer
|
||||
// round-trips the value. The replacement contract is that the provider
|
||||
// is DERIVED at every decision point from (runtime, model) via the
|
||||
// registry — see internal/providers/derive_provider.go.
|
||||
//
|
||||
// The original suite's coverage is replaced by:
|
||||
//
|
||||
// - workspace-server: TestPutProvider_410Gone +
|
||||
// TestGetProvider_410Gone + TestProviderEndpointGone_BodyShape in
|
||||
// internal/handlers/llm_provider_removal_p4_test.go.
|
||||
// - workspace-server: TestWorkspaceCreate_FirstDeploy_OnlyPersistsMODEL
|
||||
// in internal/handlers/workspace_provision_shared_test.go.
|
||||
// - registry: TestDeriveProvider_RealManifest in
|
||||
// internal/providers/derive_provider_test.go.
|
||||
//
|
||||
// Restore from git history if any aspect of the legacy LLM_PROVIDER
|
||||
// flow needs to be revisited (it should not — the retirement is
|
||||
// permanent).
|
||||
// Each test pins one invariant. If any fails, the bug is back.
|
||||
|
||||
import { describe, it } from "vitest";
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
describe("ConfigTab provider override — retired (internal#718 P4)", () => {
|
||||
it.skip("LLM_PROVIDER override flow is retired; see file header for the replacement coverage", () => {
|
||||
// intentionally empty
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
const apiPut = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
patch: (path: string, body: unknown) => apiPatch(path, body),
|
||||
put: (path: string, body: unknown) => apiPut(path, body),
|
||||
post: vi.fn(),
|
||||
del: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Shared store stub — `updateNodeData` is exposed so a test can assert the
|
||||
// node-data flush happens after a successful PATCH (regression: previously
|
||||
// the DB updated but the canvas badge stayed stale until full hydrate).
|
||||
const storeUpdateNodeData = vi.fn();
|
||||
const storeRestartWorkspace = vi.fn();
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector({ restartWorkspace: storeRestartWorkspace, updateNodeData: storeUpdateNodeData }),
|
||||
{ getState: () => ({ restartWorkspace: storeRestartWorkspace, updateNodeData: storeUpdateNodeData }) },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentCardSection", () => ({
|
||||
AgentCardSection: () => <div data-testid="agent-card-stub" />,
|
||||
}));
|
||||
|
||||
import { ConfigTab } from "../ConfigTab";
|
||||
|
||||
// wireApi — same shape as ConfigTab.hermes.test.tsx, extended with the
|
||||
// /provider endpoint. Each test sets `providerValue` to the value the
|
||||
// GET endpoint returns; "missing" means the endpoint rejects (older
|
||||
// workspace-server pre-PR-2 — must not crash the tab).
|
||||
function wireApi(opts: {
|
||||
workspaceRuntime?: string;
|
||||
workspaceModel?: string;
|
||||
configYamlContent?: string | null;
|
||||
templates?: Array<{ id: string; name?: string; runtime?: string; models?: unknown[]; providers?: string[] }>;
|
||||
providerValue?: string | "missing";
|
||||
}) {
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === `/workspaces/ws-test`) {
|
||||
return Promise.resolve({ runtime: opts.workspaceRuntime ?? "" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/model`) {
|
||||
return Promise.resolve({ model: opts.workspaceModel ?? "" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/provider`) {
|
||||
if (opts.providerValue === "missing") {
|
||||
return Promise.reject(new Error("404"));
|
||||
}
|
||||
return Promise.resolve({ provider: opts.providerValue ?? "", source: opts.providerValue ? "workspace_secrets" : "default" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/files/config.yaml`) {
|
||||
if (opts.configYamlContent === null) return Promise.reject(new Error("not found"));
|
||||
return Promise.resolve({ content: opts.configYamlContent ?? "" });
|
||||
}
|
||||
if (path === "/templates") {
|
||||
return Promise.resolve(opts.templates ?? []);
|
||||
}
|
||||
return Promise.reject(new Error(`unmocked api.get: ${path}`));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPatch.mockReset();
|
||||
apiPut.mockReset();
|
||||
storeUpdateNodeData.mockReset();
|
||||
storeRestartWorkspace.mockReset();
|
||||
});
|
||||
|
||||
describe("ConfigTab — Provider override (Option B PR-5)", () => {
|
||||
// Empty provider on load is the legitimate default ("auto-derive
|
||||
// from model slug prefix"), NOT an error. The endpoint returning
|
||||
// {provider: "", source: "default"} is the documented happy-path
|
||||
// shape — if the form treated that as "load failed" we'd lose the
|
||||
// ability to render the input at all on fresh workspaces.
|
||||
it("renders an empty Provider input when no override is set", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "nousresearch/hermes-4-70b",
|
||||
configYamlContent: "name: ws\nruntime: hermes\n",
|
||||
providerValue: "",
|
||||
});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const input = await screen.findByTestId("provider-input");
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
});
|
||||
|
||||
// Pre-existing override loads back into the field on mount. Without
|
||||
// this, an operator who set provider=openrouter yesterday would see
|
||||
// the field blank today, conclude the value didn't stick, and
|
||||
// re-save — the resulting PUT-with-same-value would auto-restart
|
||||
// the workspace for nothing.
|
||||
it("loads an existing provider override from the server", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "nousresearch/hermes-4-70b",
|
||||
configYamlContent: "name: ws\nruntime: hermes\n",
|
||||
providerValue: "openrouter",
|
||||
});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const input = await screen.findByTestId("provider-input");
|
||||
await waitFor(() => expect((input as HTMLInputElement).value).toBe("openrouter"));
|
||||
});
|
||||
|
||||
// Old workspace-server (pre-PR-2) returns a 404 on /provider. The
|
||||
// tab must keep loading — the fallback is "" (auto-derive), same as
|
||||
// a fresh workspace.
|
||||
it("falls back to empty provider when the endpoint is missing", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "nousresearch/hermes-4-70b",
|
||||
configYamlContent: "name: ws\nruntime: hermes\n",
|
||||
providerValue: "missing",
|
||||
});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const input = await screen.findByTestId("provider-input");
|
||||
expect((input as HTMLInputElement).value).toBe("");
|
||||
// Tab should be fully rendered, not stuck in loading or error state.
|
||||
expect(screen.queryByText(/Loading config/i)).toBeNull();
|
||||
});
|
||||
|
||||
// Setting a value + Save must PUT to the right endpoint with the
|
||||
// right body shape. Server-side handler (workspace-server
|
||||
// handlers/secrets.go:SetProvider) reads body.provider — any other
|
||||
// key gets silently ignored and the workspace_secrets row stays
|
||||
// unset. This regression would manifest as "Save → Restart →
|
||||
// workspace still says No LLM provider configured."
|
||||
it("PUTs the new provider to /workspaces/:id/provider on Save", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "nousresearch/hermes-4-70b",
|
||||
configYamlContent: "name: ws\nruntime: hermes\n",
|
||||
providerValue: "",
|
||||
});
|
||||
apiPut.mockResolvedValue({ status: "saved", provider: "anthropic" });
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const input = await screen.findByTestId("provider-input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "anthropic" } });
|
||||
expect((input as HTMLInputElement).value).toBe("anthropic");
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: /^save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const providerCalls = apiPut.mock.calls.filter(([path]) => path === "/workspaces/ws-test/provider");
|
||||
expect(providerCalls.length).toBe(1);
|
||||
expect(providerCalls[0][1]).toEqual({ provider: "anthropic" });
|
||||
});
|
||||
});
|
||||
|
||||
// No-change Save must NOT PUT /provider. The server-side SetProvider
|
||||
// auto-restarts the workspace on every successful PUT — re-writing
|
||||
// an unchanged value would cost the user a ~30s reboot every time
|
||||
// they tweak some other field.
|
||||
it("does not PUT /provider when the value is unchanged", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "nousresearch/hermes-4-70b",
|
||||
configYamlContent: "name: ws\nruntime: hermes\ntier: 2\n",
|
||||
providerValue: "openrouter",
|
||||
});
|
||||
apiPut.mockResolvedValue({});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await screen.findByTestId("provider-input");
|
||||
|
||||
// Click Save without touching the provider field. Trigger another
|
||||
// dirty-marker (tier change) so Save is enabled — the test is
|
||||
// about NOT touching /provider, not about Save being disabled.
|
||||
const tierSelect = screen.getByLabelText(/tier/i) as HTMLSelectElement;
|
||||
fireEvent.change(tierSelect, { target: { value: "3" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: /^save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
// Some PUT(s) may fire (e.g. /model). Just assert /provider is NOT among them.
|
||||
const providerCalls = apiPut.mock.calls.filter(([path]) => path === "/workspaces/ws-test/provider");
|
||||
expect(providerCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// The dropdown's suggestion list MUST come from the runtime's own
|
||||
// template (via /templates → runtime_config.providers), not a
|
||||
// hardcoded canvas-side enum. This is the "Native + pluggable
|
||||
// runtime" invariant: a new runtime declaring its own provider
|
||||
// taxonomy in its config.yaml gets a working dropdown without ANY
|
||||
// canvas-side change.
|
||||
//
|
||||
// Pinned by checking that suggestions surfaced in the datalist
|
||||
// exactly mirror what the templates endpoint returned for the
|
||||
// matching runtime. If a future contributor reintroduces a
|
||||
// PROVIDER_SUGGESTIONS-style hardcoded list and the datalist
|
||||
// contents don't follow the template, this test fails.
|
||||
it("populates the provider datalist from the matched runtime's templates entry", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "nousresearch/hermes-4-70b",
|
||||
configYamlContent: "name: ws\nruntime: hermes\n",
|
||||
providerValue: "",
|
||||
templates: [
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
runtime: "hermes",
|
||||
models: [],
|
||||
// The provider list every runtime adapter ships in its own
|
||||
// config.yaml. Canvas must surface THIS, not its own list.
|
||||
providers: ["nous", "openrouter", "anthropic", "minimax-cn"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const input = await screen.findByTestId("provider-input");
|
||||
const listId = (input as HTMLInputElement).getAttribute("list");
|
||||
expect(listId).toBeTruthy();
|
||||
await waitFor(() => {
|
||||
const datalist = document.getElementById(listId!);
|
||||
expect(datalist).not.toBeNull();
|
||||
const optionValues = Array.from(datalist!.querySelectorAll("option")).map(
|
||||
(o) => (o as HTMLOptionElement).value,
|
||||
);
|
||||
// Order matters — most-common-first is part of the contract so
|
||||
// the demo flow lands on a working choice without scrolling.
|
||||
expect(optionValues).toEqual(["nous", "openrouter", "anthropic", "minimax-cn"]);
|
||||
});
|
||||
});
|
||||
|
||||
// Fallback path: when a template hasn't migrated to the explicit
|
||||
// `providers:` field yet, suggestions are derived from model slug
|
||||
// prefixes. Still adapter-driven (the slugs come from the template's
|
||||
// `models:` list), just inferred. This keeps existing templates
|
||||
// working while the platform team migrates them one at a time.
|
||||
it("renders vendor-grouped provider dropdown when template ships models", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "anthropic/claude-opus-4-7",
|
||||
configYamlContent: "name: ws\nruntime: hermes\n",
|
||||
providerValue: "",
|
||||
templates: [
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
runtime: "hermes",
|
||||
models: [
|
||||
{ id: "anthropic/claude-opus-4-7", required_env: ["ANTHROPIC_API_KEY"] },
|
||||
{ id: "openai/gpt-4o", required_env: ["OPENROUTER_API_KEY"] },
|
||||
{ id: "anthropic/claude-sonnet-4-5", required_env: ["ANTHROPIC_API_KEY"] }, // dup vendor — must dedupe
|
||||
{ id: "nousresearch/hermes-4-70b", required_env: ["HERMES_API_KEY"] },
|
||||
],
|
||||
// No `providers:` field → ProviderModelSelector derives vendors
|
||||
// from model id prefixes via its own buildProviderCatalog.
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
// With models present, the new vendor-aware dropdown renders.
|
||||
// Provider entries dedupe by vendor → 3 unique vendors here
|
||||
// (anthropic, openai, nousresearch).
|
||||
const select = await screen.findByTestId("provider-select") as HTMLSelectElement;
|
||||
await waitFor(() => {
|
||||
const optionTexts = Array.from(select.options)
|
||||
.map((o) => o.text)
|
||||
.filter((t) => !t.startsWith("—")); // strip placeholder
|
||||
// Labels are vendor display names, but vendor identity is what
|
||||
// matters for dedupe. Assert each expected vendor surfaces once.
|
||||
expect(optionTexts.some((t) => t.startsWith("Anthropic API"))).toBe(true);
|
||||
expect(optionTexts.some((t) => t.startsWith("OpenAI"))).toBe(true);
|
||||
expect(optionTexts.some((t) => t.startsWith("Nous Research"))).toBe(true);
|
||||
expect(optionTexts.length).toBe(3); // dedupe pin
|
||||
});
|
||||
});
|
||||
|
||||
// Empty string is a legitimate save target — it clears the override
|
||||
// (the server-side endpoint deletes the workspace_secrets row).
|
||||
// Operators who picked "anthropic" yesterday and want to revert to
|
||||
// auto-derive today should be able to do so by clearing the field
|
||||
// and clicking Save. Without this PUT path, the only way to clear
|
||||
// would be a direct DB edit.
|
||||
it("PUTs an empty string when the operator clears a previously-set provider", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "anthropic:claude-opus-4-7",
|
||||
configYamlContent: "name: ws\nruntime: hermes\n",
|
||||
providerValue: "openrouter",
|
||||
});
|
||||
apiPut.mockResolvedValue({ status: "cleared" });
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const input = await screen.findByTestId("provider-input");
|
||||
await waitFor(() => expect((input as HTMLInputElement).value).toBe("openrouter"));
|
||||
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: /^save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const providerCalls = apiPut.mock.calls.filter(([path]) => path === "/workspaces/ws-test/provider");
|
||||
expect(providerCalls.length).toBe(1);
|
||||
expect(providerCalls[0][1]).toEqual({ provider: "" });
|
||||
});
|
||||
});
|
||||
|
||||
// Display-vs-storage drift regression (2026-05-03 incident, workspace
|
||||
// e13aebd8…). User deployed claude-code with MiniMax-M2 stored in
|
||||
// MODEL_PROVIDER. The container env (MODEL=MiniMax-M2) and chat
|
||||
// worked correctly, but the Config tab showed "Claude Code
|
||||
// subscription / Claude Sonnet (OAuth)" — i.e. the template's
|
||||
// runtime_config.model: sonnet default — because currentModelId
|
||||
// reads runtime_config.model first and loadConfig was overriding
|
||||
// only the top-level config.model field. The merged shape was:
|
||||
// { model: "MiniMax-M2", runtime_config: { model: "sonnet" } }
|
||||
// and currentModelId picked "sonnet". Fix: loadConfig propagates
|
||||
// wsMetadataModel into BOTH places so the form is a single source
|
||||
// of truth (DB-backed MODEL_PROVIDER). Pinning the merged-path
|
||||
// branch with the exact reproducing shape: claude-code template
|
||||
// YAML has runtime_config.model: sonnet; live workspace's
|
||||
// MODEL_PROVIDER is MiniMax-M2; tab must show the latter.
|
||||
it("prefers MODEL_PROVIDER over the template's runtime_config.model on load", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "claude-code",
|
||||
workspaceModel: "MiniMax-M2",
|
||||
configYamlContent: "name: ws\nruntime: claude-code\nruntime_config:\n model: sonnet\n",
|
||||
providerValue: "",
|
||||
templates: [
|
||||
{
|
||||
id: "claude-code-default",
|
||||
name: "Claude Code",
|
||||
runtime: "claude-code",
|
||||
models: [
|
||||
{ id: "sonnet", name: "Claude Sonnet (OAuth)", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "MiniMax-M2", name: "MiniMax M2", required_env: ["MINIMAX_API_KEY"] },
|
||||
{ id: "MiniMax-M2.7", name: "MiniMax M2.7", required_env: ["MINIMAX_API_KEY"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const modelSelect = (await screen.findByTestId("model-select")) as HTMLSelectElement;
|
||||
await waitFor(() => expect(modelSelect.value).toBe("MiniMax-M2"));
|
||||
|
||||
// Provider dropdown should also reflect MiniMax (back-derived from
|
||||
// the model slug since LLM_PROVIDER is unset). Without the fix,
|
||||
// the selector falls back to the first catalog entry whose first
|
||||
// model matches "sonnet" → anthropic-oauth bucket → "Claude Code
|
||||
// subscription".
|
||||
const providerSelect = screen.getByTestId("provider-select") as HTMLSelectElement;
|
||||
const selectedOption = providerSelect.options[providerSelect.selectedIndex];
|
||||
expect(selectedOption.textContent ?? "").toMatch(/MiniMax/);
|
||||
});
|
||||
|
||||
// Sibling pin to the display-fix above. The display fix mirrors
|
||||
// wsMetadataModel into runtime_config.model so the selector renders
|
||||
// the live value; that mirror means handleSave's old YAML-vs-form
|
||||
// diff would always be non-zero on a no-op save (YAML default
|
||||
// "sonnet" vs. mirrored "MiniMax-M2") and PUT /model — which
|
||||
// server-side SetModel chains into an auto-restart. handleSave now
|
||||
// diffs against the loaded MODEL_PROVIDER instead. Pin: an
|
||||
// unrelated edit (tier change) must NOT touch /model when the
|
||||
// model itself didn't change.
|
||||
it("does not PUT /model on a no-op save when only an unrelated field changed", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "claude-code",
|
||||
workspaceModel: "MiniMax-M2",
|
||||
configYamlContent: "name: ws\nruntime: claude-code\ntier: 2\nruntime_config:\n model: sonnet\n",
|
||||
providerValue: "",
|
||||
templates: [
|
||||
{
|
||||
id: "claude-code-default",
|
||||
name: "Claude Code",
|
||||
runtime: "claude-code",
|
||||
models: [
|
||||
{ id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] },
|
||||
{ id: "MiniMax-M2", name: "MiniMax M2", required_env: ["MINIMAX_API_KEY"] },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
apiPut.mockResolvedValue({});
|
||||
apiPatch.mockResolvedValue({});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const tierSelect = (await screen.findByLabelText(/tier/i)) as HTMLSelectElement;
|
||||
fireEvent.change(tierSelect, { target: { value: "3" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: /^save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
const tierPatches = apiPatch.mock.calls.filter(([path, body]) =>
|
||||
path === "/workspaces/ws-test" && (body as { tier?: number }).tier === 3,
|
||||
);
|
||||
expect(tierPatches.length).toBe(1);
|
||||
});
|
||||
// Spurious /model PUT would fire here without the originalModel
|
||||
// diff baseline. The model itself didn't change, so /model must
|
||||
// stay untouched (otherwise SetModel auto-restarts).
|
||||
const modelPuts = apiPut.mock.calls.filter(([path]) => path === "/workspaces/ws-test/model");
|
||||
expect(modelPuts.length).toBe(0);
|
||||
});
|
||||
|
||||
// Save-then-stale-badge regression (2026-05-03 incident). User
|
||||
// selected T3 in the Tier dropdown, hit Save & Restart, the workspace
|
||||
// PATCH succeeded (`tier: 3` in DB), but the canvas header pill kept
|
||||
// showing "TIER T2" until a full hydrate. Root cause: handleSave
|
||||
// sent the PATCH to workspace-server but never pushed the same
|
||||
// change into useCanvasStore.updateNodeData, so every UI surface
|
||||
// reading from the store kept its stale value. Pin: a successful
|
||||
// tier PATCH must mirror into the store so the badge updates
|
||||
// synchronously with the response.
|
||||
it("flushes the dbPatch into useCanvasStore.updateNodeData after a successful PATCH", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "claude-code",
|
||||
workspaceModel: "MiniMax-M2",
|
||||
configYamlContent: "name: ws\nruntime: claude-code\ntier: 2\nruntime_config:\n model: sonnet\n",
|
||||
providerValue: "",
|
||||
templates: [
|
||||
{
|
||||
id: "claude-code-default",
|
||||
name: "Claude Code",
|
||||
runtime: "claude-code",
|
||||
models: [{ id: "sonnet", name: "Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
apiPatch.mockResolvedValue({ status: "updated" });
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const tierSelect = (await screen.findByLabelText(/tier/i)) as HTMLSelectElement;
|
||||
fireEvent.change(tierSelect, { target: { value: "3" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: /^save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPatch.mock.calls.some(([p]) => p === "/workspaces/ws-test")).toBe(true);
|
||||
});
|
||||
// Without the store flush, the badge would keep reading tier=2
|
||||
// from useCanvasStore.nodes until a full hydrate. Pin: handleSave
|
||||
// pushes the same fields it PATCHed.
|
||||
expect(storeUpdateNodeData).toHaveBeenCalledWith(
|
||||
"ws-test",
|
||||
expect.objectContaining({ tier: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
// Failure-gating sibling pin to the store-flush test above. The
|
||||
// production code places `updateNodeData` AFTER `await api.patch(...)`
|
||||
// inside the same `if (Object.keys(dbPatch).length > 0)` block, so a
|
||||
// PATCH rejection should throw before the store call. Without this
|
||||
// pin, a future refactor that wraps the PATCH in try/catch and
|
||||
// unconditionally calls updateNodeData would ship green — and then
|
||||
// the badge would lie when the server actually rejected the change.
|
||||
// Codified review feedback from PR #2545 (Agent 2).
|
||||
it("does NOT flush into useCanvasStore.updateNodeData when the PATCH rejects", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "claude-code",
|
||||
workspaceModel: "MiniMax-M2",
|
||||
configYamlContent: "name: ws\nruntime: claude-code\ntier: 2\nruntime_config:\n model: sonnet\n",
|
||||
providerValue: "",
|
||||
templates: [
|
||||
{
|
||||
id: "claude-code-default",
|
||||
name: "Claude Code",
|
||||
runtime: "claude-code",
|
||||
models: [{ id: "sonnet", name: "Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }],
|
||||
},
|
||||
],
|
||||
});
|
||||
apiPatch.mockRejectedValue(new Error("500 from workspace-server"));
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const tierSelect = (await screen.findByLabelText(/tier/i)) as HTMLSelectElement;
|
||||
fireEvent.change(tierSelect, { target: { value: "3" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: /^save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
// Wait for handleSave to settle (succeeds-or-fails). PATCH must
|
||||
// have been attempted; the error swallow inside handleSave keeps
|
||||
// saving=false in finally.
|
||||
await waitFor(() => {
|
||||
expect(apiPatch.mock.calls.some(([p]) => p === "/workspaces/ws-test")).toBe(true);
|
||||
});
|
||||
// Critically: the store must NOT have been told about the failed
|
||||
// change. Otherwise the badge would lie about a write the server
|
||||
// rejected.
|
||||
const tierFlushes = storeUpdateNodeData.mock.calls.filter(([, body]) =>
|
||||
typeof (body as { tier?: number }).tier === "number",
|
||||
);
|
||||
expect(tierFlushes.length).toBe(0);
|
||||
});
|
||||
|
||||
// Pin the hermes/pre-#240 edge case: workspace where MODEL_PROVIDER
|
||||
// was never written but YAML has runtime_config.model: "something".
|
||||
// originalModel must reflect the rendered baseline (the YAML value),
|
||||
// not the empty MODEL_PROVIDER, so an unrelated save (tier change)
|
||||
// doesn't fire a /model PUT and trigger an auto-restart. Codified
|
||||
// review feedback from PR #2545 (Agent 1, "Important").
|
||||
it("does not PUT /model when MODEL_PROVIDER is empty and the user only edited an unrelated field", async () => {
|
||||
wireApi({
|
||||
workspaceRuntime: "hermes",
|
||||
workspaceModel: "", // legacy workspace — never went through the picker
|
||||
configYamlContent:
|
||||
"name: ws\nruntime: hermes\ntier: 2\nruntime_config:\n model: nousresearch/hermes-4-70b\n",
|
||||
providerValue: "",
|
||||
templates: [
|
||||
{
|
||||
id: "hermes",
|
||||
name: "Hermes",
|
||||
runtime: "hermes",
|
||||
models: [{ id: "nousresearch/hermes-4-70b", name: "Hermes 4 70B", required_env: ["HERMES_API_KEY"] }],
|
||||
providers: ["nous"],
|
||||
},
|
||||
],
|
||||
});
|
||||
apiPut.mockResolvedValue({});
|
||||
apiPatch.mockResolvedValue({});
|
||||
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
const tierSelect = (await screen.findByLabelText(/tier/i)) as HTMLSelectElement;
|
||||
fireEvent.change(tierSelect, { target: { value: "3" } });
|
||||
|
||||
const saveBtn = screen.getByRole("button", { name: /^save$/i });
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPatch.mock.calls.some(([p]) => p === "/workspaces/ws-test")).toBe(true);
|
||||
});
|
||||
const modelPuts = apiPut.mock.calls.filter(([path]) => path === "/workspaces/ws-test/model");
|
||||
expect(modelPuts.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
const { mockGet, mockPost, mockRFBConstructor, mockRFBClipboardPasteFrom, mockRFBFocus } = vi.hoisted(() => ({
|
||||
const { mockGet, mockPost, mockRFBConstructor } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockRFBConstructor: vi.fn(),
|
||||
mockRFBClipboardPasteFrom: vi.fn(),
|
||||
mockRFBFocus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
@@ -32,12 +30,6 @@ vi.mock("@novnc/novnc", () => ({
|
||||
this.options = options;
|
||||
mockRFBConstructor(target, url, options);
|
||||
}
|
||||
clipboardPasteFrom(text: string) {
|
||||
mockRFBClipboardPasteFrom(text);
|
||||
}
|
||||
focus(options?: FocusOptions) {
|
||||
mockRFBFocus(options);
|
||||
}
|
||||
disconnect() {}
|
||||
},
|
||||
}));
|
||||
@@ -50,8 +42,6 @@ describe("DisplayTab", () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
mockRFBConstructor.mockReset();
|
||||
mockRFBClipboardPasteFrom.mockReset();
|
||||
mockRFBFocus.mockReset();
|
||||
});
|
||||
|
||||
it("renders unavailable state for non-display workspaces", async () => {
|
||||
@@ -167,43 +157,6 @@ describe("DisplayTab", () => {
|
||||
expect(mockRFBConstructor.mock.calls[0][1]).not.toContain("token=");
|
||||
});
|
||||
|
||||
it("forwards browser paste events into the noVNC clipboard", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
available: true,
|
||||
mode: "desktop-control",
|
||||
protocol: "novnc",
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
controller: "none",
|
||||
});
|
||||
mockPost.mockResolvedValueOnce({
|
||||
controller: "user",
|
||||
controlled_by: "admin-token",
|
||||
expires_at: "2026-05-23T08:48:27Z",
|
||||
session_url: "/workspaces/ws-display/display/session/websockify#token=signed",
|
||||
});
|
||||
|
||||
render(<DisplayTab workspaceId="ws-display" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: "Take control" })).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Take control" }));
|
||||
|
||||
const desktop = await screen.findByTitle("Workspace desktop");
|
||||
fireEvent.paste(desktop, {
|
||||
clipboardData: {
|
||||
getData: (type: string) => (type === "text/plain" ? "Paste Me" : ""),
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockRFBClipboardPasteFrom).toHaveBeenCalledWith("Paste Me");
|
||||
expect(mockRFBFocus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
});
|
||||
|
||||
it("releases user display control", async () => {
|
||||
mockGet
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -166,12 +166,11 @@ export function AttachmentImage({ workspaceId, attachment, onDownload, tone }: P
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
ariaLabel={`Preview of ${attachment.name}`}
|
||||
contained
|
||||
>
|
||||
<img
|
||||
src={state.blobUrl}
|
||||
alt={attachment.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
className="max-w-[95vw] max-h-[90vh] object-contain"
|
||||
/>
|
||||
</AttachmentLightbox>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
// AttachmentLightbox — shared modal for image / PDF /
|
||||
// AttachmentLightbox — shared fullscreen modal for image / PDF /
|
||||
// (future) any-fullscreen-renderable kind. Owns:
|
||||
// - Backdrop + centered viewport
|
||||
// - Esc to close
|
||||
@@ -14,11 +14,11 @@
|
||||
//
|
||||
// Design choices:
|
||||
//
|
||||
// 1. Portals — we don't use ReactDOM.createPortal because the chat tab
|
||||
// already gives us a positioned container and the preview should stay
|
||||
// inside that panel. Saves a portal mount in the common case + avoids
|
||||
// the SSR warning (canvas is "use client" but the parent shell is
|
||||
// server-rendered).
|
||||
// 1. Portals — we don't use ReactDOM.createPortal because the canvas
|
||||
// chat surface already renders at a high z-index and the modal's
|
||||
// fixed-position layout reaches the viewport regardless. Saves a
|
||||
// portal mount in the common case + avoids the SSR warning (canvas
|
||||
// is "use client" but the parent shell is server-rendered).
|
||||
//
|
||||
// 2. Focus trap — inline implementation (not a 3rd-party dep). The
|
||||
// chat lightbox needs to trap focus only across two interactive
|
||||
@@ -41,17 +41,13 @@ interface Props {
|
||||
* the dialog opens. The caller knows what's inside (image alt
|
||||
* text, PDF filename) and supplies it. */
|
||||
ariaLabel: string;
|
||||
/** Constrain the preview to the nearest positioned ancestor instead
|
||||
* of the whole browser viewport. ChatTab passes this so previews
|
||||
* stay inside the active side-panel tab. */
|
||||
contained?: boolean;
|
||||
/** The thing being shown in fullscreen — <img>, <embed>, etc.
|
||||
* Caller is responsible for sizing it to fit the viewport (we
|
||||
* give it max-w-full max-h-full via CSS). */
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function AttachmentLightbox({ open, onClose, ariaLabel, contained = false, children }: Props) {
|
||||
export function AttachmentLightbox({ open, onClose, ariaLabel, children }: Props) {
|
||||
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -94,19 +90,12 @@ export function AttachmentLightbox({ open, onClose, ariaLabel, contained = false
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const rootClass = contained
|
||||
? "absolute inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
|
||||
: "fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity";
|
||||
const contentClass = contained
|
||||
? "h-full w-full p-3 flex items-center justify-center"
|
||||
: "max-w-[95vw] max-h-[90vh] flex items-center justify-center";
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
className={rootClass}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 motion-reduce:transition-none transition-opacity"
|
||||
onClick={onBackdropClick}
|
||||
>
|
||||
{/* Close button — top-right, large hit area, keyboard-focusable.
|
||||
@@ -123,7 +112,7 @@ export function AttachmentLightbox({ open, onClose, ariaLabel, contained = false
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
className={contentClass}
|
||||
className="max-w-[95vw] max-h-[90vh] flex items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
// suppress the toolbar; we keep it on so the user gets standard
|
||||
// PDF affordances.
|
||||
//
|
||||
// Preview: AttachmentLightbox hosts the PDF inside the active chat tab
|
||||
// on click. Same shared modal as image — third caller justifies the
|
||||
// Fullscreen: AttachmentLightbox hosts the PDF at viewport size on
|
||||
// click. Same shared modal as image — third caller justifies the
|
||||
// abstraction (per RFC #2991 design).
|
||||
//
|
||||
// Failure modes:
|
||||
@@ -144,15 +144,16 @@ export function AttachmentPDF({ workspaceId, attachment, onDownload, tone }: Pro
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
ariaLabel={`Preview of ${attachment.name}`}
|
||||
contained
|
||||
>
|
||||
<div className="h-full w-full overflow-hidden rounded-lg border border-white/20 bg-white shadow-2xl">
|
||||
<iframe
|
||||
src={`${state.blobUrl}#view=FitH`}
|
||||
title={attachment.name}
|
||||
className="h-full w-full bg-white"
|
||||
/>
|
||||
</div>
|
||||
<embed
|
||||
src={state.blobUrl}
|
||||
type="application/pdf"
|
||||
// The lightbox's content slot caps at 95vw / 90vh, so size
|
||||
// 100% within that and let the user scroll inside the PDF
|
||||
// viewer.
|
||||
style={{ width: "95vw", height: "90vh" }}
|
||||
aria-label={attachment.name}
|
||||
/>
|
||||
</AttachmentLightbox>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentLightbox — modal for image / PDF preview.
|
||||
* AttachmentLightbox — fullscreen modal for image / PDF preview.
|
||||
*
|
||||
* Owns: backdrop + viewport, Esc to close, click-outside to close,
|
||||
* focus trap (close button focus on open, restore on close),
|
||||
@@ -135,22 +135,6 @@ describe("AttachmentLightbox — render", () => {
|
||||
const closeBtn = document.querySelector('button[aria-label="Close preview"]');
|
||||
expect(closeBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses absolute positioning when contained=true", () => {
|
||||
render(
|
||||
<AttachmentLightbox
|
||||
open={true}
|
||||
onClose={vi.fn()}
|
||||
ariaLabel="Preview"
|
||||
contained
|
||||
>
|
||||
<MockContent />
|
||||
</AttachmentLightbox>,
|
||||
);
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog?.className).toContain("absolute");
|
||||
expect(dialog?.className).not.toContain("fixed");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Focus management ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentPDF — inline PDF preview button + click-to-panel lightbox.
|
||||
* AttachmentPDF — inline PDF preview button + click-to-fullscreen lightbox.
|
||||
*
|
||||
* Per RFC #2991 PR-3: platform-auth URIs fetch bytes → Blob → ObjectURL;
|
||||
* external URIs use the raw URL directly. State machine: idle → loading →
|
||||
* ready/error. Loading skeleton shown while fetching. Error falls back to
|
||||
* AttachmentChip. Clicking the preview button opens AttachmentLightbox with
|
||||
* a browser PDF iframe. Blob URL cleaned up on unmount.
|
||||
* <embed>. Blob URL cleaned up on unmount.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use DOM APIs for assertions.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders loading skeleton with PdfGlyph + filename text
|
||||
* - Renders preview button with PDF glyph, filename, and "PDF" label
|
||||
* - Opens lightbox with a framed <iframe> viewer on button click
|
||||
* - Opens lightbox with <embed> on button click
|
||||
* - Lightbox closes on Escape
|
||||
* - tone=user applies blue/accent classes on button
|
||||
* - tone=agent applies neutral border on button
|
||||
@@ -136,7 +136,7 @@ describe("AttachmentPDF — ready", () => {
|
||||
expect(btn?.textContent).toContain("PDF");
|
||||
});
|
||||
|
||||
it("opens lightbox with a framed iframe viewer on button click", async () => {
|
||||
it("opens lightbox with <embed> on button click", async () => {
|
||||
mockFetchOk("data");
|
||||
const att = makeAttachment("report.pdf");
|
||||
render(
|
||||
@@ -158,13 +158,8 @@ describe("AttachmentPDF — ready", () => {
|
||||
});
|
||||
const dialog = document.querySelector('[role="dialog"]');
|
||||
expect(dialog?.getAttribute("aria-label")).toContain("report.pdf");
|
||||
expect(dialog?.className).toContain("absolute");
|
||||
const frame = dialog?.querySelector("iframe") as HTMLIFrameElement | null;
|
||||
expect(frame).toBeTruthy();
|
||||
expect(frame?.getAttribute("title")).toBe("report.pdf");
|
||||
expect(frame?.className).toContain("bg-white");
|
||||
expect(frame?.parentElement?.className).toContain("w-full");
|
||||
expect(dialog?.querySelector("embed")).toBeNull();
|
||||
// Lightbox contains an <embed>
|
||||
expect(dialog?.querySelector("embed")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("closes lightbox on Escape key", async () => {
|
||||
|
||||
@@ -237,13 +237,11 @@ describe("AttachmentPreview dispatch", () => {
|
||||
expect(screen.getByLabelText(/Open doc\.pdf preview/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click → panel-contained lightbox opens with a browser PDF iframe.
|
||||
// Click → lightbox opens with <embed> inside.
|
||||
fireEvent.click(screen.getByLabelText(/Open doc\.pdf preview/i));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(dialog).toBeTruthy();
|
||||
expect(dialog.className).toContain("absolute");
|
||||
expect(dialog.querySelector("iframe")).not.toBeNull();
|
||||
expect(dialog.querySelector("embed")).toBeNull();
|
||||
expect(dialog.querySelector("embed[type='application/pdf']")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("kind=pdf fetch fails → falls back to chip", async () => {
|
||||
|
||||
@@ -113,31 +113,6 @@ describe("resolveAttachmentHref — platform-pending: scheme (poll-mode uploads)
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAttachmentHref — legacy platform content URLs", () => {
|
||||
const chatWs = "chat-ws-aaaaaaaa";
|
||||
const sourceWs = "d76977b1-d620-4f42-a57e-111111111111";
|
||||
const fileID = "e2dfaf2e-1111-4abc-9999-222222222222";
|
||||
|
||||
it("rewrites /workspaces/<ws>/content/<file>/content to the authenticated pending-upload endpoint", () => {
|
||||
const url = resolveAttachmentHref(
|
||||
chatWs,
|
||||
`/workspaces/${sourceWs}/content/${fileID}/content`,
|
||||
);
|
||||
expect(url).toContain(`/workspaces/${sourceWs}/pending-uploads/${fileID}/content`);
|
||||
expect(url).not.toContain(`/workspaces/${chatWs}/`);
|
||||
});
|
||||
|
||||
it("treats legacy content URLs as platform attachments so previews fetch with auth headers", () => {
|
||||
expect(isPlatformAttachment(`/workspaces/${sourceWs}/content/${fileID}/content`)).toBe(true);
|
||||
});
|
||||
|
||||
it("passes malformed legacy content URLs through unchanged", () => {
|
||||
const malformed = `/workspaces/${sourceWs}/content//content`;
|
||||
expect(resolveAttachmentHref(chatWs, malformed)).toBe(malformed);
|
||||
expect(isPlatformAttachment(malformed)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPlatformAttachment", () => {
|
||||
it("returns true for platform-pending: URIs", () => {
|
||||
expect(isPlatformAttachment("platform-pending:abc/file")).toBe(true);
|
||||
|
||||
@@ -125,8 +125,6 @@ export async function uploadChatFiles(
|
||||
* - `/workspace/...` (bare absolute path inside the container)
|
||||
* - `platform-pending:<wsid>/<file_id>` (poll-mode upload, staged
|
||||
* on platform side; resolves to /pending-uploads/<file_id>/content)
|
||||
* - `/workspaces/<wsid>/content/<file_id>/content` (legacy platform
|
||||
* content URL; normalizes to the same pending-upload endpoint)
|
||||
* Everything that looks like an allowed-root container path is
|
||||
* rewritten to the authenticated /chat/download endpoint. HTTP(S)
|
||||
* URIs pass through unchanged so we can also render links to
|
||||
@@ -165,11 +163,6 @@ export function resolveAttachmentHref(
|
||||
}
|
||||
return uri;
|
||||
}
|
||||
const legacy = parseLegacyPlatformContentUri(uri);
|
||||
if (legacy) {
|
||||
const [wsid, fileID] = legacy;
|
||||
return `${PLATFORM_URL}/workspaces/${encodeURIComponent(wsid)}/pending-uploads/${encodeURIComponent(fileID)}/content`;
|
||||
}
|
||||
const containerPath = normalizeWorkspaceUri(uri);
|
||||
if (containerPath) {
|
||||
return `${PLATFORM_URL}/workspaces/${workspaceId}/chat/download?path=${encodeURIComponent(containerPath)}`;
|
||||
@@ -182,7 +175,6 @@ export function resolveAttachmentHref(
|
||||
* downloadChatFile rather than letting the browser navigate. */
|
||||
export function isPlatformAttachment(uri: string): boolean {
|
||||
if (uri.startsWith("platform-pending:")) return true;
|
||||
if (parseLegacyPlatformContentUri(uri)) return true;
|
||||
return normalizeWorkspaceUri(uri) !== null;
|
||||
}
|
||||
|
||||
@@ -191,12 +183,6 @@ export function isPlatformAttachment(uri: string): boolean {
|
||||
* mirror the server's `allowedRoots` allowlist. */
|
||||
const ALLOWED_CONTAINER_ROOTS = ["/configs", "/workspace", "/home", "/plugins"];
|
||||
|
||||
function parseLegacyPlatformContentUri(uri: string): [string, string] | null {
|
||||
const m = uri.match(/^\/workspaces\/([^/]+)\/content\/([^/]+)\/content(?:[?#].*)?$/);
|
||||
if (!m || !m[1] || !m[2]) return null;
|
||||
return [m[1], m[2]];
|
||||
}
|
||||
|
||||
function normalizeWorkspaceUri(uri: string): string | null {
|
||||
let path: string | null = null;
|
||||
if (uri.startsWith("workspace:")) {
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
} from "@testing-library/react";
|
||||
import { LLMBillingSection } from "../llm-billing-section";
|
||||
|
||||
// Tests for LLMBillingSection (internal#691). Locks in:
|
||||
// - the section renders the resolved mode + source label
|
||||
// - the dropdown maps "inherit" → PUT {mode: null}
|
||||
// - the dropdown maps "byok" → PUT {mode: "byok"}
|
||||
// - a garbled override surfaces the warning banner
|
||||
// - the post-write resolution updates the UI without a refetch
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPut = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (...args: unknown[]) => apiGet(...args),
|
||||
put: (...args: unknown[]) => apiPut(...args),
|
||||
post: vi.fn().mockResolvedValue({}),
|
||||
del: vi.fn().mockResolvedValue({}),
|
||||
patch: vi.fn().mockResolvedValue({}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Collapsed-by-default Section wrapper would hide the content; replace
|
||||
// it with a passthrough so the dropdown is reachable in the test DOM.
|
||||
vi.mock("../form-inputs", async () => {
|
||||
const actual = await vi.importActual<typeof import("../form-inputs")>(
|
||||
"../form-inputs",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
Section: ({ children }: { children: React.ReactNode }) => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("LLMBillingSection — internal#691", () => {
|
||||
it("renders the resolved mode + source for an inherited workspace", async () => {
|
||||
apiGet.mockResolvedValueOnce({
|
||||
workspace_id: "ws-1",
|
||||
resolved_mode: "platform_managed",
|
||||
workspace_override: null,
|
||||
org_default: "platform_managed",
|
||||
source: "org_default",
|
||||
});
|
||||
|
||||
render(<LLMBillingSection workspaceId="ws-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiGet).toHaveBeenCalledWith(
|
||||
"/admin/workspaces/ws-1/llm-billing-mode",
|
||||
);
|
||||
});
|
||||
// Resolved mode appears.
|
||||
expect(screen.getByText(/Resolved mode:/i).textContent).toMatch(/platform_managed/);
|
||||
// Source label appears.
|
||||
expect(
|
||||
screen.getByText(/inherited from org default/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('PUTs {mode: "byok"} when user picks BYOK and reflects the new resolution', async () => {
|
||||
apiGet.mockResolvedValueOnce({
|
||||
workspace_id: "ws-2",
|
||||
resolved_mode: "platform_managed",
|
||||
workspace_override: null,
|
||||
org_default: "platform_managed",
|
||||
source: "org_default",
|
||||
});
|
||||
apiPut.mockResolvedValueOnce({
|
||||
workspace_id: "ws-2",
|
||||
resolved_mode: "byok",
|
||||
workspace_override: "byok",
|
||||
org_default: "platform_managed",
|
||||
source: "workspace_override",
|
||||
});
|
||||
|
||||
render(<LLMBillingSection workspaceId="ws-2" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
|
||||
const select = (await screen.findByLabelText(
|
||||
/llm billing mode override/i,
|
||||
)) as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "byok" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPut).toHaveBeenCalledWith(
|
||||
"/admin/workspaces/ws-2/llm-billing-mode",
|
||||
{ mode: "byok" },
|
||||
);
|
||||
});
|
||||
// Post-write resolution propagated to UI.
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/explicit override on this workspace/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("PUTs {mode: null} when user picks Inherit (clears the override)", async () => {
|
||||
apiGet.mockResolvedValueOnce({
|
||||
workspace_id: "ws-3",
|
||||
resolved_mode: "byok",
|
||||
workspace_override: "byok",
|
||||
org_default: "platform_managed",
|
||||
source: "workspace_override",
|
||||
});
|
||||
apiPut.mockResolvedValueOnce({
|
||||
workspace_id: "ws-3",
|
||||
resolved_mode: "platform_managed",
|
||||
workspace_override: null,
|
||||
org_default: "platform_managed",
|
||||
source: "org_default",
|
||||
});
|
||||
|
||||
render(<LLMBillingSection workspaceId="ws-3" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
|
||||
const select = (await screen.findByLabelText(
|
||||
/llm billing mode override/i,
|
||||
)) as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "inherit" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apiPut).toHaveBeenCalledWith(
|
||||
"/admin/workspaces/ws-3/llm-billing-mode",
|
||||
{ mode: null },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a warning banner when the override value is garbled", async () => {
|
||||
apiGet.mockResolvedValueOnce({
|
||||
workspace_id: "ws-4",
|
||||
resolved_mode: "platform_managed", // resolver fell through, default-closed
|
||||
workspace_override: "byokk", // typo persisted somehow
|
||||
org_default: "platform_managed",
|
||||
source: "org_default",
|
||||
});
|
||||
|
||||
render(<LLMBillingSection workspaceId="ws-4" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/non-standard value/i),
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders an error banner when the GET fails", async () => {
|
||||
apiGet.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
render(<LLMBillingSection workspaceId="ws-5" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/network down/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
export { type ConfigData, DEFAULT_CONFIG, TextInput, NumberInput, Toggle, TagList, Section } from "./form-inputs";
|
||||
export { parseYaml, toYaml } from "./yaml-utils";
|
||||
export { SecretsSection } from "./secrets-section";
|
||||
export { LLMBillingSection } from "./llm-billing-section";
|
||||
|
||||
@@ -1,219 +0,0 @@
|
||||
"use client";
|
||||
|
||||
// llm-billing-section.tsx — Config-tab section for the per-workspace
|
||||
// llm_billing_mode override (internal#691).
|
||||
//
|
||||
// Surfaces:
|
||||
// - The currently RESOLVED mode for this workspace (the mode the
|
||||
// workspace-server's strip gate will use at next provision).
|
||||
// - The org-level default (so the user sees what they're inheriting).
|
||||
// - A dropdown to set / clear the workspace-level override.
|
||||
// - A "source" line so operators can answer "is this inherited or
|
||||
// explicit?" without DB archeology (RFC Observability hot-spot).
|
||||
//
|
||||
// Hits:
|
||||
// GET /admin/workspaces/:id/llm-billing-mode — read resolution
|
||||
// PUT /admin/workspaces/:id/llm-billing-mode — write {mode: "..."|null}
|
||||
//
|
||||
// Both routes are on the per-tenant workspace-server (same origin as the
|
||||
// other canvas /admin calls). CP's proxy at /cp/admin/workspaces/:id/
|
||||
// llm-billing-mode exists for ops use; the canvas uses the per-tenant
|
||||
// path directly to keep the round-trip cheap.
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import { Section } from "./form-inputs";
|
||||
|
||||
// Mirrors workspace-server/internal/handlers/llm_billing_mode.go::BillingModeResolution.
|
||||
// Kept as a literal shape (not imported) because canvas has no Go-type bridge.
|
||||
export interface BillingModeResolution {
|
||||
workspace_id: string;
|
||||
resolved_mode: "platform_managed" | "byok" | "disabled";
|
||||
// Pointer-typed on the Go side: nil = inherit, non-nil = the raw
|
||||
// workspace-level override (even if garbled and falling through).
|
||||
workspace_override: string | null;
|
||||
org_default: "platform_managed" | "byok" | "disabled";
|
||||
source: "workspace_override" | "org_default" | "constant_fallback";
|
||||
}
|
||||
|
||||
// The dropdown emits one of these values. "inherit" is the UX-only label
|
||||
// that maps to a `null` body in the PUT request.
|
||||
type DropdownChoice = "inherit" | "platform_managed" | "byok" | "disabled";
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
const MODE_LABELS: Record<DropdownChoice, string> = {
|
||||
inherit: "Inherit from org default",
|
||||
platform_managed: "Platform-managed (uses Molecule credits)",
|
||||
byok: "BYOK (your own OAuth / vendor keys)",
|
||||
disabled: "Disabled (no LLM access)",
|
||||
};
|
||||
|
||||
const MODE_DESCRIPTIONS: Record<DropdownChoice, string> = {
|
||||
inherit:
|
||||
"Use whichever mode is set at the organization level. Recommended unless this specific workspace needs a different billing source.",
|
||||
platform_managed:
|
||||
"Strip CLAUDE_CODE_OAUTH_TOKEN and vendor API keys from the workspace; route all LLM traffic through Molecule's proxy and bill your org credits.",
|
||||
byok:
|
||||
"Keep CLAUDE_CODE_OAUTH_TOKEN / vendor API keys in the workspace; LLM traffic goes directly to your provider and is billed to your OAuth subscription or API account.",
|
||||
disabled:
|
||||
"Block all LLM access for this workspace. Useful for sandbox workspaces that should not consume credits or hit external providers.",
|
||||
};
|
||||
|
||||
const SOURCE_LABELS: Record<BillingModeResolution["source"], string> = {
|
||||
workspace_override: "explicit override on this workspace",
|
||||
org_default: "inherited from org default",
|
||||
constant_fallback:
|
||||
"fallback (workspace + org defaults missing or unrecognized — defaulted to platform_managed)",
|
||||
};
|
||||
|
||||
export function LLMBillingSection({ workspaceId }: Props) {
|
||||
const [resolution, setResolution] = useState<BillingModeResolution | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.get<BillingModeResolution>(
|
||||
`/admin/workspaces/${workspaceId}/llm-billing-mode`,
|
||||
);
|
||||
setResolution(res);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to load billing mode");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workspaceId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Current dropdown selection is derived from the resolution. If the
|
||||
// override is null, we show "inherit"; otherwise we mirror the raw
|
||||
// workspace_override (NOT resolved_mode — that would conflate "explicit
|
||||
// platform_managed override" with "inherit while org happens to be
|
||||
// platform_managed", which has different semantics on the write side).
|
||||
const currentChoice: DropdownChoice = (() => {
|
||||
if (!resolution) return "inherit";
|
||||
if (resolution.workspace_override == null) return "inherit";
|
||||
const raw = resolution.workspace_override;
|
||||
if (raw === "platform_managed" || raw === "byok" || raw === "disabled") {
|
||||
return raw;
|
||||
}
|
||||
// Garbled value persisted via some external write. Show inherit so
|
||||
// the user can pick a clean value; on save they'll either clear it
|
||||
// (PUT null) or overwrite it with a valid one.
|
||||
return "inherit";
|
||||
})();
|
||||
|
||||
const handleChange = async (choice: DropdownChoice) => {
|
||||
if (!resolution) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
try {
|
||||
// "inherit" → PUT {mode: null}; otherwise → PUT {mode: choice}.
|
||||
const body = choice === "inherit" ? { mode: null } : { mode: choice };
|
||||
const updated = await api.put<BillingModeResolution>(
|
||||
`/admin/workspaces/${workspaceId}/llm-billing-mode`,
|
||||
body,
|
||||
);
|
||||
setResolution(updated);
|
||||
setSuccess(true);
|
||||
setTimeout(() => setSuccess(false), 2000);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to update billing mode");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="LLM Billing" defaultOpen={false}>
|
||||
{loading && (
|
||||
<div className="text-[10px] text-ink-mid">Loading billing mode…</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad mb-2"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolution && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[10px] text-ink-mid">
|
||||
Resolved mode: <strong className="text-ink">{resolution.resolved_mode}</strong>{" "}
|
||||
<span className="text-ink-mid">
|
||||
({SOURCE_LABELS[resolution.source]})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-ink-mid">
|
||||
Org default: <span className="text-ink">{resolution.org_default}</span>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="block text-[10px] text-ink-mid"
|
||||
htmlFor={`llm-billing-mode-${workspaceId}`}
|
||||
>
|
||||
Override
|
||||
</label>
|
||||
<select
|
||||
id={`llm-billing-mode-${workspaceId}`}
|
||||
aria-label="LLM billing mode override"
|
||||
value={currentChoice}
|
||||
disabled={saving}
|
||||
onChange={(e) => void handleChange(e.target.value as DropdownChoice)}
|
||||
className="w-full bg-surface-card border border-line rounded p-1 text-[10px] text-ink focus:outline-none focus:border-accent disabled:opacity-50"
|
||||
>
|
||||
{(Object.keys(MODE_LABELS) as DropdownChoice[]).map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{MODE_LABELS[m]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div
|
||||
className="text-[10px] text-ink-mid leading-snug"
|
||||
aria-live="polite"
|
||||
>
|
||||
{MODE_DESCRIPTIONS[currentChoice]}
|
||||
</div>
|
||||
|
||||
{success && (
|
||||
<div className="mt-1 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">
|
||||
Updated. Restart the workspace to apply.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolution.workspace_override != null &&
|
||||
!["platform_managed", "byok", "disabled"].includes(
|
||||
resolution.workspace_override,
|
||||
) && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-1 px-2 py-1 bg-yellow-900/30 border border-yellow-800 rounded text-[10px] text-warning"
|
||||
>
|
||||
Workspace override has a non-standard value (
|
||||
<code>{resolution.workspace_override}</code>) and is being
|
||||
ignored. Pick a valid mode above to clear the corrupt value.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -63,7 +63,6 @@ vi.mock("@/components/MissingKeysModal", () => ({
|
||||
onKeysAdded: (model?: string) => void;
|
||||
onCancel: () => void;
|
||||
configuredKeys?: Set<string>;
|
||||
optionalKeys?: string[];
|
||||
modelSuggestions?: string[];
|
||||
initialModel?: string;
|
||||
title?: string;
|
||||
@@ -78,9 +77,6 @@ vi.mock("@/components/MissingKeysModal", () => ({
|
||||
</span>
|
||||
<span data-testid="modal-initial-model">{props.initialModel ?? ""}</span>
|
||||
<span data-testid="modal-title">{props.title ?? ""}</span>
|
||||
<span data-testid="modal-optional-keys">
|
||||
{(props.optionalKeys ?? []).join(",")}
|
||||
</span>
|
||||
<button
|
||||
data-testid="modal-keys-added"
|
||||
onClick={() => props.onKeysAdded()}
|
||||
@@ -117,7 +113,6 @@ function makeTemplate(over: Partial<Template> = {}): Template {
|
||||
runtime: "claude-code",
|
||||
models: [],
|
||||
required_env: [],
|
||||
recommended_env: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
@@ -134,7 +129,6 @@ beforeEach(() => {
|
||||
missingKeys: [],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
mockApiPost.mockResolvedValue({ id: "ws-new" });
|
||||
@@ -249,7 +243,6 @@ describe("useTemplateDeploy — preflight failure modes", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const onDeployed = vi.fn();
|
||||
@@ -278,7 +271,6 @@ describe("useTemplateDeploy — modal lifecycle", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const onDeployed = vi.fn();
|
||||
@@ -314,7 +306,6 @@ describe("useTemplateDeploy — modal lifecycle", () => {
|
||||
missingKeys: ["ANTHROPIC_API_KEY"],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -368,7 +359,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(["MINIMAX_API_KEY", "ANTHROPIC_API_KEY"]),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -402,7 +392,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -431,7 +420,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -496,7 +484,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
{ id: "ANTHROPIC_API_KEY", label: "Anthropic", envVars: ["ANTHROPIC_API_KEY"] },
|
||||
],
|
||||
runtime: "hermes",
|
||||
optionalKeys: [],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
@@ -512,35 +499,6 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
|
||||
expect(screen.getByTestId("modal-configured-size").textContent).toBe("0");
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens configure modal for optional env prompts even when no required provider key is missing", async () => {
|
||||
mockCheckDeploySecrets.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
missingKeys: [],
|
||||
providers: [],
|
||||
runtime: "claude-code",
|
||||
optionalKeys: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
|
||||
configuredKeys: new Set(),
|
||||
});
|
||||
const { result, rerender } = renderHook(() => useTemplateDeploy());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.deploy(makeTemplate({
|
||||
id: "seo-agent",
|
||||
name: "SEO Agent",
|
||||
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
|
||||
}));
|
||||
});
|
||||
|
||||
rerender();
|
||||
render(<>{result.current.modal}</>);
|
||||
|
||||
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
|
||||
expect(screen.getByTestId("modal-optional-keys").textContent).toBe(
|
||||
"GOOGLE_GSC_SITE,GOOGLE_GA4_PROPERTY_ID",
|
||||
);
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTemplateDeploy — POST failure", () => {
|
||||
|
||||
@@ -15,8 +15,6 @@ export function useKeyboardShortcut(
|
||||
if (!enabled) return;
|
||||
|
||||
function handler(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest?.('[data-display-stream="true"]')) return;
|
||||
if (e.key !== key) return;
|
||||
if (meta && !e.metaKey) return;
|
||||
if (ctrl && !e.ctrlKey) return;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user