Compare commits

..

1 Commits

Author SHA1 Message Date
hongming 4e16e385ab fix(memory-plugin): emit JSON literal "null" for nil metadata/propagation
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 7s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 6s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 9s
E2E Chat / detect-changes (pull_request) Successful in 8s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 7s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
Harness Replays / detect-changes (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
gate-check-v3 / gate-check (pull_request) Successful in 7s
qa-review / approved (pull_request) Failing after 5s
sop-checklist / na-declarations (pull_request) N/A: (none)
security-review / approved (pull_request) Failing after 5s
sop-checklist / review-refire (pull_request) Has been skipped
sop-checklist / all-items-acked (pull_request) Successful in 4s
sop-tier-check / tier-check (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 58s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 7s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 7s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 12s
Harness Replays / Harness Replays (pull_request) Successful in 7s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 2m14s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Platform (Go) (pull_request) Successful in 7m10s
CI / all-required (pull_request) Successful in 9m11s
audit-force-merge / audit (pull_request) Successful in 6s
URGENT — fixes a production regression introduced by #1794 hitting a
latent bug in the plugin's marshalMetadata helper.

Symptom: every POST /workspaces/:id/memories on tenants running the
post-#1794 image returns HTTP 500. Tenant logs show:

    Commit memory error (plugin): memory-plugin: internal:
    commit memory: pq: invalid input syntax for type json

Same error fires for UpsertNamespace called from the Phase A2 backfill
(cmd/memory-backfill), causing the client's circuit breaker to open
after 3 failures and rejecting EVERY subsequent plugin call for that
process.

Root cause: marshalMetadata returned Go nil for nil-map input. lib/pq
sends Go nil as a bytea-typed NULL placeholder. PostgreSQL refused to
implicitly cast that placeholder into the target jsonb column
(memory_namespaces.metadata + memory_records.propagation), so the
INSERT failed at parse time. Direct `INSERT ... metadata=NULL` works
fine from psql, so the bug is purely on the driver-binding side —
sending the JSON literal `null` keeps the parameter typed as a
valid jsonb value regardless of pq's auto-conversion heuristics.

Existing sqlmock tests use sqlmock.AnyArg() for the metadata column,
so they didn't catch the real-postgres type mismatch.

Verified:
- All four production tenants on staging-39c8618 currently FAIL every
  POST /memories with the JSON syntax error
- Direct `INSERT INTO memory_plugin.memory_namespaces ... NULL` from
  psql succeeds — confirms PostgreSQL is fine with NULL; only the
  pq-binding path is broken
- pgplugin unit test updated to pin the new contract: nil input →
  `[]byte("null")`

Once this lands + image rebuilds + tenants recycle:
- POST /memories writes will succeed (closes the #1794 regression)
- Phase A2 backfill via /memory-backfill will succeed (closes #1791
  step 2)

Closes the production regression. Phase A2 backfill is now unblocked.
2026-05-24 03:31:54 -07:00
392 changed files with 3277 additions and 16090 deletions
+14 -28
View File
@@ -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)
+2 -4
View File
@@ -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)
+1 -3
View File
@@ -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"
+1 -1
View File
@@ -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:
+30 -268
View File
@@ -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
+1 -155
View File
@@ -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
+5 -10
View File
@@ -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}"
+9 -19
View File
@@ -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)
+1 -1
View File
@@ -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.
+1 -2
View File
@@ -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
+7 -15
View File
@@ -164,20 +164,12 @@ jobs:
# 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
@@ -247,7 +239,7 @@ jobs:
# Strip the package-import prefix so we can match .coverage-allowlist.txt
# entries written as paths relative to workspace-server/.
# Handle both module paths: platform/workspace-server/... and platform/...
rel=$(echo "$file" | sed 's|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/workspace-server/||; s|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/||')
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
echo "::warning file=workspace-server/$rel::Critical file at ${pct}% coverage (allowlisted, #1823) — fix before expiry."
-242
View File
@@ -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: false
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
+4 -4
View File
@@ -152,7 +152,7 @@ jobs:
# block). See #2578 PR comment for the rationale.
E2E_ANTHROPIC_API_KEY: ${{ secrets.MOLECULE_STAGING_ANTHROPIC_API_KEY }}
# OpenAI fallback — kept wired so an operator-dispatched run with
# E2E_RUNTIME=hermes or =codex via workflow_dispatch can still
# E2E_RUNTIME=hermes or =langgraph via workflow_dispatch can still
# exercise the OpenAI path.
E2E_OPENAI_API_KEY: ${{ secrets.MOLECULE_STAGING_OPENAI_API_KEY }}
E2E_RUNTIME: ${{ github.event.inputs.runtime || 'claude-code' }}
@@ -161,7 +161,7 @@ jobs:
# and defeats the cost saving. Operators can override via the
# workflow_dispatch flow (no input wired here yet — runtime
# override is enough for ad-hoc).
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'codex' && 'openai/gpt-4o' || 'MiniMax-M2' }}
E2E_MODEL_SLUG: ${{ github.event.inputs.runtime == 'hermes' && 'openai/gpt-4o' || github.event.inputs.runtime == 'langgraph' && 'openai:gpt-4o' || 'MiniMax-M2' }}
E2E_RUN_ID: "${{ github.run_id }}-${{ github.run_attempt }}"
E2E_KEEP_ORG: ${{ github.event.inputs.keep_org && '1' || '0' }}
@@ -185,7 +185,7 @@ jobs:
- name: Verify LLM key present
run: |
# Per-runtime key check — claude-code uses MiniMax; hermes /
# codex (operator-dispatched only) use OpenAI. Hard-fail
# langgraph (operator-dispatched only) use OpenAI. Hard-fail
# rather than soft-skip per #2578's lesson — empty key
# silently falls through to the wrong SECRETS_JSON branch and
# produces a confusing auth error 5 min later instead of the
@@ -206,7 +206,7 @@ jobs:
required_secret_value=""
fi
;;
codex|hermes)
langgraph|hermes)
required_secret_name="MOLECULE_STAGING_OPENAI_API_KEY"
required_secret_value="${E2E_OPENAI_API_KEY:-}"
;;
+1 -20
View File
@@ -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
@@ -33,24 +32,6 @@ on:
# iterating all open PRs when PR_NUMBER is empty.
workflow_dispatch:
# Serialize per PR (or per repo for schedule/manual ticks) to prevent
# the fan-out OOM class documented in
# `reference_operator_host_python3_oom_storm_2026_05_18`. `edited`
# events fan out on every PR-body edit; combined with the hourly cron
# and synchronize bursts this workflow can stack runs of the same
# workflow_id on the same PR (each ~4GB anon-RSS) and trip the
# `--memory=4g --memory-swap=8g` per-container cap.
#
# NO `cancel-in-progress` (defaults to false). Per
# `feedback_janitor_supersede_must_group_by_workflow_id`, cancelling
# in-flight runs of any required-check-shaped workflow risks the
# dismiss_stale_approvals + empty-commit-rerun dance (Gitea 1.22.6 has
# no REST rerun). The gate-check is `continue-on-error: true` +
# idempotent (POST/PATCH gate-check comment by context) so sequential
# ticks are strictly safe.
concurrency:
group: gate-check-v3-${{ github.event.pull_request.number || github.event.issue.number || github.ref }}
permissions:
# read: contents — for checkout (base ref, not PR head for security)
# read: pull-requests — for reading PR info via API
@@ -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
@@ -239,13 +239,12 @@ jobs:
# 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' }}
+1 -1
View File
@@ -106,7 +106,7 @@ jobs:
[[ "$file" == *_test.go ]] && continue
[[ "$file" == *"$path"* ]] || continue
awk "BEGIN{exit !(\$pct < 10)}" || continue
rel=$(echo "$file" | sed 's|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/workspace-server/||; s|^git.moleculesai.app/molecule-ai/molecule-core/workspace-server/||')
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
continue
fi
-12
View File
@@ -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.
+3 -7
View File
@@ -15,11 +15,9 @@ test("FilesTab renders after split", async ({ page, request }) => {
// Clean slate
const { workspaces } = await request
.get("http://localhost:8080/workspaces")
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string; name: string }> }));
.then(async (r) => ({ workspaces: (await r.json()) as Array<{ id: string }> }));
for (const w of workspaces) {
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`, {
headers: { "X-Confirm-Name": w.name },
});
await request.delete(`http://localhost:8080/workspaces/${w.id}?confirm=true`);
}
// Create a workspace
@@ -82,7 +80,5 @@ test("FilesTab renders after split", async ({ page, request }) => {
await expect(editorEmpty.first()).toBeVisible({ timeout: 5_000 });
// Cleanup
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`, {
headers: { "X-Confirm-Name": "FilesTab Smoke" },
});
await request.delete(`http://localhost:8080/workspaces/${wsId}?confirm=true`);
});
-35
View File
@@ -1,35 +0,0 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
{
ignores: [
".next/**",
"coverage/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-require-imports": "warn",
"prefer-const": "warn",
"react-hooks/rules-of-hooks": "warn",
"react/display-name": "warn",
"react/no-unescaped-entities": "warn",
},
},
];
export default eslintConfig;
+1 -4330
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -6,7 +6,7 @@
"dev": "next dev --turbopack -p 3000",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"lint": "next lint",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
@@ -31,7 +31,6 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.0.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.6.0",
@@ -39,8 +38,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9.39.4",
"eslint-config-next": "^15.5.15",
"@tailwindcss/postcss": "^4.0.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.13",
"tailwindcss": "^4.0.0",
-6
View File
@@ -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'");
});
+1 -4
View File
@@ -232,10 +232,7 @@ function CanvasInner() {
}
state.beginDelete(subtree);
try {
const workspaceName = state.nodes.find((n) => n.id === id)?.data.name ?? "";
await api.del(`/workspaces/${id}?confirm=true`, {
headers: { "X-Confirm-Name": workspaceName },
});
await api.del(`/workspaces/${id}?confirm=true`);
// Mirror the server-side cascade locally — drop the parent AND
// every descendant in one atomic update. The per-descendant
// WORKSPACE_REMOVED WS events still arrive (and are no-ops
+1 -1
View File
@@ -128,7 +128,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex-1 overflow-auto bg-black/80 p-4">
{loading && (
<div role="status" aria-live="polite" className="text-[12px] text-ink-mid" data-testid="console-loading">
<div className="text-[12px] text-ink-mid" data-testid="console-loading">
Loading console output
</div>
)}
+244 -199
View File
@@ -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,48 @@ 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[];
}
const DEFAULT_CREATE_MODEL = "anthropic:claude-opus-4-7";
// 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"] },
];
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;
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("");
@@ -59,36 +71,41 @@ export function CreateWorkspaceButton() {
const [error, setError] = useState<string | null>(null);
const [workspaces, setWorkspaces] = useState<WorkspaceOption[]>([]);
const [displayEnabled, setDisplayEnabled] = useState(false);
const [displayInstanceType, setDisplayInstanceType] = useState(DEFAULT_DISPLAY_INSTANCE_TYPE);
const [displayRootGB, setDisplayRootGB] = useState(String(DEFAULT_DISPLAY_ROOT_GB));
const [displayInstanceType, setDisplayInstanceType] = useState("t3.xlarge");
const [displayRootGB, setDisplayRootGB] = useState("80");
const [displayResolution, setDisplayResolution] = useState("1920x1080");
// Templates fetched from /api/templates — drives the dynamic provider
// 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: [],
});
const [llmSecret, setLLMSecret] = useState("");
// 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("");
// Tier picker: on SaaS every workspace gets its own EC2 VM (Full Access
// by construction), so we hide the T1/T2/T3 Docker-sandbox tiers and
// lock to T4 — the full-host access tier. The EC2 size is controlled by
// the compute profile below. On self-hosted we still offer T1/T2/T3
// because the Docker-
// lock to T4 — the full-host access tier, which maps to t3.large at the
// CP level. On self-hosted we still offer T1/T2/T3 because the Docker-
// sandbox distinction is a real choice there; T4 is available too for
// operators who want the full-host tier.
//
@@ -138,65 +155,69 @@ export function CreateWorkspaceButton() {
[]
);
const handleRuntimeChange = useCallback((nextRuntime: string) => {
setRuntime(nextRuntime);
setTemplate("");
setLLMSelection({ providerId: "", model: "", envVars: [] });
setLLMSecret("");
}, []);
const isHermes = template.trim().toLowerCase() === "hermes";
// 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 (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 (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]);
// 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,18 +225,18 @@ export function CreateWorkspaceButton() {
setName("");
setRole("");
setTier(defaultTier);
setRuntime(DEFAULT_RUNTIME);
setTemplate("");
setParentId("");
setBudgetLimit("");
setError(null);
setDisplayEnabled(false);
setDisplayInstanceType(DEFAULT_DISPLAY_INSTANCE_TYPE);
setDisplayRootGB(String(DEFAULT_DISPLAY_ROOT_GB));
setDisplayInstanceType("t3.xlarge");
setDisplayRootGB("80");
setDisplayResolution("1920x1080");
setHermesProvider("anthropic");
setExternalRuntime("external");
setLLMSelection({ providerId: "", model: "", envVars: [] });
setLLMSecret("");
setHermesApiKey("");
setHermesModel("");
api
.get<WorkspaceOption[]>("/workspaces")
.then((ws) => setWorkspaces(ws))
@@ -223,7 +244,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 +255,20 @@ export function CreateWorkspaceButton() {
setError("Name is required");
return;
}
if (!isExternal && !llmSelection.model.trim()) {
setError("Model is required");
if (isHermes && !hermesApiKey.trim()) {
setError("API key is required for Hermes workspaces");
return;
}
if (!isExternal && selectedLLMProvider?.envVars.length && !llmSecret.trim()) {
setError("Provider credential is required");
if (isHermes && !hermesModel.trim()) {
setError("Model is required for Hermes workspaces — provider routing depends on the model slug prefix");
return;
}
setCreating(true);
setError(null);
const nativeProvider = selectedLLMProvider;
const provider = isHermes
? HERMES_PROVIDERS.find((p) => p.id === hermesProvider)
: undefined;
try {
const parsedBudget = budgetLimit.trim()
@@ -269,40 +292,32 @@ export function CreateWorkspaceButton() {
tier,
parent_id: parentId || undefined,
budget_limit: parsedBudget,
...(!isExternal && nativeProvider
...(!isExternal && !isHermes ? { model: DEFAULT_CREATE_MODEL } : {}),
...(displayEnabled
? {
model: llmSelection.model.trim(),
llm_provider: nativeProvider.vendor,
...(nativeProvider.envVars.length > 0
? { secrets: { [nativeProvider.envVars[0]]: llmSecret.trim() } }
: {}),
}
: {}),
...(!isExternal
? {
compute: displayEnabled
? {
instance_type: displayInstanceType,
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : DEFAULT_DISPLAY_ROOT_GB },
display: {
mode: "desktop-control",
protocol: "novnc",
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
},
}
: {
instance_type: DEFAULT_HEADLESS_INSTANCE_TYPE,
volume: { root_gb: DEFAULT_HEADLESS_ROOT_GB },
display: { mode: "none" },
},
compute: {
instance_type: displayInstanceType,
volume: { root_gb: Number.isFinite(parsedRootGB) ? parsedRootGB : 80 },
display: {
mode: "desktop-control",
protocol: "novnc",
width: Number.isFinite(displayWidth) ? displayWidth : 1920,
height: Number.isFinite(displayHeight) ? displayHeight : 1080,
},
},
}
: {}),
canvas: { x: Math.random() * 400 + 100, y: Math.random() * 300 + 100 },
// 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,76 +429,13 @@ 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>
)}
{!isExternal && selectedLLMProvider && (
<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-secret-input" className="text-[11px] text-ink-mid block mb-1">
{selectedLLMProvider.envVars[0]}
</label>
<input
id="llm-secret-input"
type="password"
value={llmSecret}
onChange={(e) => setLLMSecret(e.target.value)}
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-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors font-mono"
/>
</div>
)}
</div>
<InputField
label="Template"
value={template}
onChange={setTemplate}
placeholder="e.g. seo-agent (from workspace-configs-templates/)"
mono
/>
)}
<div>
@@ -590,11 +542,10 @@ export function CreateWorkspaceButton() {
)}
<div>
<label htmlFor="parent-workspace-select" className="text-[11px] text-ink-mid block mb-1">
<label className="text-[11px] text-ink-mid block mb-1">
Parent Workspace
</label>
<select
id="parent-workspace-select"
value={parentId}
onChange={(e) => setParentId(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"
@@ -609,6 +560,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"
+2 -2
View File
@@ -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));
}, []);
+18 -240
View File
@@ -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 ?? []);
@@ -242,13 +242,10 @@ export function ProvisioningTimeout({
const handleCancelConfirm = useCallback(async () => {
if (!confirmingCancel) return;
const workspaceId = confirmingCancel;
const workspaceName = timedOut.find((e) => e.workspaceId === workspaceId)?.workspaceName ?? "";
setConfirmingCancel(null);
setCancelling((prev) => new Set(prev).add(workspaceId));
try {
await api.del(`/workspaces/${workspaceId}`, {
headers: { "X-Confirm-Name": workspaceName },
});
await api.del(`/workspaces/${workspaceId}`);
setTimedOut((prev) => prev.filter((e) => e.workspaceId !== workspaceId));
trackingRef.current.delete(workspaceId);
showToast("Deployment cancelled", "info");
+1 -3
View File
@@ -305,9 +305,7 @@ export function SidePanel() {
{panelTab === "chat" && <ChatTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "terminal" && <TerminalTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />}
{panelTab === "display" && <DisplayTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "container-config" && selectedNodeId && (
<ContainerConfigTab key={selectedNodeId} workspaceId={selectedNodeId} data={node.data} />
)}
{panelTab === "container-config" && <ContainerConfigTab key={selectedNodeId} data={node.data} />}
{panelTab === "config" && <ConfigTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "schedule" && <ScheduleTab key={selectedNodeId} workspaceId={selectedNodeId} />}
{panelTab === "channels" && <ChannelsTab key={selectedNodeId} workspaceId={selectedNodeId} />}
+2 -2
View File
@@ -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 {
+2 -4
View File
@@ -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 } }
);
}
@@ -123,7 +63,7 @@ describe("CreateWorkspaceDialog", () => {
it('first option is "None (root level)" with empty value', async () => {
await openDialog();
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
expect(select).toBeTruthy();
const firstOption = select.options[0];
expect(firstOption.value).toBe("");
@@ -133,12 +73,12 @@ describe("CreateWorkspaceDialog", () => {
it("populates select with workspace names from GET /workspaces", async () => {
await openDialog();
await waitFor(() => {
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
const optionValues = Array.from(select.options).map((o) => o.value);
expect(optionValues).toContain("ws-1");
expect(optionValues).toContain("ws-2");
});
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
const optionTexts = Array.from(select.options).map((o) => o.text.trim());
expect(optionTexts.some((t) => t.includes("Platform Team"))).toBe(true);
expect(optionTexts.some((t) => t.includes("Research Agent"))).toBe(true);
@@ -147,7 +87,7 @@ describe("CreateWorkspaceDialog", () => {
it("sends parent_id in POST body when a workspace is selected", async () => {
await openDialog();
await waitFor(() => {
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
expect(select.options.length).toBeGreaterThan(1);
});
@@ -155,7 +95,7 @@ describe("CreateWorkspaceDialog", () => {
target: { value: "My Agent" },
});
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "ws-1" } });
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
@@ -172,7 +112,7 @@ describe("CreateWorkspaceDialog", () => {
target: { value: "Root Agent" },
});
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "" } });
const createBtn = screen.getAllByRole("button").find((b) => b.textContent === "Create");
@@ -183,7 +123,7 @@ describe("CreateWorkspaceDialog", () => {
expect(body.parent_id).toBeUndefined();
});
it("sends the cost-efficient headless compute profile by default", async () => {
it("omits compute config by default", async () => {
await openDialog();
fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), {
target: { value: "Plain Agent" },
@@ -192,54 +132,10 @@ describe("CreateWorkspaceDialog", () => {
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.compute).toEqual({
instance_type: "t3.medium",
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.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"), {
target: { value: "External Agent" },
});
fireEvent.click(screen.getByLabelText(/External agent/));
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.compute).toBeUndefined();
expect(body.runtime).toBe("external");
expect(body.model).toBe("anthropic:claude-opus-4-7");
});
it("sends display compute profile when desktop display is enabled", async () => {
@@ -254,8 +150,7 @@ 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("anthropic:claude-opus-4-7");
expect(body.compute).toEqual({
instance_type: "t3.xlarge",
volume: { root_gb: 80 },
@@ -268,72 +163,13 @@ describe("CreateWorkspaceDialog", () => {
});
});
it("sends BYOK API key secrets when API key auth mode is selected", async () => {
await openDialog();
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-secret-input") as HTMLInputElement, {
target: { value: "sk-minimax-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.model).toBe("MiniMax-M2.7");
expect(body.llm_provider).toBe("minimax");
expect(body.secrets).toEqual({ MINIMAX_API_KEY: "sk-minimax-test" });
});
it("sends Claude OAuth token separately from platform-managed mode", async () => {
await openDialog();
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-secret-input") as HTMLInputElement, {
target: { value: "oauth-token" },
});
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.model).toBe("sonnet");
expect(body.llm_provider).toBe("anthropic-oauth");
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();
// Dialog still renders; select exists with only the root option
await waitFor(() => {
const select = screen.getByLabelText("Parent Workspace") as HTMLSelectElement;
const select = document.querySelector("select") as HTMLSelectElement;
expect(select.options.length).toBe(1);
expect(select.options[0].value).toBe("");
});
@@ -341,103 +177,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 +409,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();
});
});
});
@@ -272,9 +272,7 @@ describe("OrgCancelButton — API interactions", () => {
fireEvent.click(screen.getByRole("button", { name: /cancel deployment of test org/i }));
fireEvent.click(screen.getByRole("button", { name: /yes/i }));
await act(async () => { /* flush */ });
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true", {
headers: { "X-Confirm-Name": "Test Org" },
});
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/root-1?confirm=true");
});
it("shows success toast on DELETE success", async () => {
@@ -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 />);
@@ -57,7 +57,6 @@ export function OrgCancelButton({ rootId, rootName, workspaceCount }: Props) {
try {
await api.del<{ status: string }>(
`/workspaces/${rootId}?confirm=true`,
{ headers: { "X-Confirm-Name": rootName } },
);
showToast(`Cancelled deployment of "${rootName}"`, "success");
// Optimistic local removal — workspace-server broadcasts
@@ -199,9 +199,7 @@ describe("OrgCancelButton — Yes / cascade delete", () => {
});
// 1) API call hit the cascade-delete endpoint with confirm=true
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/ws-root?confirm=true", {
headers: { "X-Confirm-Name": "My Org" },
});
expect(mockApiDel).toHaveBeenCalledWith("/workspaces/ws-root?confirm=true");
// 2) beginDelete locked the WHOLE subtree (root + 2 children) — NOT the unrelated node
expect(mockState.beginDelete).toHaveBeenCalledTimes(1);
@@ -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>
)}
+2 -2
View File
@@ -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>
+2 -2
View File
@@ -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>
)}
+8 -81
View File
@@ -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.
@@ -736,36 +701,6 @@ export function ConfigTab({ workspaceId }: Props) {
}
}
// Provider → billing_mode linkage (internal#703 Gap 2). When the
// provider actually changed AND its implied billing_mode differs
// from the previously-selected provider's, push the new mode to
// the per-tenant llm-billing-mode endpoint (same path the LLM
// Billing section uses). Without this, selecting a non-Platform
// provider leaves billing_mode=platform_managed → CP keeps
// injecting the platform proxy → BYOK never takes.
//
// Gated on (a) the provider PUT having succeeded — no point setting
// byok if the credential write failed — and (b) the mode actually
// changing, so an unrelated provider tweak between two BYOK vendors
// (e.g. minimax → openrouter) doesn't re-issue a redundant
// platform_managed→byok PUT and trigger a needless restart.
let billingModeSaveError: string | null = null;
if (providerChanged && !providerSaveError) {
const nextMode = billingModeForProvider(provider);
const prevMode = billingModeForProvider(originalProvider);
if (nextMode !== prevMode) {
try {
await api.put(
`/admin/workspaces/${workspaceId}/llm-billing-mode`,
{ mode: nextMode },
);
} catch (e) {
billingModeSaveError =
e instanceof Error ? e.message : "Billing mode update was rejected";
}
}
}
setOriginalYaml(content);
if (rawMode) {
const parsed = parseYaml(content);
@@ -785,22 +720,16 @@ export function ConfigTab({ workspaceId }: Props) {
} else if (!restart) {
useCanvasStore.getState().updateNodeData(workspaceId, { needsRestart: !providerWillAutoRestart });
}
// Aggregate partial-save errors. modelSaveError, providerSaveError,
// and billingModeSaveError 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). The billing-mode case
// is the most important to surface: the provider credential saved
// but BYOK won't actually take until billing_mode flips, so a
// silent failure here is exactly the #703 "selecting a provider has
// no effect" symptom.
// 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 {
@@ -1179,8 +1108,6 @@ export function ConfigTab({ workspaceId }: Props) {
</div>
</Section>
<LLMBillingSection workspaceId={workspaceId} />
<SecretsSection
workspaceId={workspaceId}
requiredEnv={config.runtime_config?.required_env}
+39 -246
View File
@@ -1,210 +1,46 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import { runtimeDisplayName } from "@/lib/runtime-names";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import type { WorkspaceCompute } from "@/store/socket";
const INSTANCE_TYPES = ["t3.medium", "t3.large", "t3.xlarge", "t3.2xlarge", "m6i.large", "m6i.xlarge", "c6i.xlarge"];
const RUNTIME_OPTIONS = ["claude-code", "codex", "hermes", "openclaw", "kimi", "kimi-cli", "external"];
const RESOLUTIONS = ["1280x720", "1440x900", "1920x1080", "2560x1440"];
const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium";
const DEFAULT_HEADLESS_ROOT_GB = 30;
import type { WorkspaceNodeData } from "@/store/canvas";
type Props = {
workspaceId: string;
data: Pick<
WorkspaceNodeData,
"runtime" | "status" | "needsRestart" | "activeTasks" | "deliveryMode"
| "workspaceAccess" | "maxConcurrentTasks" | "compute" | "applyTemplateOnRestart"
| "workspaceAccess" | "maxConcurrentTasks"
>;
};
type FormState = {
runtime: string;
instanceType: string;
rootGB: string;
displayEnabled: boolean;
displayMode: string;
displayProtocol: string;
resolution: string;
};
export function ContainerConfigTab({ workspaceId, data }: Props) {
const runtime = data.runtime;
const instanceType = data.compute?.instance_type;
const rootGB = data.compute?.volume?.root_gb;
const displayMode = data.compute?.display?.mode;
const displayProtocol = data.compute?.display?.protocol;
const displayWidth = data.compute?.display?.width;
const displayHeight = data.compute?.display?.height;
const initial = useMemo(
() => formFromData({ runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight }),
[runtime, instanceType, rootGB, displayMode, displayProtocol, displayWidth, displayHeight],
);
const [form, setForm] = useState<FormState>(initial);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
useEffect(() => {
setForm(initial);
setError(null);
setSuccess(false);
}, [initial]);
export function ContainerConfigTab({ data }: Props) {
const runtime = data.runtime || "unknown";
const workspaceAccess = formatAccess(data.workspaceAccess);
const maxConcurrentTasks = data.maxConcurrentTasks ? String(data.maxConcurrentTasks) : "platform-managed";
const mountedPath = "/workspace";
const privilegeStatus = "standard";
const deliveryMode = data.deliveryMode || "push";
const dirty = JSON.stringify(form) !== JSON.stringify(initial);
const restartLabel = dirty ? "Save & Restart" : "Restart to apply";
const resolutionOptions = RESOLUTIONS.includes(form.resolution)
? RESOLUTIONS
: [form.resolution, ...RESOLUTIONS];
const save = async (restart: boolean) => {
setError(null);
setSuccess(false);
setSaving(true);
try {
let applyTemplateOnRestart = data.applyTemplateOnRestart ?? false;
if (dirty) {
const rootGB = parseInt(form.rootGB, 10);
if (!Number.isFinite(rootGB)) {
setError("Root volume must be a number");
return;
}
const [width, height] = form.resolution.split("x").map((v) => parseInt(v, 10));
const compute: WorkspaceCompute = {
instance_type: form.instanceType,
volume: { root_gb: rootGB },
display: form.displayEnabled
? { mode: form.displayMode, protocol: form.displayProtocol, width, height }
: { mode: "none" },
};
const resp = await api.patch<{ needs_restart?: boolean }>(`/workspaces/${workspaceId}`, {
runtime: form.runtime,
compute,
});
useCanvasStore.getState().updateNodeData(workspaceId, {
runtime: form.runtime,
compute,
needsRestart: resp.needs_restart ?? true,
applyTemplateOnRestart: form.runtime !== initial.runtime,
});
applyTemplateOnRestart = form.runtime !== initial.runtime;
}
if (restart) {
await useCanvasStore.getState().restartWorkspace(workspaceId, {
applyTemplate: applyTemplateOnRestart,
});
}
setSuccess(true);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to save");
} finally {
setSaving(false);
}
};
return (
<div className="p-4 space-y-4">
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="mb-3">
<h3 className="text-sm font-semibold text-ink">Container Config</h3>
{data.needsRestart && <span className="text-[11px] text-warm">Restart required</span>}
</div>
<div className="grid grid-cols-1 gap-3 text-[11px]">
<SelectField
id="runtime-image-profile"
label="Runtime image"
value={form.runtime}
options={RUNTIME_OPTIONS}
optionLabel={runtimeDisplayName}
onChange={(runtime) => setForm((s) => ({ ...s, runtime }))}
/>
<SelectField
id="instance-type"
label="Instance type"
value={form.instanceType}
options={INSTANCE_TYPES}
onChange={(instanceType) => setForm((s) => ({ ...s, instanceType }))}
/>
<label className="grid gap-1" htmlFor="root-volume-gb">
<span className="text-ink-mid">Root volume</span>
<div className="flex items-center gap-2">
<input
id="root-volume-gb"
aria-label="Root volume"
type="number"
min={30}
max={500}
value={form.rootGB}
onChange={(e) => setForm((s) => ({ ...s, rootGB: e.target.value }))}
className="min-w-0 flex-1 rounded-md border border-line/60 bg-surface-sunken px-3 py-2 font-mono text-ink outline-none focus:border-accent"
/>
<span className="text-ink-mid">GB</span>
</div>
</label>
<label className="flex items-center justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
<span className="text-ink-mid">Display</span>
<input
type="checkbox"
aria-label="Enable display"
checked={form.displayEnabled}
onChange={(e) => setForm((s) => ({
...s,
displayEnabled: e.target.checked,
displayMode: e.target.checked && s.displayMode === "none" ? "desktop-control" : s.displayMode,
displayProtocol: e.target.checked && !s.displayProtocol ? "novnc" : s.displayProtocol,
}))}
className="h-4 w-4 accent-accent"
/>
</label>
{form.displayEnabled && (
<SelectField
id="display-resolution"
label="Resolution"
value={form.resolution}
options={resolutionOptions}
onChange={(resolution) => setForm((s) => ({ ...s, resolution }))}
/>
)}
</div>
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Runtime image" value={runtimeDisplayName(runtime)} detail={runtime} />
<ConfigRow label="Workspace access" value={workspaceAccess} />
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
<ConfigRow label="Mounted workspace path" value={mountedPath} />
<ConfigRow label="Container privileges" value={privilegeStatus} />
<ConfigRow label="Delivery mode" value={deliveryMode} />
</dl>
</section>
<div className="mt-4 flex items-center justify-end gap-2">
{error && <span className="mr-auto text-[11px] text-bad">{error}</span>}
{success && <span className="mr-auto text-[11px] text-good">Saved</span>}
<button
type="button"
disabled={!dirty || saving}
onClick={() => setForm(initial)}
className="rounded-md border border-line/60 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-50"
>
Reset
</button>
<button
type="button"
disabled={!dirty || saving}
onClick={() => save(false)}
className="rounded-md bg-accent px-3 py-2 text-[11px] font-medium text-white disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Saving..." : "Save"}
</button>
<button
type="button"
disabled={(!dirty && !data.needsRestart) || saving}
onClick={() => save(true)}
className="rounded-md bg-ink px-3 py-2 text-[11px] font-medium text-surface disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? "Restarting..." : restartLabel}
</button>
<section className="rounded-lg border border-line/50 bg-surface-card/40 p-4">
<h3 className="mb-3 text-sm font-semibold text-ink">Session Controls</h3>
<div className="grid grid-cols-2 gap-2">
<ReadOnlyAction label={data.needsRestart ? "Restart required" : "Restart"} />
<ReadOnlyAction label="Reset session" />
</div>
</section>
@@ -213,73 +49,13 @@ export function ContainerConfigTab({ workspaceId, data }: Props) {
<dl className="grid grid-cols-1 gap-2 text-[11px]">
<ConfigRow label="Container status" value={data.status} />
<ConfigRow label="Active tasks" value={String(data.activeTasks ?? 0)} />
<ConfigRow label="Workspace access" value={workspaceAccess} />
<ConfigRow label="Max concurrent tasks" value={maxConcurrentTasks} />
<ConfigRow label="Mounted workspace path" value="/workspace" />
<ConfigRow label="Delivery mode" value={deliveryMode} />
<ConfigRow label="Mounted path access" value="available" />
</dl>
</section>
</div>
);
}
function formFromData(data: {
runtime?: string;
instanceType?: string;
rootGB?: number;
displayMode?: string;
displayProtocol?: string;
displayWidth?: number;
displayHeight?: number;
}): FormState {
const width = data.displayWidth ?? 1920;
const height = data.displayHeight ?? 1080;
const resolution = `${width}x${height}`;
return {
runtime: data.runtime || "claude-code",
instanceType: data.instanceType || DEFAULT_HEADLESS_INSTANCE_TYPE,
rootGB: String(data.rootGB || DEFAULT_HEADLESS_ROOT_GB),
displayEnabled: !!data.displayMode && data.displayMode !== "none",
displayMode: data.displayMode && data.displayMode !== "none" ? data.displayMode : "desktop-control",
displayProtocol: data.displayProtocol || "novnc",
resolution,
};
}
function SelectField({
id,
label,
value,
options,
optionLabel = (v: string) => v,
onChange,
}: {
id: string;
label: string;
value: string;
options: string[];
optionLabel?: (value: string) => string;
onChange: (value: string) => void;
}) {
return (
<label className="grid gap-1" htmlFor={id}>
<span className="text-ink-mid">{label}</span>
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
className="rounded-md border border-line/60 bg-surface-sunken px-3 py-2 font-mono text-ink outline-none focus:border-accent"
>
{options.map((option) => (
<option key={option} value={option}>
{optionLabel(option)}
</option>
))}
</select>
</label>
);
}
function formatAccess(value: string | null | undefined): string {
if (!value) return "none";
return value.replace(/_/g, "-");
@@ -288,16 +64,33 @@ function formatAccess(value: string | null | undefined): string {
function ConfigRow({
label,
value,
detail,
}: {
label: string;
value: string;
detail?: string;
}) {
return (
<div className="flex items-start justify-between gap-3 rounded-md bg-surface-sunken/40 px-3 py-2">
<dt className="text-ink-mid">{label}</dt>
<dd className="min-w-0 text-right">
<div className="font-mono text-ink break-words">{value}</div>
{detail && detail !== value && (
<div className="mt-0.5 font-mono text-[10px] text-ink-mid break-words">{detail}</div>
)}
</dd>
</div>
);
}
function ReadOnlyAction({ label }: { label: string }) {
return (
<button
type="button"
disabled
className="rounded-md border border-line/50 bg-surface-sunken/40 px-3 py-2 text-[11px] text-ink-mid disabled:cursor-not-allowed disabled:opacity-70"
>
{label}
</button>
);
}
+1 -3
View File
@@ -93,9 +93,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
const handleDelete = async () => {
setDeleteError(null);
try {
await api.del(`/workspaces/${workspaceId}?confirm=true`, {
headers: { "X-Confirm-Name": name },
});
await api.del(`/workspaces/${workspaceId}?confirm=true`);
// Mirror the server-side cascade — drop the row + every
// descendant locally so the canvas reflects the deletion
// immediately, even when the WS is dead and the per-descendant
+4 -105
View File
@@ -265,11 +265,6 @@ function DisplayControlBar({
onAcquire: () => void;
onRelease: () => void;
}) {
const userControl = control?.controller === "user";
const adminControl = userControl && control?.controlled_by === "admin-token";
const canAcquireUserControl = control?.controller === "none" || (userControl && !hasSession);
const canReleaseUserControl = adminControl || (userControl && hasSession);
return (
<div className="flex min-w-0 items-center gap-3">
{control && (
@@ -287,7 +282,8 @@ function DisplayControlBar({
{controlError && <p className="mt-0.5 text-[10px] text-red-200">{controlError}</p>}
</div>
)}
{canAcquireUserControl && (
{(control?.controller === "none" ||
(control?.controller === "user" && control.controlled_by === "admin-token" && !hasSession)) && (
<button
type="button"
onClick={onAcquire}
@@ -297,7 +293,7 @@ function DisplayControlBar({
Take control
</button>
)}
{canReleaseUserControl && (
{control?.controller === "user" && control.controlled_by === "admin-token" && (
<button
type="button"
onClick={onRelease}
@@ -313,21 +309,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 +324,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 +339,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 +355,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") ?? "";
@@ -67,7 +67,7 @@ export function FileEditor({
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div aria-hidden="true" className="text-2xl opacity-20 mb-2">📄</div>
<div className="text-2xl opacity-20 mb-2">📄</div>
<p className="text-[10px] text-ink-mid">Select a file to edit</p>
</div>
</div>
@@ -79,16 +79,16 @@ export function FileEditor({
{/* File header */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/20">
<div className="flex items-center gap-1.5 min-w-0">
<span aria-hidden="true" className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
<span className="text-[10px] opacity-50">{getIcon(selectedFile, false)}</span>
<span className="text-[10px] font-mono text-ink-mid truncate">{selectedFile}</span>
{isDirty && <span className="text-[9px] text-warm">modified</span>}
</div>
<div className="flex items-center gap-2">
{success && <span role="status" aria-live="polite" className="text-[9px] text-good">{success}</span>}
{success && <span className="text-[9px] text-good">{success}</span>}
<button
onClick={onDownload}
aria-label="Download file"
className="text-[10px] text-ink-mid hover:text-ink-mid focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] text-ink-mid hover:text-ink-mid"
>
</button>
@@ -96,7 +96,7 @@ export function FileEditor({
<button
onClick={onSave}
disabled={!isDirty || saving}
className="text-[10px] text-accent hover:text-accent disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
className="text-[10px] text-accent hover:text-accent disabled:opacity-30"
>
{saving ? "Saving..." : "Save"}
</button>
@@ -166,7 +166,6 @@ export function FileEditor({
}
}}
spellCheck={false}
aria-label="File content editor"
className="flex-1 w-full bg-surface p-3 text-[11px] font-mono text-ink leading-relaxed resize-none focus:outline-none"
style={{ tabSize: 2 }}
/>
@@ -29,8 +29,8 @@ afterEach(() => {
const defaultProps = {
selectedFile: "/configs/agent.yaml",
fileContent: "name: test\nruntime: claude-code",
editContent: "name: test\nruntime: claude-code",
fileContent: "name: test\nruntime: langgraph",
editContent: "name: test\nruntime: langgraph",
setEditContent: vi.fn(),
loadingFile: false,
saving: false,
@@ -197,12 +197,12 @@ describe("FileEditor — textarea", () => {
render(
<FileEditor
{...defaultProps}
editContent="runtime: claude-code"
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
expect(ta).toBeTruthy();
expect(ta?.value).toBe("runtime: claude-code");
expect(ta?.value).toBe("runtime: langgraph");
});
it("textarea is readOnly when root is not /configs", () => {
@@ -210,7 +210,7 @@ describe("FileEditor — textarea", () => {
<FileEditor
{...defaultProps}
root="/workspace"
editContent="runtime: claude-code"
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
@@ -222,7 +222,7 @@ describe("FileEditor — textarea", () => {
<FileEditor
{...defaultProps}
root="/configs"
editContent="runtime: claude-code"
editContent="runtime: langgraph"
/>,
);
const ta = document.querySelector("textarea");
@@ -78,11 +78,11 @@ describe("walkEntry — file entry", () => {
});
it("populates the File object with correct content", async () => {
const { entry, file } = makeFile("config.yaml", "runtime: claude-code");
const { entry, file } = makeFile("config.yaml", "runtime: langgraph");
const out: CollectedEntry[] = [];
await walkEntry(entry as never, "", out);
expect(out[0]!.file).toBe(file);
expect(await out[0]!.file.text()).toBe("runtime: claude-code");
expect(await out[0]!.file.text()).toBe("runtime: langgraph");
});
it("appends to existing entries array (non-destructive)", async () => {
+1 -1
View File
@@ -32,7 +32,7 @@ interface PluginInfo {
author: string;
tags: string[];
skills: string[];
// Declared supported runtimes (e.g. ["claude_code", "hermes"]).
// Declared supported runtimes (e.g. ["claude_code", "deepagents"]).
// Empty / absent = "unspecified, try it".
runtimes?: string[];
// Only present on /workspaces/:id/plugins responses — true if the
@@ -1,255 +0,0 @@
// @vitest-environment jsdom
//
// Tests for the provider → llm_billing_mode linkage (internal#703 Gap 2).
//
// What this pins: when the operator changes the PROVIDER in the Config
// tab, the workspace's llm_billing_mode must follow — a non-Platform
// provider sets billing_mode=byok; Platform sets platform_managed. Before
// this wiring, selecting "Claude Code subscription (OAuth)" or any vendor
// key wrote the credential env but left billing_mode=platform_managed, so
// CP kept injecting the platform proxy base URL and the OAuth token /
// vendor key was never used — BYOK silently no-op'd (the live jrs-auto
// SEO-Agent symptom in #703).
//
// The billing-mode PUT targets the same per-tenant endpoint the LLM
// Billing section uses: PUT /admin/workspaces/:id/llm-billing-mode with
// body {mode: "byok" | "platform_managed"}.
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
import React from "react";
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(),
},
}));
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, billingModeForProvider } from "../ConfigTab";
function wireApi(opts: { providerValue?: string | "missing" }) {
apiGet.mockImplementation((path: string) => {
if (path === `/workspaces/ws-test`) {
return Promise.resolve({ runtime: "hermes" });
}
if (path === `/workspaces/ws-test/model`) {
return Promise.resolve({ model: "nousresearch/hermes-4-70b" });
}
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`) {
return Promise.resolve({ content: "name: ws\nruntime: hermes\n" });
}
if (path === "/templates") return Promise.resolve([]);
return Promise.reject(new Error(`unmocked api.get: ${path}`));
});
}
function billingModeCalls() {
return apiPut.mock.calls.filter(
([path]) => path === "/admin/workspaces/ws-test/llm-billing-mode",
);
}
beforeEach(() => {
apiGet.mockReset();
apiPatch.mockReset();
apiPut.mockReset();
storeUpdateNodeData.mockReset();
storeRestartWorkspace.mockReset();
});
describe("billingModeForProvider — pure mapping (internal#703 Gap 2)", () => {
// Platform / empty → platform_managed. Empty means "no explicit
// override → inherit", which resolves to platform on the backend, so
// it must NOT flip the workspace into byok.
it("maps Platform and empty to platform_managed", () => {
expect(billingModeForProvider("platform")).toBe("platform_managed");
expect(billingModeForProvider("")).toBe("platform_managed");
expect(billingModeForProvider(" ")).toBe("platform_managed");
expect(billingModeForProvider("PLATFORM")).toBe("platform_managed");
});
// Every non-Platform provider → byok. If this regresses to returning
// platform_managed for a vendor, BYOK silently no-ops again (#703).
it("maps non-Platform providers to byok", () => {
expect(billingModeForProvider("anthropic-oauth")).toBe("byok"); // Claude Code subscription
expect(billingModeForProvider("anthropic")).toBe("byok"); // Anthropic API key
expect(billingModeForProvider("minimax")).toBe("byok");
expect(billingModeForProvider("openrouter")).toBe("byok");
expect(billingModeForProvider("openai")).toBe("byok");
});
});
describe("ConfigTab — provider change drives billing_mode (internal#703 Gap 2)", () => {
// The core fix: picking a non-Platform provider (here "anthropic-oauth"
// = Claude Code subscription OAuth) from a fresh/empty provider must
// PUT mode=byok to the per-tenant llm-billing-mode endpoint. This is
// the exact path that was missing — the credential env saved but the
// billing mode never followed, so the proxy stayed engaged.
it("PUTs mode=byok when switching to a non-Platform provider", async () => {
wireApi({ providerValue: "" });
apiPut.mockResolvedValue({ status: "saved" });
render(<ConfigTab workspaceId="ws-test" />);
const input = await screen.findByTestId("provider-input");
fireEvent.change(input, { target: { value: "anthropic-oauth" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
const calls = billingModeCalls();
expect(calls.length).toBe(1);
expect(calls[0][1]).toEqual({ mode: "byok" });
});
// Provider credential PUT still happens too (independent endpoint).
expect(
apiPut.mock.calls.some(([path]) => path === "/workspaces/ws-test/provider"),
).toBe(true);
});
// Switching FROM a byok provider back TO Platform must PUT
// mode=platform_managed so the workspace re-engages the proxy and stops
// expecting a (now-absent) vendor key.
it("PUTs mode=platform_managed when switching back to Platform", async () => {
wireApi({ providerValue: "anthropic-oauth" });
apiPut.mockResolvedValue({ status: "saved" });
render(<ConfigTab workspaceId="ws-test" />);
const input = await screen.findByTestId("provider-input");
await waitFor(() => expect((input as HTMLInputElement).value).toBe("anthropic-oauth"));
fireEvent.change(input, { target: { value: "platform" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
const calls = billingModeCalls();
expect(calls.length).toBe(1);
expect(calls[0][1]).toEqual({ mode: "platform_managed" });
});
});
// Changing between two BYOK vendors (minimax → openrouter) keeps
// billing_mode=byok — the implied mode is unchanged, so re-PUTing it
// would be a wasteful no-op that risks an extra restart. Must NOT fire.
it("does NOT PUT billing-mode when the implied mode is unchanged", async () => {
wireApi({ providerValue: "minimax" });
apiPut.mockResolvedValue({ status: "saved" });
render(<ConfigTab workspaceId="ws-test" />);
const input = await screen.findByTestId("provider-input");
await waitFor(() => expect((input as HTMLInputElement).value).toBe("minimax"));
fireEvent.change(input, { target: { value: "openrouter" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
// Provider PUT fires (vendor changed)...
expect(
apiPut.mock.calls.some(([path]) => path === "/workspaces/ws-test/provider"),
).toBe(true);
});
// ...but billing-mode does NOT (byok → byok is a no-op).
expect(billingModeCalls().length).toBe(0);
});
// A Save that doesn't touch the provider must not PUT billing-mode —
// editing tier/name shouldn't disturb the workspace's billing mode.
it("does NOT PUT billing-mode on a Save that leaves provider unchanged", async () => {
wireApi({ providerValue: "anthropic-oauth" });
apiPut.mockResolvedValue({ status: "saved" });
render(<ConfigTab workspaceId="ws-test" />);
await screen.findByTestId("provider-input");
// Dirty an unrelated field so Save is enabled.
const tierSelect = screen.getByLabelText(/tier/i) as HTMLSelectElement;
fireEvent.change(tierSelect, { target: { value: "3" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
// Some PUT may fire (e.g. /model); just assert billing-mode did not.
expect(billingModeCalls().length).toBe(0);
});
});
// If the provider credential PUT itself fails, we must NOT set byok —
// flipping billing_mode while the credential write failed would leave
// the workspace expecting a key it doesn't have (worse than no-op).
it("does NOT PUT billing-mode when the provider PUT fails", async () => {
wireApi({ providerValue: "" });
apiPut.mockImplementation((path: string) => {
if (path === "/workspaces/ws-test/provider") return Promise.reject(new Error("boom"));
return Promise.resolve({ status: "saved" });
});
render(<ConfigTab workspaceId="ws-test" />);
const input = await screen.findByTestId("provider-input");
fireEvent.change(input, { target: { value: "anthropic-oauth" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
// The provider-failure error is surfaced (getByText throws if absent).
expect(screen.getByText(/provider update failed/i)).toBeTruthy();
});
expect(billingModeCalls().length).toBe(0);
});
// If the credential saved but the billing-mode PUT is rejected, the
// user must be warned that BYOK may not take — a silent failure here
// is precisely the #703 symptom we're fixing.
it("surfaces an error when billing-mode PUT fails after a successful provider save", async () => {
wireApi({ providerValue: "" });
apiPut.mockImplementation((path: string) => {
if (path === "/admin/workspaces/ws-test/llm-billing-mode") {
return Promise.reject(new Error("403 forbidden"));
}
return Promise.resolve({ status: "saved" });
});
render(<ConfigTab workspaceId="ws-test" />);
const input = await screen.findByTestId("provider-input");
fireEvent.change(input, { target: { value: "anthropic-oauth" } });
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => {
expect(screen.getByText(/switching billing mode failed/i)).toBeTruthy();
});
});
});
@@ -3,10 +3,10 @@
// Regression tests for ConfigTab hermes-workspace UX (#1894 + #1900).
//
// All four bugs this suite pins hit the same workspace on 2026-04-23:
// a hermes-runtime workspace whose Config tab showed "Claude Code
// a hermes-runtime workspace whose Config tab showed "LangGraph
// (default)" in the runtime dropdown, an empty Model field, and a
// scary red "No config.yaml found" banner. Clicking Save would
// silently PATCH runtime back to Claude Code, breaking the workspace.
// silently PATCH runtime back to LangGraph, breaking the workspace.
//
// Each test pins one invariant. If any fails, the bug is back.
@@ -91,7 +91,7 @@ describe("ConfigTab — hermes workspace", () => {
it("loads runtime from workspace metadata when config.yaml is missing (#1894 bug 1)", async () => {
// This is the hermes case: no platform config.yaml, so the form must
// fall back to GET /workspaces/:id's runtime field. Before the fix, the
// runtime dropdown showed "Claude Code (default)" because the fallback
// runtime dropdown showed "LangGraph (default)" because the fallback
// didn't exist.
wireApi({
workspaceRuntime: "hermes",
@@ -150,9 +150,9 @@ describe("ConfigTab — hermes workspace", () => {
expect(screen.queryByText(/Hermes manages its own config/i)).toBeNull();
});
it("DOES show 'No config.yaml found' error for claude-code workspace (default runtime)", async () => {
it("DOES show 'No config.yaml found' error for langgraph workspace (default runtime)", async () => {
// Regression guard the other way — the gray info banner is hermes-
// specific. A claude-code workspace with no config.yaml SHOULD still
// specific. A langgraph workspace with no config.yaml SHOULD still
// see the red error so the user knows to provide a template config.
wireApi({
workspaceRuntime: "",
@@ -302,21 +302,21 @@ describe("ConfigTab — config.yaml on disk", () => {
// MCP server list, etc.) but runtime/model/tier come from the
// workspace row so the node badge matches the form.
//
// Scenario: DB says "hermes", config.yaml says "openclaw". The form
// Scenario: DB says "hermes", config.yaml says "crewai". The form
// must show hermes (DB wins).
//
// We pick hermes (not claude-code) on the DB side because "claude-code"
// is collapsed to the empty-string "Claude Code (default)" option in
// the runtime dropdown — so a "claude-code" DB value would render as
// We pick hermes (not langgraph) on the DB side because "langgraph"
// is collapsed to the empty-string "LangGraph (default)" option in
// the runtime dropdown — so a "langgraph" DB value would render as
// the empty-valued option and obscure whether the DB-wins logic
// actually fired. Hermes has its own non-empty option value and
// gives the assertion a clean signal.
wireApi({
workspaceRuntime: "hermes", // DB — authoritative
configYamlContent: 'runtime: openclaw\nmodel: "claude-opus"\n',
configYamlContent: 'runtime: crewai\nmodel: "claude-opus"\n',
templates: [
{ id: "t-hermes", name: "Hermes", runtime: "hermes", models: [] },
{ id: "t-openclaw", name: "OpenClaw", runtime: "openclaw", models: [] },
{ id: "t-crewai", name: "CrewAI", runtime: "crewai", models: [] },
],
});
@@ -1,66 +1,21 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const apiPatch = vi.fn();
const updateNodeData = vi.fn();
const restartWorkspace = vi.fn();
vi.mock("@/lib/api", () => ({
api: {
patch: (path: string, body: unknown) => apiPatch(path, body),
},
}));
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/runtime-names", () => ({
runtimeDisplayName: (runtime: string) => runtime,
}));
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(selector: (s: unknown) => unknown) => selector({ restartWorkspace, updateNodeData }),
{ getState: () => ({ restartWorkspace, updateNodeData }) },
),
}));
import { ContainerConfigTab } from "../ContainerConfigTab";
afterEach(() => {
cleanup();
});
beforeEach(() => {
apiPatch.mockReset();
restartWorkspace.mockReset();
updateNodeData.mockReset();
});
describe("ContainerConfigTab", () => {
it("defaults missing compute to the cost-efficient headless profile", () => {
it("renders read-only runtime and container settings separate from compute shape", () => {
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: undefined,
}}
/>,
);
expect(screen.getByLabelText("Instance type")).toHaveProperty("value", "t3.medium");
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "30");
});
it("renders persisted compute and status settings", () => {
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
@@ -69,249 +24,19 @@ describe("ContainerConfigTab", () => {
maxConcurrentTasks: 3,
workspaceAccess: "read_write",
deliveryMode: "poll",
compute: {
instance_type: "t3.xlarge",
volume: { root_gb: 80 },
display: { mode: "desktop-control", protocol: "novnc", width: 1920, height: 1080 },
},
}}
/>,
);
expect(screen.getByLabelText("Runtime image")).toHaveProperty("value", "claude-code");
expect(screen.getByLabelText("Instance type")).toHaveProperty("value", "t3.xlarge");
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "80");
expect(screen.getByLabelText("Enable display")).toHaveProperty("checked", true);
expect(screen.getByLabelText("Resolution")).toHaveProperty("value", "1920x1080");
expect(screen.getByText("Runtime image")).toBeTruthy();
expect(screen.getByText("claude-code")).toBeTruthy();
expect(screen.getByText("Workspace access")).toBeTruthy();
expect(screen.getByText("read-write")).toBeTruthy();
});
it("does not reset dirty form edits on unrelated status rerender", () => {
const { rerender } = render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.change(screen.getByLabelText("Root volume"), { target: { value: "120" } });
rerender(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 1,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
expect(screen.getByLabelText("Root volume")).toHaveProperty("value", "120");
});
it("saves runtime and compute changes through workspace PATCH", async () => {
apiPatch.mockResolvedValueOnce({ needs_restart: true });
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.change(screen.getByLabelText("Runtime image"), { target: { value: "hermes" } });
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "m6i.xlarge" } });
fireEvent.change(screen.getByLabelText("Root volume"), { target: { value: "100" } });
fireEvent.click(screen.getByLabelText("Enable display"));
fireEvent.change(screen.getByLabelText("Resolution"), { target: { value: "2560x1440" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-compute", {
runtime: "hermes",
compute: {
instance_type: "m6i.xlarge",
volume: { root_gb: 100 },
display: { mode: "desktop-control", protocol: "novnc", width: 2560, height: 1440 },
},
});
expect(updateNodeData).toHaveBeenCalledWith("ws-compute", {
runtime: "hermes",
compute: {
instance_type: "m6i.xlarge",
volume: { root_gb: 100 },
display: { mode: "desktop-control", protocol: "novnc", width: 2560, height: 1440 },
},
needsRestart: true,
applyTemplateOnRestart: true,
});
expect(restartWorkspace).not.toHaveBeenCalled();
});
it("preserves existing custom display mode and resolution when saving unrelated compute", async () => {
apiPatch.mockResolvedValueOnce({ needs_restart: true });
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "gpu-desktop-control", protocol: "dcv", width: 1600, height: 1000 },
},
}}
/>,
);
expect(screen.getByLabelText("Resolution")).toHaveProperty("value", "1600x1000");
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "t3.xlarge" } });
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-compute", {
runtime: "claude-code",
compute: {
instance_type: "t3.xlarge",
volume: { root_gb: 50 },
display: { mode: "gpu-desktop-control", protocol: "dcv", width: 1600, height: 1000 },
},
});
});
it("can save changed compute and restart the workspace to apply it", async () => {
apiPatch.mockResolvedValueOnce({ needs_restart: true });
restartWorkspace.mockResolvedValueOnce(undefined);
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.change(screen.getByLabelText("Instance type"), { target: { value: "t3.xlarge" } });
fireEvent.click(screen.getByRole("button", { name: "Save & Restart" }));
await waitFor(() => expect(apiPatch).toHaveBeenCalledTimes(1));
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: false }));
});
it("requests template re-apply when saving a runtime change and restarting", async () => {
apiPatch.mockResolvedValueOnce({ needs_restart: true });
restartWorkspace.mockResolvedValueOnce(undefined);
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: false,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.change(screen.getByLabelText("Runtime image"), { target: { value: "hermes" } });
fireEvent.click(screen.getByRole("button", { name: "Save & Restart" }));
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: true }));
});
it("can restart without re-saving when changes are already pending", async () => {
restartWorkspace.mockResolvedValueOnce(undefined);
render(
<ContainerConfigTab
workspaceId="ws-compute"
data={{
runtime: "claude-code",
status: "online",
needsRestart: true,
activeTasks: 0,
maxConcurrentTasks: null,
workspaceAccess: "none",
deliveryMode: "push",
applyTemplateOnRestart: true,
compute: {
instance_type: "t3.large",
volume: { root_gb: 50 },
display: { mode: "none" },
},
}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Restart to apply" }));
await waitFor(() => expect(restartWorkspace).toHaveBeenCalledWith("ws-compute", { applyTemplate: true }));
expect(apiPatch).not.toHaveBeenCalled();
expect(screen.getByText("Max concurrent tasks")).toBeTruthy();
expect(screen.getByText("3")).toBeTruthy();
expect(screen.getByText("/workspace")).toBeTruthy();
expect(screen.getByText("Container privileges")).toBeTruthy();
expect(screen.queryByText("Instance type")).toBeNull();
expect(screen.queryByText("Root volume")).toBeNull();
});
});
@@ -290,9 +290,7 @@ describe("DetailsTab — delete workflow", () => {
) as HTMLButtonElement;
fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
await flush();
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true", {
headers: { "X-Confirm-Name": "Test Workspace" },
});
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1");
expect(mockSelectNode).toHaveBeenCalledWith(null);
});
@@ -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" });
@@ -149,30 +143,46 @@ afterEach(() => {
// ── Tests ────────────────────────────────────────────────────────────────────
/**
* Drive the always-show-picker flow to completion: deploy() opens the
* modal, then we click "keys added" to fire the actual POST. Centralised
* here because as of the always-prompt change, every happy-path test
* must click through the modal before asserting on POST.
*/
async function deployThroughPicker<T>(
result: { current: ReturnType<typeof useTemplateDeploy> },
rerender: () => void,
template: Template,
): Promise<void> {
await act(async () => {
await result.current.deploy(template);
});
rerender();
render(<>{result.current.modal}</>);
await act(async () => {
fireEvent.click(screen.getByTestId("modal-keys-added"));
// Let the fire-and-forget executeDeploy resolve.
await Promise.resolve();
await Promise.resolve();
});
}
describe("useTemplateDeploy — happy path", () => {
it("preflight ok with no key requirements → POST /workspaces directly → onDeployed fires", async () => {
it("preflight ok → modal opens → keys-added → POST /workspaces → onDeployed fires", async () => {
const onDeployed = vi.fn();
const { result } = renderHook(() =>
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
await act(async () => {
await result.current.deploy(makeTemplate({
id: "seo-agent",
name: "SEO Agent",
model: "MiniMax-M2.7",
}));
});
await deployThroughPicker(result, rerender, makeTemplate());
expect(mockCheckDeploySecrets).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith(
"/workspaces",
expect.objectContaining({
name: "SEO Agent",
template: "seo-agent",
name: "Claude Code",
template: "claude-code-default",
tier: 1,
model: "MiniMax-M2.7",
llm_provider: "minimax",
}),
);
expect(onDeployed).toHaveBeenCalledWith("ws-new");
@@ -182,13 +192,11 @@ describe("useTemplateDeploy — happy path", () => {
it("uses caller-supplied canvasCoords when provided", async () => {
const canvasCoords = vi.fn(() => ({ x: 42, y: 99 }));
const { result } = renderHook(() =>
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ canvasCoords }),
);
await act(async () => {
await result.current.deploy(makeTemplate());
});
await deployThroughPicker(result, rerender, makeTemplate());
expect(canvasCoords).toHaveBeenCalledTimes(1);
expect(mockApiPost).toHaveBeenCalledWith(
@@ -198,11 +206,9 @@ describe("useTemplateDeploy — happy path", () => {
});
it("falls back to random coords inside [100,500] × [100,400] when canvasCoords omitted", async () => {
const { result } = renderHook(() => useTemplateDeploy());
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate());
});
await deployThroughPicker(result, rerender, makeTemplate());
const body = (mockApiPost as Mock).mock.calls[0]?.[1] as {
canvas: { x: number; y: number };
@@ -249,7 +255,6 @@ describe("useTemplateDeploy — preflight failure modes", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -278,7 +283,6 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const onDeployed = vi.fn();
@@ -314,7 +318,6 @@ describe("useTemplateDeploy — modal lifecycle", () => {
missingKeys: ["ANTHROPIC_API_KEY"],
providers: [],
runtime: "claude-code",
optionalKeys: [],
configuredKeys: new Set(),
});
const { result, rerender } = renderHook(() => useTemplateDeploy());
@@ -368,7 +371,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 +404,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 +432,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());
@@ -458,9 +458,16 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
);
});
it("template with no provider requirements deploys directly on platform-managed defaults", async () => {
it("single-provider template ALSO opens picker when preflight.ok (always-prompt rule)", async () => {
// Default preflight mock: ok=true, providers=[]. claude-code is
// single-provider, but the always-prompt rule means the user must
// still click through the picker to confirm provider+model — even
// when keys are saved and the runtime has only one provider option.
// Reason: the user needs an explicit chance to override the
// template's default model (e.g. opus vs sonnet vs haiku) before
// an EC2 boots and burns billing on the wrong tier.
const onDeployed = vi.fn();
const { result } = renderHook(() =>
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
@@ -468,18 +475,13 @@ describe("useTemplateDeploy — multi-provider always-ask flow", () => {
await result.current.deploy(makeTemplate());
});
rerender();
render(<>{result.current.modal}</>);
expect(screen.queryByTestId("missing-keys-modal")).toBeNull();
expect(mockApiPost).toHaveBeenCalledWith(
"/workspaces",
expect.objectContaining({
template: "claude-code-default",
model: "claude-sonnet-4-5",
llm_provider: "anthropic",
}),
);
expect(onDeployed).toHaveBeenCalledWith("ws-new");
expect(screen.getByTestId("missing-keys-modal")).toBeTruthy();
// POST does NOT fire until the user confirms in the picker.
expect(mockApiPost).not.toHaveBeenCalled();
expect(onDeployed).not.toHaveBeenCalled();
expect(result.current.deploying).toBeNull();
});
@@ -496,7 +498,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,48 +513,17 @@ 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", () => {
it("POST rejection sets error and clears deploying", async () => {
mockApiPost.mockRejectedValueOnce(new Error("server 500"));
const onDeployed = vi.fn();
const { result } = renderHook(() =>
const { result, rerender } = renderHook(() =>
useTemplateDeploy({ onDeployed }),
);
await act(async () => {
await result.current.deploy(makeTemplate());
});
await deployThroughPicker(result, rerender, makeTemplate());
expect(result.current.error).toBe("server 500");
expect(result.current.deploying).toBeNull();
@@ -562,11 +532,9 @@ describe("useTemplateDeploy — POST failure", () => {
it("non-Error rejection still surfaces a message (defensive)", async () => {
mockApiPost.mockRejectedValueOnce("plain string");
const { result } = renderHook(() => useTemplateDeploy());
const { result, rerender } = renderHook(() => useTemplateDeploy());
await act(async () => {
await result.current.deploy(makeTemplate());
});
await deployThroughPicker(result, rerender, makeTemplate());
expect(result.current.error).toBe("Deploy failed");
expect(result.current.deploying).toBeNull();
@@ -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;
+4 -36
View File
@@ -55,22 +55,6 @@ interface MissingKeysInfo {
preflight: PreflightResult;
}
function nativeProviderForClaudeCodeModel(model: string): string | undefined {
const trimmed = model.trim();
const lower = trimmed.toLowerCase();
if (!trimmed) return undefined;
if (lower.startsWith("minimax")) return "minimax";
if (lower.startsWith("kimi")) return "kimi-coding";
if (lower.startsWith("claude")) return "anthropic";
if (/^(sonnet|opus|haiku)$/.test(lower)) return "anthropic-oauth";
return undefined;
}
function isNativeClaudeCodeRuntime(template: Template): boolean {
const runtime = template.runtime ?? resolveRuntime(template.id);
return runtime === "claude-code";
}
export interface UseTemplateDeployResult {
/** Template id currently being deployed (incl. the preflight
* network call), or null when idle. Callers pass this to disable
@@ -113,10 +97,6 @@ export function useTemplateDeploy(
setDeploying(template.id);
setError(null);
try {
const selectedModel = model?.trim() || template.model?.trim();
const nativeProvider = isNativeClaudeCodeRuntime(template) && selectedModel
? nativeProviderForClaudeCodeModel(selectedModel)
: undefined;
const coords = canvasCoords
? canvasCoords()
: {
@@ -128,8 +108,7 @@ export function useTemplateDeploy(
template: template.id,
tier: isSaaSTenant() ? 4 : template.tier,
canvas: coords,
...(selectedModel ? { model: selectedModel } : {}),
...(nativeProvider ? { llm_provider: nativeProvider } : {}),
...(model ? { model } : {}),
});
onDeployed?.(ws.id);
} catch (e) {
@@ -152,7 +131,6 @@ export function useTemplateDeploy(
runtime,
models: template.models,
required_env: template.required_env,
recommended_env: template.recommended_env,
});
} catch (e) {
// Preflight network failure used to strand `deploying` — the
@@ -166,17 +144,8 @@ export function useTemplateDeploy(
setDeploying(null);
return;
}
if (
preflight.ok &&
preflight.providers.length === 0 &&
preflight.optionalKeys.length === 0
) {
await executeDeploy(template);
return;
}
// Open the picker whenever a template declares provider/key choices.
// Templates with no provider requirements deploy directly on the
// platform-managed default above. Reasons to keep the picker here:
// Always open the picker — every deploy goes through an
// explicit confirm-provider/model step. Reasons:
// 1. Multi-provider templates (e.g. hermes) need a per-
// workspace pick or the adapter falls back to its
// compiled-in default and 500s with "No LLM provider
@@ -195,7 +164,7 @@ export function useTemplateDeploy(
setMissingKeysInfo({ template, preflight });
setDeploying(null);
},
[executeDeploy],
[],
);
// No useCallback here — consumers call this on every render anyway
@@ -225,7 +194,6 @@ export function useTemplateDeploy(
<MissingKeysModal
open={!!missingKeysInfo}
missingKeys={missingKeysInfo?.preflight.missingKeys ?? []}
optionalKeys={missingKeysInfo?.preflight.optionalKeys ?? []}
providers={missingKeysInfo?.preflight.providers ?? []}
runtime={missingKeysInfo?.preflight.runtime ?? ""}
configuredKeys={missingKeysInfo?.preflight.configuredKeys}
@@ -32,16 +32,11 @@ const hermesModels: ModelSpec[] = [
const HERMES: TemplateLike = { runtime: "hermes", models: hermesModels };
const CLAUDE_CODE: TemplateLike = {
runtime: "claude-code",
const LANGGRAPH: TemplateLike = {
runtime: "langgraph",
required_env: ["OPENAI_API_KEY"],
};
const OPTIONAL_ONLY: TemplateLike = {
runtime: "claude-code",
recommended_env: ["GOOGLE_GSC_SITE", "GOOGLE_GA4_PROPERTY_ID"],
};
const UNKNOWN: TemplateLike = { runtime: "nothing-declared" };
// -----------------------------------------------------------------------------
@@ -74,7 +69,7 @@ describe("providersFromTemplate", () => {
});
it("falls back to top-level required_env when no models[] are declared", () => {
const providers = providersFromTemplate(CLAUDE_CODE);
const providers = providersFromTemplate(LANGGRAPH);
expect(providers).toHaveLength(1);
expect(providers[0].envVars).toEqual(["OPENAI_API_KEY"]);
});
@@ -156,11 +151,10 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
const result = await checkDeploySecrets(CLAUDE_CODE);
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.optionalKeys).toEqual([]);
expect(result.runtime).toBe("claude-code");
expect(result.runtime).toBe("langgraph");
});
it("returns ok=true on a multi-provider template when ANY provider is configured", async () => {
@@ -190,7 +184,6 @@ describe("checkDeploySecrets", () => {
);
// Grouped providers preserved for the picker.
expect(result.providers).toHaveLength(3);
expect(result.optionalKeys).toEqual([]);
});
it("treats has_value=false as not-configured", async () => {
@@ -202,7 +195,7 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
const result = await checkDeploySecrets(CLAUDE_CODE);
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
});
@@ -214,22 +207,6 @@ describe("checkDeploySecrets", () => {
expect(global.fetch).not.toHaveBeenCalled();
});
it("prompts optional-only env without treating it as missing", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve([]),
} as Response);
const result = await checkDeploySecrets(OPTIONAL_ONLY);
expect(result.ok).toBe(true);
expect(result.missingKeys).toEqual([]);
expect(result.optionalKeys).toEqual([
"GOOGLE_GSC_SITE",
"GOOGLE_GA4_PROPERTY_ID",
]);
expect(global.fetch).toHaveBeenCalled();
});
it("uses the workspace-scoped endpoint when workspaceId is provided", async () => {
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
@@ -239,7 +216,7 @@ describe("checkDeploySecrets", () => {
]),
} as Response);
await checkDeploySecrets(CLAUDE_CODE, "ws-123");
await checkDeploySecrets(LANGGRAPH, "ws-123");
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/workspaces/ws-123/secrets"),
expect.any(Object),
@@ -252,7 +229,7 @@ describe("checkDeploySecrets", () => {
json: () => Promise.resolve([]),
} as Response);
await checkDeploySecrets(CLAUDE_CODE);
await checkDeploySecrets(LANGGRAPH);
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining("/settings/secrets"),
expect.any(Object),
@@ -264,10 +241,9 @@ describe("checkDeploySecrets", () => {
new Error("Network error"),
);
const result = await checkDeploySecrets(CLAUDE_CODE);
const result = await checkDeploySecrets(LANGGRAPH);
expect(result.ok).toBe(false);
expect(result.missingKeys).toEqual(["OPENAI_API_KEY"]);
expect(result.optionalKeys).toEqual([]);
// Empty Set on fetch failure — useTemplateDeploy relies on this
// so the picker still opens with every entry rendered as input.
expect(result.configuredKeys).toEqual(new Set());
@@ -28,8 +28,8 @@ describe("isExternalLikeRuntime", () => {
"docker",
"local",
"agent",
"legacy-runtime",
"codex",
"crewai",
"langgraph",
"openclaw",
"custom-runtime",
])("%q returns false", (runtime) => {
@@ -8,7 +8,7 @@
* count bounded.
*/
import { describe, it, expect } from "vitest";
import { isUserVisibleWorkspaceTemplate, resolveRuntime } from "../deploy-preflight";
import { resolveRuntime } from "../deploy-preflight";
describe("resolveRuntime", () => {
describe("explicit runtime-map entries", () => {
@@ -64,15 +64,3 @@ describe("resolveRuntime", () => {
});
});
});
describe("isUserVisibleWorkspaceTemplate", () => {
it("hides runtime-default templates from product template surfaces", () => {
for (const id of ["claude-code-default", "codex", "hermes", "openclaw"]) {
expect(isUserVisibleWorkspaceTemplate({ id })).toBe(false);
}
});
it("keeps product templates visible", () => {
expect(isUserVisibleWorkspaceTemplate({ id: "seo-agent" })).toBe(true);
});
});
@@ -68,7 +68,8 @@ describe("provisionTimeoutForRuntime", () => {
});
it("returns 120_000 for any unknown runtime", () => {
expect(provisionTimeoutForRuntime("legacy-runtime")).toBe(120_000);
expect(provisionTimeoutForRuntime("langgraph")).toBe(120_000);
expect(provisionTimeoutForRuntime("crewai")).toBe(120_000);
expect(provisionTimeoutForRuntime("some-new-runtime")).toBe(120_000);
});
@@ -76,7 +77,7 @@ describe("provisionTimeoutForRuntime", () => {
const cases: Array<[string | undefined, { provisionTimeoutMs?: number } | undefined]> = [
[undefined, undefined],
["claude-code", undefined],
["claude-code", { provisionTimeoutMs: 500_000 }],
["langgraph", { provisionTimeoutMs: 500_000 }],
[undefined, { provisionTimeoutMs: 45_000 }],
];
for (const [runtime, overrides] of cases) {
-2
View File
@@ -23,7 +23,6 @@ const DEFAULT_TIMEOUT_MS = 35_000;
export interface RequestOptions {
timeoutMs?: number;
headers?: Record<string, string>;
}
/**
@@ -77,7 +76,6 @@ async function request<T>(
const headers: Record<string, string> = {
"Content-Type": "application/json",
...platformAuthHeaders(),
...(options?.headers ?? {}),
};
// Re-read slug locally for the 401 handler below — `headers` already
// has it, but the 401 branch needs the bare value to gate the
+1 -1
View File
@@ -44,7 +44,7 @@ export const plans: Plan[] = [
price: "$0",
features: [
"3 workspaces",
"Claude Code, Codex, Hermes, OpenClaw runtimes",
"Claude Code, LangGraph, OpenClaw runtimes",
"Shared Redis + bounded storage",
"Community support",
],
+2 -22
View File
@@ -21,7 +21,6 @@ import { api } from "./api";
export interface ModelSpec {
id: string;
name?: string;
provider?: string;
required_env?: string[];
}
@@ -32,8 +31,6 @@ export interface TemplateLike {
models?: ModelSpec[];
/** AND-required env vars declared at runtime_config level. */
required_env?: string[];
/** Optional env vars declared at runtime_config level. */
recommended_env?: string[];
}
/** Full /templates response shape shared by TemplatePalette (sidebar)
@@ -52,17 +49,6 @@ export interface Template extends TemplateLike {
skill_count: number;
}
const RUNTIME_DEFAULT_TEMPLATE_IDS = new Set([
"claude-code-default",
"codex",
"hermes",
"openclaw",
]);
export function isUserVisibleWorkspaceTemplate(template: Pick<Template, "id">): boolean {
return !RUNTIME_DEFAULT_TEMPLATE_IDS.has(template.id);
}
/** Map from a template id to the runtime name the per-workspace
* preflight expects. Used only when the server's `/templates`
* response predates the `runtime` field on the summary (legacy
@@ -98,8 +84,6 @@ export interface PreflightResult {
/** Flat list of env var names needed for the legacy modal path and
* for callers that want a single display of "what's missing". */
missingKeys: string[];
/** Optional env vars to offer in the modal without blocking deploy. */
optionalKeys: string[];
/** Grouped provider options derived from the template. When length 2
* the modal renders a picker; length 1 means exactly one provider is
* required (AllKeysModal renders the N envVars inline). */
@@ -252,14 +236,12 @@ export async function checkDeploySecrets(
): Promise<PreflightResult> {
const providers = providersFromTemplate(template);
const runtime = template.runtime;
const optionalKeys = Array.from(new Set(template.recommended_env ?? []));
if (providers.length === 0 && optionalKeys.length === 0) {
if (providers.length === 0) {
// Template declares no env requirements — nothing to preflight.
return {
ok: true,
missingKeys: [],
optionalKeys: [],
providers: [],
runtime,
configuredKeys: new Set(),
@@ -281,11 +263,10 @@ export async function checkDeploySecrets(
configured = new Set();
}
if (providers.length === 0 || findSatisfiedProvider(providers, configured)) {
if (findSatisfiedProvider(providers, configured)) {
return {
ok: true,
missingKeys: [],
optionalKeys,
providers,
runtime,
configuredKeys: configured,
@@ -300,7 +281,6 @@ export async function checkDeploySecrets(
return {
ok: false,
missingKeys,
optionalKeys,
providers,
runtime,
configuredKeys: configured,
+1 -4
View File
@@ -12,9 +12,7 @@ import type { NextRequest } from "next/server";
* style-src retains 'unsafe-inline': React Flow positions nodes via
* element-level style="" attributes which cannot be nonce'd; CSS injection
* is significantly lower risk than script injection and is acceptable here.
* object-src locked to 'none'; frame-src allows self + blob: for
* browser-native PDF previews backed by authenticated Blob URLs.
* base-uri / frame-ancestors locked to 'self'/'none'.
* object-src / base-uri / frame-ancestors locked to 'none'/'self'.
* upgrade-insecure-requests forces HTTPS on mixed-content.
*
* Development permissive policy:
@@ -63,7 +61,6 @@ export function buildCsp(nonce: string, isDev: boolean): string {
"img-src 'self' blob: data:",
"font-src 'self'",
"object-src 'none'",
"frame-src 'self' blob:",
"base-uri 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
-1
View File
@@ -528,7 +528,6 @@ export function buildNodesAndEdges(
// A2A delivery mode (task #227). Absent on older ws-server builds
// — leave undefined so the chat UI's "?? 'push'" fallback applies.
deliveryMode: ws.delivery_mode,
compute: ws.compute,
},
};
if (hasParent) {
+6 -18
View File
@@ -7,7 +7,7 @@ import {
} from "@xyflow/react";
import { api } from "@/lib/api";
import { showToast } from "@/components/Toaster";
import type { WorkspaceCompute, WorkspaceData, WSMessage } from "./socket";
import type { WorkspaceData, WSMessage } from "./socket";
import { handleCanvasEvent } from "./canvas-events";
import { markDeleted, wasRecentlyDeleted } from "./deleteTombstones";
import {
@@ -130,14 +130,6 @@ export interface WorkspaceNodeData extends Record<string, unknown> {
* builds that fallthrough is treated as "push" to match
* ws-server's `lookupDeliveryMode` default. */
deliveryMode?: string;
/** Desired EC2/container shape persisted in workspaces.compute. Applied
* at next restart/reprovision, and used to determine Display tab
* availability. */
compute?: WorkspaceCompute;
/** Runtime image changed through Container Config; next restart must
* re-apply the runtime-default template instead of reusing the old
* config volume. UI-only, cleared after restart. */
applyTemplateOnRestart?: boolean;
}
export type PanelTab = "details" | "skills" | "chat" | "terminal" | "display" | "container-config" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity" | "audit";
@@ -176,7 +168,7 @@ interface CanvasState {
setPanelTab: (tab: PanelTab) => void;
getSelectedNode: () => Node<WorkspaceNodeData> | null;
updateNodeData: (id: string, data: Partial<WorkspaceNodeData>) => void;
restartWorkspace: (id: string, options?: { applyTemplate?: boolean }) => Promise<void>;
restartWorkspace: (id: string) => Promise<void>;
removeNode: (id: string) => void;
/** Remove a node AND every descendant in one atomic update. Mirrors
* the server-side cascade `DELETE /workspaces/:id?confirm=true`
@@ -337,11 +329,8 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
},
batchDelete: async () => {
const ids = Array.from(get().selectedNodeIds);
const names = new Map(get().nodes.map((node) => [node.id, node.data.name]));
const results = await Promise.allSettled(
ids.map((id) => api.del(`/workspaces/${id}`, {
headers: { "X-Confirm-Name": names.get(id) ?? "" },
}))
ids.map((id) => api.del(`/workspaces/${id}`))
);
const failed: string[] = [];
results.forEach((r, i) => {
@@ -832,10 +821,9 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
});
},
restartWorkspace: async (id, options) => {
const body = options?.applyTemplate ? { apply_template: true } : undefined;
await api.post(`/workspaces/${id}/restart`, body);
get().updateNodeData(id, { needsRestart: false, applyTemplateOnRestart: false });
restartWorkspace: async (id) => {
await api.post(`/workspaces/${id}/restart`);
get().updateNodeData(id, { needsRestart: false });
},
removeNode: (id) => {
-14
View File
@@ -354,20 +354,6 @@ export interface WorkspaceData {
* collapsing the spinner the moment the synchronous queued-200 returns
* (task #227 external/MCP workspaces had no progress UX). */
delivery_mode?: string;
compute?: WorkspaceCompute;
}
export interface WorkspaceCompute {
instance_type?: string;
volume?: {
root_gb?: number;
};
display?: {
mode?: string;
protocol?: string;
width?: number;
height?: number;
};
}
let socket: ReconnectingSocket | null = null;
-10
View File
@@ -658,11 +658,6 @@
outline-offset: var(--focus-ring-offset);
}
.delete-dialog__cancel-btn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.delete-dialog__confirm-btn {
background: var(--status-invalid);
color: #ffffff;
@@ -676,11 +671,6 @@
outline-offset: var(--focus-ring-offset);
}
.delete-dialog__confirm-btn:focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-ring-offset);
}
.delete-dialog__confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; }
/* ── Unsaved changes guard ─────────────────────────── */
-2
View File
@@ -4,8 +4,6 @@ declare module "@novnc/novnc" {
resizeSession: boolean;
focusOnClick: boolean;
constructor(target: HTMLElement, url: string, options?: { wsProtocols?: string[]; [key: string]: unknown });
clipboardPasteFrom(text: string): void;
disconnect(): void;
focus(options?: FocusOptions): void;
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ Full contract: `docs/runbooks/admin-auth.md`.
|--------|------|---------|
| GET | /health | inline |
| GET | /metrics | metrics.Handler() — Prometheus text format; no auth, scrape-safe |
| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go — `GET /workspaces`, `POST /workspaces`, and `DELETE /workspaces/:id` require `AdminAuth`. `DELETE /workspaces/:id` also requires `X-Confirm-Name: <workspace name>`; cascading deletes still require `?confirm=true`. `PATCH /workspaces/:id` enforces field-level authz: cosmetic fields (name, role, x, y, canvas) pass through; sensitive fields (tier, parent_id, runtime, workspace_dir) require a valid bearer token when any live token exists. |
| POST/GET/PATCH/DELETE | /workspaces[/:id] | workspace.go — `GET /workspaces`, `POST /workspaces`, and `DELETE /workspaces/:id` require `AdminAuth`. `PATCH /workspaces/:id` enforces field-level authz: cosmetic fields (name, role, x, y, canvas) pass through; sensitive fields (tier, parent_id, runtime, workspace_dir) require a valid bearer token when any live token exists. |
| GET/PATCH | /workspaces/:id/config | workspace.go |
| GET/POST | /workspaces/:id/memory | workspace.go |
| DELETE | /workspaces/:id/memory/:key | workspace.go |
-2
View File
@@ -6,8 +6,6 @@ Molecule AI's memory model is built around one principle:
That is the purpose of **HMA: Hierarchical Memory Architecture**.
The organizational boundary is enforced **physically**, not at the application layer: each org runs as its own tenant on its own EC2, with its own memory plugin sidecar and its own Postgres. Memory writes are loopback-only — never cross-tenant. See [`workspace-placement.md`](workspace-placement.md) for the architecture contract that makes HMA tenant-isolated by construction.
## The Three Scopes
| Scope | Meaning | Intended use |
@@ -84,8 +84,6 @@ Six runtime adapters ship production-ready on `main`: LangGraph, DeepAgents, Cla
## 3. System Architecture
> **Workspace placement contract:** every Molecule org runs as a fully isolated tenant on its own EC2, with workspace-server, memory plugin, Postgres, and Redis all co-located. The platform (controlplane on Railway) handles provisioning, billing, and DNS only — it never holds tenant data. See [`workspace-placement.md`](workspace-placement.md) for the formal RFC.
### System Boundary Diagram
```

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