Compare commits

..

No commits in common. "main" and "fix/issue-94-e2e-api-parallel-safe-class-b" have entirely different histories.

301 changed files with 3569 additions and 20455 deletions

View File

@ -1,118 +0,0 @@
#!/usr/bin/env bash
# audit-force-merge — detect a §SOP-6 force-merge after PR close, emit
# `incident.force_merge` to stdout as structured JSON.
#
# Vector's docker_logs source picks up runner stdout; the JSON gets
# shipped to Loki on molecule-canonical-obs, indexable by event_type.
# Query example:
#
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
#
# A force-merge is detected when a PR closed-with-merged=true had at
# least one of the repo's required-status-check contexts in a state
# other than "success" at the merge commit's SHA. That's exactly what
# the Gitea force_merge:true API call lets through, so it's a faithful
# detector of the override path.
#
# Triggers on `pull_request_target: closed` (loaded from base branch
# per §SOP-6 security model). No-op when merged=false.
#
# Required env (set by the workflow):
# GITEA_TOKEN, GITEA_HOST, REPO, PR_NUMBER, REQUIRED_CHECKS
#
# REQUIRED_CHECKS is a newline-separated list of status-check context
# names that branch protection requires. Declared in the workflow YAML
# rather than fetched from /branch_protections (which needs admin
# scope — sop-tier-bot has read-only). Trade dynamism for simplicity:
# when the required-check set changes, update both branch protection
# AND this env. Keeping them in sync is less complexity than granting
# the audit bot admin perms on every repo.
set -euo pipefail
: "${GITEA_TOKEN:?required}"
: "${GITEA_HOST:?required}"
: "${REPO:?required}"
: "${PR_NUMBER:?required}"
: "${REQUIRED_CHECKS:?required (newline-separated context names)}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
# 1. Fetch the PR. If not merged, no-op.
PR=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
MERGED=$(echo "$PR" | jq -r '.merged // false')
if [ "$MERGED" != "true" ]; then
echo "::notice::PR #${PR_NUMBER} closed without merge — no audit emission."
exit 0
fi
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty')
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"')
TITLE=$(echo "$PR" | jq -r '.title // ""')
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"')
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty')
if [ -z "$MERGE_SHA" ]; then
echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge."
exit 0
fi
# 2. Required status checks declared in the workflow env.
REQUIRED="$REQUIRED_CHECKS"
if [ -z "${REQUIRED//[[:space:]]/}" ]; then
echo "::notice::REQUIRED_CHECKS empty — force-merge not applicable."
exit 0
fi
# 3. Status-check state at the PR HEAD (where checks ran). The merge
# commit doesn't get its own checks; we evaluate the PR's last
# commit, which is what branch protection compared against.
STATUS=$(curl -sS -H "$AUTH" \
"${API}/repos/${OWNER}/${NAME}/commits/${HEAD_SHA}/status")
declare -A CHECK_STATE
while IFS=$'\t' read -r ctx state; do
[ -n "$ctx" ] && CHECK_STATE[$ctx]="$state"
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"')
# 4. For each required check, was it green at merge? YAML block scalars
# (`|`) leave a trailing newline; skip blank/whitespace-only lines.
FAILED_CHECKS=()
while IFS= read -r req; do
trimmed="${req#"${req%%[![:space:]]*}"}" # ltrim
trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" # rtrim
[ -z "$trimmed" ] && continue
state="${CHECK_STATE[$trimmed]:-missing}"
if [ "$state" != "success" ]; then
FAILED_CHECKS+=("${trimmed}=${state}")
fi
done <<< "$REQUIRED"
if [ "${#FAILED_CHECKS[@]}" -eq 0 ]; then
echo "::notice::PR #${PR_NUMBER} merged with all required checks green — not a force-merge."
exit 0
fi
# 5. Emit structured audit event.
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .)
# Print as a single-line JSON so Vector's parse_json transform can pick
# it up cleanly from docker_logs.
jq -nc \
--arg event_type "incident.force_merge" \
--arg ts "$NOW" \
--arg repo "$REPO" \
--argjson pr "$PR_NUMBER" \
--arg title "$TITLE" \
--arg base "$BASE_BRANCH" \
--arg merged_by "$MERGED_BY" \
--arg merge_sha "$MERGE_SHA" \
--argjson failed_checks "$FAILED_JSON" \
'{event_type: $event_type, ts: $ts, repo: $repo, pr: $pr, title: $title,
base_branch: $base, merged_by: $merged_by, merge_sha: $merge_sha,
failed_checks: $failed_checks}'
echo "::warning::FORCE-MERGE detected on PR #${PR_NUMBER} by ${MERGED_BY}: ${#FAILED_CHECKS[@]} required check(s) not green at merge time."

View File

@ -1,346 +0,0 @@
#!/usr/bin/env bash
# sop-tier-check — verify a Gitea PR satisfies the §SOP-6 approval gate.
#
# Reads the PR's tier label, walks approving reviewers, and checks team
# membership against the tier's approval expression. Passes only when
# ALL clauses in the expression are satisfied by the set of approving
# reviewers (AND-composition; internal#189).
#
# Expression syntax:
# "team-a" — OR-set: any ONE of the comma-separated teams
# "team-a AND team-b" — AND: BOTH must each have ≥1 approver
# "(a,b,c)" — OR-set wrapped in parens; same as "a,b,c"
#
# Example: "qa AND security AND (managers,ceo)" means:
# ≥1 approver in team "qa" AND
# ≥1 approver in team "security" AND
# ≥1 approver in team "managers" OR "ceo"
#
# Per the spec (internal#189), the hard gate here pairs with the
# advisory gate of sop-conformance LLM-judge (internal#188): each
# required-team click must reflect real verification (visible in review
# body or A2A messages), not rubber-stamp APPROVE. Both gates together
# close the "teammate clicks APPROVE without verifying" gap.
#
# Invoked from `.gitea/workflows/sop-tier-check.yml`. The workflow sets
# the env vars below; this script does no IO outside of stdout/stderr +
# the Gitea API.
#
# Required env:
# GITEA_TOKEN — bot PAT with read:organization,read:user,
# read:issue,read:repository scopes
# GITEA_HOST — e.g. git.moleculesai.app
# REPO — owner/name (from github.repository)
# PR_NUMBER — int (from github.event.pull_request.number)
# PR_AUTHOR — login (from github.event.pull_request.user.login)
#
# Optional:
# SOP_DEBUG=1 — print per-API-call diagnostic lines. Default: off.
# SOP_LEGACY_CHECK=1 — revert to OR-gate (≥1 approver from any eligible
# team). Grace window for PRs in-flight when the
# new AND-composition was deployed. Expires 2026-05-17
# (7-day burn-in window; internal#189 Phase 1).
# Set by workflow for PRs merged before the deploy.
set -euo pipefail
debug() {
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] $*" >&2
fi
}
# Validate env
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
: "${GITEA_HOST:?GITEA_HOST required}"
: "${REPO:?REPO required (owner/name)}"
: "${PR_NUMBER:?PR_NUMBER required}"
: "${PR_AUTHOR:?PR_AUTHOR required}"
OWNER="${REPO%%/*}"
NAME="${REPO##*/}"
API="https://${GITEA_HOST}/api/v1"
AUTH="Authorization: token ${GITEA_TOKEN}"
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
# Sanity: token resolves to a user
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
if [ -z "$WHOAMI" ]; then
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
exit 1
fi
echo "::notice::token resolves to user: $WHOAMI"
# 1. Read tier label
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
TIER=""
for L in $LABELS; do
case "$L" in
tier:low|tier:medium|tier:high)
if [ -n "$TIER" ]; then
echo "::error::Multiple tier labels: $TIER + $L. Apply exactly one."
exit 1
fi
TIER="$L"
;;
esac
done
if [ -z "$TIER" ]; then
echo "::error::PR has no tier:low|tier:medium|tier:high label. Apply one before merge."
exit 1
fi
debug "tier=$TIER"
# 2. Tier → required team expression (AND-composition; internal#189)
#
# Expression syntax:
# clause-a AND clause-b AND ... — ALL clauses must pass
# team-a,team-b,team-c — OR-set: ≥1 approver in ANY of these teams
# (team-a,team-b) — same as team-a,team-b (parens optional)
#
# This map is the single source of truth. Update it when the team structure
# or policy changes. Teams referenced here but absent in Gitea are treated
# as unachievable (would always fail) — operators notice the clear error
# and create the missing team.
#
# Current Gitea teams: ceo, engineers, managers
# Future teams (create before removing "???" fallback): qa, security, security-audit
declare -A TIER_EXPR=(
# tier:low — same as previous OR gate: any engineer, manager, or ceo.
["tier:low"]="engineers,managers,ceo"
# tier:medium — AND of (managers) AND (engineers) AND (qa???,security???)
# The qa+security clause requires both teams to exist; when not yet
# created, the PR author is responsible for adding them before requesting
# approval on a tier:medium PR. Ops: create qa + security Gitea teams
# and update this map to remove the "???" markers (internal#189 follow-up).
["tier:medium"]="managers AND engineers AND qa???,security???"
# tier:high — ceo only. The AND-composition adds no value for a
# single-team gate, but the framework is wired for consistency.
["tier:high"]="ceo"
)
EXPR="${TIER_EXPR[$TIER]-}"
if [ -z "$EXPR" ]; then
echo "::error::No expression defined for tier $TIER in TIER_EXPR map."
exit 1
fi
debug "expression=$EXPR"
# 3. Legacy OR-gate override (7-day burn-in grace window; internal#189 Phase 1)
if [ "${SOP_LEGACY_CHECK:-}" = "1" ]; then
LEGACY_ELIGIBLE=""
case "$TIER" in
tier:low) LEGACY_ELIGIBLE="engineers managers ceo" ;;
tier:medium) LEGACY_ELIGIBLE="managers ceo" ;;
tier:high) LEGACY_ELIGIBLE="ceo" ;;
esac
echo "::notice::SOP_LEGACY_CHECK=1 — using OR-gate ({$LEGACY_ELIGIBLE}) for this PR."
ELIGIBLE="$LEGACY_ELIGIBLE"
fi
# 4. Resolve all team names → IDs
# /orgs/{org}/teams/{slug}/... endpoints don't exist on Gitea 1.22;
# we use /teams/{id}.
ORG_TEAMS_FILE=$(mktemp)
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/teams")
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
if [ "${SOP_DEBUG:-}" = "1" ]; then
echo " [debug] teams-list body (first 300 chars):" >&2
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
fi
if [ "$HTTP_CODE" != "200" ]; then
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope."
exit 1
fi
# Collect every team name that appears in the expression.
# Bash word-splitting on $EXPR splits on spaces, so "AND" appears as a
# token. We skip it explicitly.
declare -A TEAM_ID
_all_teams=""
for _raw_clause in $EXPR; do
# Strip parens and split on comma.
_clause=${_raw_clause//[()]/}
for _t in $(echo "$_clause" | tr ',' '\n'); do
_t=$(echo "$_t" | tr -d '[:space:]')
[ -z "$_t" ] && continue
# Skip AND / OR operator tokens (bash word-split produced them from
# spaces in the expression string).
[ "$_t" = "AND" ] || [ "$_t" = "OR" ] && continue
# Skip if already in set.
case " $_all_teams " in
*" $_t "*) ;; # already present
*) _all_teams="${_all_teams} $_t " ;;
esac
done
done
for _t in $_all_teams; do
_t=$(echo "$_t" | tr -d ' ')
[ -z "$_t" ] && continue
_id=$(jq -r --arg t "$_t" '.[] | select(.name==$t) | .id' <"$ORG_TEAMS_FILE" | head -1)
if [ -z "$_id" ] || [ "$_id" = "null" ]; then
# "??" suffix marks teams that don't exist yet (tier:medium qa/security).
# Treat as permanently failing clause; clear error message guides ops.
if [[ "$_t" == *"???" ]]; then
debug "team \"$_t\" not found (expected — pending team creation per internal#189)"
continue
fi
_visible=$(jq -r '.[]?.name? // empty' <"$ORG_TEAMS_FILE" 2>/dev/null | tr '\n' ' ')
echo "::error::Team \"$_t\" referenced in tier $TIER expression but not found in org $OWNER. Teams visible: $_visible"
exit 1
fi
TEAM_ID[$_t]="$_id"
debug "team-id: $_t$_id"
done
# 5. Read approving reviewers
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
if [ -z "$APPROVERS" ]; then
echo "::error::No approving reviews on this PR. Set SOP_DEBUG=1 and re-run for diagnostics."
exit 1
fi
debug "approvers: $(echo "$APPROVERS" | tr '\n' ' ')"
# 6. For each approver: skip self-review; probe team membership by id.
# Build $APPROVER_TEAMS[<user>]=space-surrounded team names (e.g. " managers ").
# Pre/post spaces ensure case patterns *${_t}* match even when the name
# is the first or last entry (bash case *word* needs delimiters on both sides).
#
# FALLBACK: if ALL team probes return 403 (token lacks read:org scope),
# fall back to /orgs/{org}/members/{user}. This returns 204 for any org
# member — a superset of team membership. Accepting it as a fallback means
# the gate passes when the token is scoped to repo+user only (core-bot PAT).
# This is safe because: (a) org membership is a prerequisite for every
# eligible team; (b) the AND-composition of internal#189 still requires
# multiple independent approvers; (c) any token with read:repository can
# see the approving reviews, so bypass requires a colluding approver.
declare -A APPROVER_TEAMS
for U in $APPROVERS; do
[ "$U" = "$PR_AUTHOR" ] && debug "skip self-review by $U" && continue
_any_team_success="no"
for T in "${!TEAM_ID[@]}"; do
ID="${TEAM_ID[$T]}"
CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
"${API}/teams/${ID}/members/${U}")
debug "probe: $U in team $T (id=$ID) → HTTP $CODE"
if [ "$CODE" = "200" ] || [ "$CODE" = "204" ]; then
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
debug "$U qualifies for team $T"
_any_team_success="yes"
fi
done
# Fallback: if every team probe returned 403, try org membership.
# "??" teams were never resolved to IDs so they never entered the loop.
# If the user is an org member, credit them as being in each queried team
# (engineers, managers, ceo are all org-level). This is safe because org
# membership is a prerequisite for all three, and bypass requires a colluding
# approver (same risk as before the AND-composition).
if [ "$_any_team_success" = "no" ]; then
ORG_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" \
"${API}/orgs/${OWNER}/members/${U}")
debug "probe: $U in org $OWNER (fallback) → HTTP $ORG_CODE"
if [ "$ORG_CODE" = "204" ]; then
for T in "${!TEAM_ID[@]}"; do
APPROVER_TEAMS[$U]="${APPROVER_TEAMS[$U]:- } ${APPROVER_TEAMS[$U]:+ }$T "
done
debug "$U credited as org member for all queried teams (fallback — token may lack read:org)"
fi
fi
done
# 7. Evaluate the tier expression.
#
# legacy OR-gate: use the simplified loop from before internal#189.
if [ -n "${LEGACY_ELIGIBLE:-}" ]; then
OK=""
for _u in "${!APPROVER_TEAMS[@]}"; do
for _t2 in $LEGACY_ELIGIBLE; do
case "${APPROVER_TEAMS[$_u]}" in
*${_t2}*)
echo "::notice::approver $_u is in team $_t2 (eligible for $TIER)"
OK="yes"
break
;;
esac
done
[ -n "$OK" ] && break
done
if [ -z "$OK" ]; then
echo "::error::Tier $TIER requires approval from a non-author member of {$LEGACY_ELIGIBLE}. Set SOP_DEBUG=1 to see per-probe HTTP codes."
exit 1
fi
echo "::notice::sop-tier-check passed: $TIER (legacy OR-gate)"
exit 0
fi
# AND-gate: evaluate the expression clause by clause.
# _passed_clauses and _failed_clauses accumulate for the status description.
_passed_clauses=""
_failed_clauses=""
for _raw_clause in $EXPR; do
# Normalise: strip parens, replace commas with spaces so bash word-split
# can iterate the OR-set members. The previous form
# _clause=$(echo ... | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
# collapsed every member into one concatenated token because
# `tr -d '[:space:]'` strips the very newlines that just separated them
# ("engineers,managers,ceo" -> "engineersmanagersceo"), so the OR-clause
# only ever evaluated as a single nonsense team name and never matched
# APPROVER_TEAMS. Fixed in #229: leave the comma-separated members as
# space-separated tokens for `for _t in $_clause`.
_no_parens=${_raw_clause//[()]/}
_clause=${_no_parens//,/ }
_clause_passed="no"
_clause_names=""
for _t in $_clause; do
# Append (don't overwrite) team name to the human-readable accumulator.
# The previous form `_clause_names="${_clause_names:+, }${_t}"`
# rewrote the variable on every iteration, so the FAIL message only
# ever showed the LAST team. Fixed: prepend prior value before the
# comma-separator, then append the new team name.
_clause_names="${_clause_names}${_clause_names:+, }${_t}"
# Skip teams not yet in Gitea (qa??? / security??? placeholders).
[[ "$_t" == *"???" ]] && debug "clause \"$_t\": skipped (team pending creation)" && continue
[ -z "${TEAM_ID[$_t]:-}" ] && debug "clause \"$_t\": no ID resolved, skipping" && continue
for _u in "${!APPROVER_TEAMS[@]}"; do
# Note: APPROVER_TEAMS values are space-surrounded (e.g. " managers ").
# Pattern *${_t}* matches team name anywhere in the space-padded string.
case "${APPROVER_TEAMS[$_u]}" in
*${_t}*)
_clause_passed="yes"
debug "clause \"$_t\": satisfied by $_u"
break
;;
esac
done
done
# Label for display: strip "???" from pending teams.
_label=$(echo "$_raw_clause" | tr -d '()' | tr ',' '/' | tr -d '[:space:]' | sed 's/???//g')
if [ "$_clause_passed" = "yes" ]; then
# Append (don't overwrite) — same accumulator bug as _clause_names above.
_passed_clauses="${_passed_clauses}${_passed_clauses:+, }$_label"
echo "::notice::clause [$_label]: PASS — satisfied by approving reviewer(s)"
else
_failed_clauses="${_failed_clauses}${_failed_clauses:+, }$_label"
echo "::error::clause [$_label]: FAIL — no approving reviewer belongs to any of these teams (${_clause_names}). Set SOP_DEBUG=1 to see per-team probe results."
fi
done
if [ -n "$_failed_clauses" ]; then
echo ""
echo "::error::sop-tier-check FAILED for $TIER."
echo " Passed :${_passed_clauses}"
echo " Missing:${_failed_clauses}"
echo " All clauses must be satisfied. Each missing team needs an APPROVED review from one of its members."
exit 1
fi
echo "::notice::sop-tier-check PASSED: $TIER — all required clauses satisfied [${_passed_clauses}]"

View File

@ -1,101 +0,0 @@
#!/usr/bin/env bash
# Regression test for #229 — sop-tier-check tier:low OR-clause splitter.
#
# Bug (PR #225 → still broken after PR #231):
# Line ~289 of sop-tier-check.sh used:
# _clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
# `tr -d '[:space:]'` strips the newlines that `tr ',' '\n'` just
# inserted, collapsing "engineers,managers,ceo" into a single token
# "engineersmanagersceo". The for-loop then iterates ONCE on a name
# that matches no team, so every tier:low PR fails:
# ::error::clause [engineers/managers/ceo]: FAIL — no approving
# reviewer belongs to any of these teamsengineersmanagersceo
# (note also: missing separators in the error string is bug #2 —
# `_clause_names` used "${var:+, }$x" which OVERWRITES per iteration).
#
# Fix shape (this PR):
# _no_parens=${_raw_clause//[()]/}
# _clause=${_no_parens//,/ } # comma -> space, bash word-split iterates
# _clause_names="${_clause_names}${_clause_names:+, }${_t}" # APPEND, not overwrite
#
# This test extracts the splitter logic and asserts it produces the right
# token list for each of the three tier expressions live in the script.
set -euo pipefail
PASS=0
FAIL=0
assert_eq() {
local label="$1"
local expected="$2"
local got="$3"
if [ "$expected" = "$got" ]; then
echo " PASS $label"
PASS=$((PASS + 1))
else
echo " FAIL $label"
echo " expected: <$expected>"
echo " got: <$got>"
FAIL=$((FAIL + 1))
fi
}
# ----- Splitter under test (mirrors the fixed sop-tier-check.sh block) -----
split_clause() {
local raw="$1"
local no_parens=${raw//[()]/}
local clause=${no_parens//,/ }
local out=""
for _t in $clause; do
out="${out}${out:+|}$_t"
done
echo "$out"
}
echo "test: tier:low OR-clause splits to 3 tokens"
assert_eq "tier:low" "engineers|managers|ceo" "$(split_clause "engineers,managers,ceo")"
echo "test: tier:medium AND-expression — bash word-split on \$EXPR yields 5 tokens"
EXPR="managers AND engineers AND qa???,security???"
out=""
for _raw in $EXPR; do
out="${out}${out:+ ; }$(split_clause "$_raw")"
done
assert_eq "tier:medium" "managers ; AND ; engineers ; AND ; qa???|security???" "$out"
echo "test: tier:high single-team OR-clause"
assert_eq "tier:high" "ceo" "$(split_clause "ceo")"
echo "test: paren-wrapped OR-set unwraps + splits"
assert_eq "paren OR" "managers|ceo" "$(split_clause "(managers,ceo)")"
# ----- _clause_names accumulator (was overwriting per iteration) -----
acc=""
for t in engineers managers ceo; do
acc="${acc}${acc:+, }${t}"
done
assert_eq "_clause_names append" "engineers, managers, ceo" "$acc"
# ----- _failed_clauses / _passed_clauses accumulator across raw clauses -----
acc=""
for c in clauseA clauseB clauseC; do
acc="${acc}${acc:+, }${c}"
done
assert_eq "_failed_clauses append" "clauseA, clauseB, clauseC" "$acc"
# ----- End-to-end OR-gate: simulate APPROVER_TEAMS[core-lead]=' managers ' -----
# The script's case pattern is *${_t}* with a space-padded value.
APPROVER_TEAMS_VAL=" managers "
matched=""
for _t in $(split_clause "engineers,managers,ceo" | tr '|' ' '); do
case "$APPROVER_TEAMS_VAL" in
*${_t}*) matched="$_t"; break ;;
esac
done
assert_eq "OR-gate matches managers" "managers" "$matched"
echo
echo "------"
echo "PASS=$PASS FAIL=$FAIL"
[ "$FAIL" -eq 0 ]

View File

@ -1,58 +0,0 @@
# audit-force-merge — emit `incident.force_merge` to runner stdout when
# a PR is merged with required-status-checks not green. Vector picks
# the JSON line off docker_logs and ships to Loki on
# molecule-canonical-obs (per `reference_obs_stack_phase1`); query as:
#
# {host="operator"} |= "event_type" |= "incident.force_merge" | json
#
# Closes the §SOP-6 audit gap (the doc says force-merges write to
# `structure_events`, but that table lives in the platform DB, not
# Gitea-side; Loki is the practical equivalent for Gitea Actions
# events). When the credential / observability stack converges later,
# this can sync into structure_events from Loki via a backfill job —
# the structured JSON shape is forward-compatible.
#
# Logic in `.gitea/scripts/audit-force-merge.sh` per the same script-
# extract pattern as sop-tier-check.
name: audit-force-merge
# pull_request_target loads from the base branch — same security model
# as sop-tier-check. Without this, an attacker could rewrite the
# workflow on a PR and skip the audit emission for their own
# force-merge. See `.gitea/workflows/sop-tier-check.yml` for the full
# rationale.
on:
pull_request_target:
types: [closed]
jobs:
audit:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
# Skip when PR is closed without merge — saves a runner.
if: github.event.pull_request.merged == true
steps:
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.pull_request.base.sha }}
- name: Detect force-merge + emit audit event
env:
# Same org-level secret the sop-tier-check workflow uses.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
# Required-status-check contexts to evaluate at merge time.
# Newline-separated. Mirror this against branch protection
# (settings → branches → protected branch → required checks).
# Declared here rather than fetched from /branch_protections
# because that endpoint requires admin write — sop-tier-bot is
# read-only by design (least-privilege).
REQUIRED_CHECKS: |
sop-tier-check / tier-check (pull_request)
Secret scan / Scan diff for credential-shaped strings (pull_request)
run: bash .gitea/scripts/audit-force-merge.sh

View File

@ -1,303 +0,0 @@
name: publish-runtime
# Gitea Actions port of .github/workflows/publish-runtime.yml.
#
# Ported 2026-05-10 (issue #206). Key differences from the GitHub version:
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
# - Dropped `environment: pypi-publish` — Gitea Actions does not support
# named environments or OIDC trusted publishers
# - Replaced `pypa/gh-action-pypi-publish@release/v1` (OIDC) with
# `twine upload` using PYPI_TOKEN secret — same mechanism as a local
# `python -m twine upload` with a PyPI token
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/tags/}`
# — Gitea Actions exposes github.ref (the full ref) but not ref_name
# - Dropped `merge_group` trigger (Gitea has no merge queue)
# - Dropped `staging` branch trigger (no staging branch exists in this repo)
#
# PyPI publishing: requires PYPI_TOKEN repository secret (or org-level secret).
# Set via: repo Settings → Actions → Variables and Secrets → New Secret.
# The token should be a PyPI API token scoped to molecule-ai-workspace-runtime.
#
# The DISPATCH_TOKEN cascade (git push to template repos) is unchanged —
# it uses the Gitea API directly and was already Gitea-compatible.
on:
push:
tags:
- "runtime-v*"
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
required: true
type: string
permissions:
contents: read
# Serialize publishes so two concurrent tag pushes don't both compute
# "latest+1" and race on PyPI upload. The second one waits.
concurrency:
group: publish-runtime
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.11"
cache: pip
- name: Derive version (tag, manual input, or PyPI auto-bump)
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
elif echo "$GITHUB_REF" | grep -q "^refs/tags/runtime-v"; then
# Tag is `runtime-vX.Y.Z` — strip the prefix.
VERSION="${GITHUB_REF#refs/tags/runtime-v}"
else
# Fallback: derive from PyPI latest + patch bump.
# (The staging-push auto-bump trigger is dropped on Gitea —
# no staging branch exists. This fallback path is kept for
# robustness if a future automation uses workflow_dispatch without
# an explicit version input.)
LATEST=$(curl -fsS --retry 3 https://pypi.org/pypi/molecule-ai-workspace-runtime/json \
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
MAJOR=$(echo "$LATEST" | cut -d. -f1)
MINOR=$(echo "$LATEST" | cut -d. -f2)
PATCH=$(echo "$LATEST" | cut -d. -f3)
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
echo "Auto-bumped from PyPI latest $LATEST -> $VERSION"
fi
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(\.dev[0-9]+|rc[0-9]+|a[0-9]+|b[0-9]+|\.post[0-9]+)?$'; then
echo "::error::version $VERSION does not match PEP 440"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing molecule-ai-workspace-runtime $VERSION"
- name: Install build tooling
run: pip install build twine
- name: Build package from workspace/
run: |
python scripts/build_runtime_package.py \
--version "${{ steps.version.outputs.version }}" \
--out "${{ runner.temp }}/runtime-build"
- name: Build wheel + sdist
working-directory: ${{ runner.temp }}/runtime-build
run: python -m build
- name: Capture wheel SHA256 for cascade content-verification
id: wheel_hash
working-directory: ${{ runner.temp }}/runtime-build
run: |
set -eu
WHEEL=$(ls dist/*.whl 2>/dev/null | head -1)
if [ -z "$WHEEL" ]; then
echo "::error::No .whl in dist/ — \`python -m build\` must have failed silently"
exit 1
fi
HASH=$(sha256sum "$WHEEL" | awk '{print $1}')
echo "wheel_sha256=${HASH}" >> "$GITHUB_OUTPUT"
echo "Local wheel SHA256 (pre-upload): ${HASH}"
echo "Wheel filename: $(basename "$WHEEL")"
- name: Verify package contents (sanity)
working-directory: ${{ runner.temp }}/runtime-build
run: |
python -m twine check dist/*
python -m venv /tmp/smoke
/tmp/smoke/bin/pip install --quiet dist/*.whl
/tmp/smoke/bin/python "$GITHUB_WORKSPACE/scripts/wheel_smoke.py"
- name: Publish to PyPI
env:
# PYPI_TOKEN: repository secret scoped to molecule-ai-workspace-runtime.
# Set via: Settings → Actions → Variables and Secrets → New Secret.
# Format: pypi-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
run: |
if [ -z "$PYPI_TOKEN" ]; then
echo "::error::PYPI_TOKEN secret is not set — set it at Settings → Actions → Variables and Secrets → New Secret."
echo "::error::Required format: pypi-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
exit 1
fi
python -m twine upload \
--repository pypi \
--username __token__ \
--password "$PYPI_TOKEN" \
dist/*
cascade:
needs: publish
runs-on: ubuntu-latest
steps:
- name: Wait for PyPI to propagate the new version
env:
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
EXPECTED_SHA256: ${{ needs.publish.outputs.wheel_sha256 }}
run: |
set -eu
if [ -z "$EXPECTED_SHA256" ]; then
echo "::error::publish job did not expose wheel_sha256 — cannot verify wheel content. Refusing to fan out cascade."
exit 1
fi
python -m venv /tmp/propagation-probe
PROBE=/tmp/propagation-probe/bin
$PROBE/pip install --upgrade --quiet pip
for i in $(seq 1 30); do
if $PROBE/pip install \
--quiet \
--no-cache-dir \
--force-reinstall \
--no-deps \
"molecule-ai-workspace-runtime==${RUNTIME_VERSION}" \
>/dev/null 2>&1; then
INSTALLED=$($PROBE/pip show molecule-ai-workspace-runtime 2>/dev/null \
| awk -F': ' '/^Version:/{print $2}')
if [ "$INSTALLED" = "$RUNTIME_VERSION" ]; then
echo "✓ PyPI resolved $RUNTIME_VERSION (install check)"
break
fi
fi
if [ $i -eq 30 ]; then
echo "::error::pip install --no-cache-dir molecule-ai-workspace-runtime==${RUNTIME_VERSION} never resolved within ~5 min."
echo "::error::Refusing to fan out cascade against a potentially stale PyPI index."
exit 1
fi
echo " [$i/30] waiting for PyPI to propagate ${RUNTIME_VERSION}..."
sleep 4
done
# Stage (b): download wheel + SHA256 compare against what we built.
# Catches Fastly stale-content serving old bytes under a new version URL.
HASH=$(python -m pip download \
--no-deps \
--no-cache-dir \
--dest /tmp/wheel-probe \
"molecule-ai-workspace-runtime==${RUNTIME_VERSION}" \
2>/dev/null \
&& sha256sum /tmp/wheel-probe/*.whl | awk '{print $1}')
if [ "$HASH" != "$EXPECTED_SHA256" ]; then
echo "::error::PyPI propagated $RUNTIME_VERSION but wheel content SHA256 mismatch."
echo "::error::Expected: $EXPECTED_SHA256"
echo "::error::Got: $HASH"
echo "::error::Fastly may be serving stale content. Refusing to fan out cascade."
exit 1
fi
echo "✓ PyPI CDN verified (SHA256 match)"
- name: Fan out via push to .runtime-version
env:
# Gitea PAT with write:repository scope on the 8 cascade-active
# template repos. Used for git push to each template repo's main
# branch, which trips their `on: push: branches: [main]` trigger
# on publish-image.yml.
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
run: |
set +e # don't abort on a single repo failure — collect them all
if [ -z "$DISPATCH_TOKEN" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::DISPATCH_TOKEN secret not set — skipping cascade."
echo "::warning::set it at Settings → Actions → Variables and Secrets → New Secret."
exit 0
fi
echo "::error::DISPATCH_TOKEN secret missing — cascade cannot fan out."
echo "::error::PyPI was published, but the 8 template repos will NOT pick up the new version."
exit 1
fi
VERSION="$RUNTIME_VERSION"
if [ -z "$VERSION" ]; then
echo "::error::publish job did not expose a version output"
exit 1
fi
GITEA_URL="${GITEA_URL:-https://git.moleculesai.app}"
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
FAILED=""
SKIPPED=""
git config --global user.name "publish-runtime cascade"
git config --global user.email "publish-runtime@moleculesai.app"
WORKDIR="$(mktemp -d)"
for tpl in $TEMPLATES; do
REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
CLONE="$WORKDIR/$tpl"
HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \
-H "Authorization: token $DISPATCH_TOKEN" \
"$GITEA_URL/api/v1/repos/$REPO/contents/.github/workflows/publish-image.yml")
if [ "$HTTP" = "404" ]; then
echo "↷ $tpl has no publish-image.yml — soft-skip"
SKIPPED="$SKIPPED $tpl"
continue
fi
attempt=0
success=false
while [ $attempt -lt 3 ]; do
attempt=$((attempt + 1))
rm -rf "$CLONE"
if ! git clone --depth=1 \
"https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/$REPO.git" \
"$CLONE" >/tmp/clone.log 2>&1; then
echo "::warning::clone $tpl attempt $attempt failed: $(tail -n3 /tmp/clone.log)"
sleep 2
continue
fi
cd "$CLONE"
echo "$VERSION" > .runtime-version
if git diff --quiet -- .runtime-version; then
echo "✓ $tpl already at $VERSION — no commit needed"
success=true
cd - >/dev/null
break
fi
git add .runtime-version
git commit -m "chore: pin runtime to $VERSION (publish-runtime cascade)" \
-m "Co-Authored-By: publish-runtime cascade <publish-runtime@moleculesai.app>" \
>/dev/null
if git push origin HEAD:main >/tmp/push.log 2>&1; then
echo "✓ $tpl pushed $VERSION on attempt $attempt"
success=true
cd - >/dev/null
break
fi
echo "::warning::push $tpl attempt $attempt failed, pull-rebasing"
git pull --rebase origin main >/tmp/rebase.log 2>&1 || true
cd - >/dev/null
done
if [ "$success" != "true" ]; then
FAILED="$FAILED $tpl"
fi
done
rm -rf "$WORKDIR"
if [ -n "$FAILED" ]; then
echo "::error::Cascade incomplete after 3 retries each. Failed:$FAILED"
exit 1
fi
if [ -n "$SKIPPED" ]; then
echo "Cascade complete: pinned $VERSION. Soft-skipped (no publish-image.yml):$SKIPPED"
else
echo "Cascade complete: $VERSION pinned across all manifest workspace_templates."
fi

View File

@ -1,174 +0,0 @@
name: publish-workspace-server-image
# Gitea Actions port of .github/workflows/publish-workspace-server-image.yml.
#
# Ported 2026-05-10 (issue #228). Key differences from the GitHub version:
# - Gitea Actions reads .gitea/workflows/, not .github/workflows/
# - Dropped `environment:` declarations — Gitea Actions does not support
# named environments (used by GitHub OIDC token gates)
# - Replaced `github.ref_name` (GitHub-only) with `${GITHUB_REF#refs/heads/}`
# — Gitea Actions exposes GITHUB_REF in the same format as GitHub Actions
# - docker/setup-buildx-action and aws-actions/configure-aws-credentials are
# GitHub Marketplace actions; they are installed by Gitea Actions runners and
# work identically here
# - All other variables (GITHUB_SHA, GITHUB_REPOSITORY, GITHUB_OUTPUT,
# secrets.*) use the same syntax as GitHub Actions
#
# Image tags produced:
# :staging-<sha> — per-commit digest, stable for canary verify
# :staging-latest — tracks most recent build on this branch
#
# ECR target: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/*
# Required secrets: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AUTO_SYNC_TOKEN
on:
push:
branches: [main]
paths:
- 'workspace-server/**'
- 'canvas/**'
- 'manifest.json'
- 'scripts/**'
- '.gitea/workflows/publish-workspace-server-image.yml'
workflow_dispatch:
# Serialize per-branch so two rapid staging pushes don't race the same
# :staging-latest tag retag. Allow staging and main to run in parallel
# (different GITHUB_REF → different concurrency group) since they
# produce different :staging-<sha> tags and last-write-wins on
# :staging-latest is acceptable across branches.
#
# cancel-in-progress: false → in-flight builds finish; the next push's
# build queues. This avoids a partially-pushed image.
concurrency:
group: publish-workspace-server-image-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible (e.g. permission change, daemon restart, or group-membership
# drift) rather than silently continuing to step 2 where `docker build`
# fails deep in the process with a cryptic ECR auth error that doesn't
# surface the root cause. Also reports the daemon version so operator
# can correlate with runner host logs.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build.
#
# Why: workspace-template-* repos on Gitea are private. The pre-fix
# Dockerfile.tenant ran `git clone` inside an in-image stage with no
# auth path — every CI build failed. We clone in the trusted CI
# context where AUTO_SYNC_TOKEN is available and Dockerfile.tenant
# just COPYs from .tenant-bundle-deps/.
#
# Token: AUTO_SYNC_TOKEN is the devops-engineer persona PAT.
# clone-manifest.sh embeds it as basic-auth for the clones, then
# strips .git dirs — the token never enters the image.
- name: Pre-clone manifest deps
env:
MOLECULE_GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
run: |
set -euo pipefail
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
echo "::error::AUTO_SYNC_TOKEN secret is empty"
exit 1
fi
mkdir -p .tenant-bundle-deps
bash scripts/clone-manifest.sh \
manifest.json \
.tenant-bundle-deps/workspace-configs-templates \
.tenant-bundle-deps/org-templates \
.tenant-bundle-deps/plugins
ws_count=$(find .tenant-bundle-deps/workspace-configs-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
org_count=$(find .tenant-bundle-deps/org-templates -mindepth 1 -maxdepth 1 -type d | wc -l)
plugins_count=$(find .tenant-bundle-deps/plugins -mindepth 1 -maxdepth 1 -type d | wc -l)
echo "Cloned: ws=$ws_count org=$org_count plugins=$plugins_count"
- name: Compute tags
id: tags
run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Build + push platform image (inline ECR auth — mirrors the operator-host
# approach; credentials come from GITHUB_SECRET_AWS_ACCESS_KEY_ID /
# GITHUB_SECRET_AWS_SECRET_ACCESS_KEY in Gitea Actions).
- name: Build & push platform image to ECR (staging-<sha> + staging-latest)
env:
IMAGE_NAME: ${{ env.IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
ECR_REGISTRY="${IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker build \
--file ./workspace-server/Dockerfile \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.description=Molecule AI platform — pending canary verify" \
--tag "${IMAGE_NAME}:${TAG_SHA}" \
--tag "${IMAGE_NAME}:${TAG_LATEST}" \
.
docker push "${IMAGE_NAME}:${TAG_SHA}"
docker push "${IMAGE_NAME}:${TAG_LATEST}"
# Build + push tenant image (Go platform + Next.js canvas in one image).
- name: Build & push tenant image to ECR (staging-<sha> + staging-latest)
env:
TENANT_IMAGE_NAME: ${{ env.TENANT_IMAGE_NAME }}
TAG_SHA: staging-${{ steps.tags.outputs.sha }}
TAG_LATEST: staging-latest
GIT_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: us-east-2
run: |
set -euo pipefail
ECR_REGISTRY="${TENANT_IMAGE_NAME%%/*}"
aws ecr get-login-password --region us-east-2 | \
docker login --username AWS --password-stdin "${ECR_REGISTRY}"
docker build \
--file ./workspace-server/Dockerfile.tenant \
--build-arg NEXT_PUBLIC_PLATFORM_URL= \
--build-arg GIT_SHA="${GIT_SHA}" \
--label "org.opencontainers.image.source=https://github.com/${REPO}" \
--label "org.opencontainers.image.revision=${GIT_SHA}" \
--label "org.opencontainers.image.description=Molecule AI tenant platform + canvas — pending canary verify" \
--tag "${TENANT_IMAGE_NAME}:${TAG_SHA}" \
--tag "${TENANT_IMAGE_NAME}:${TAG_LATEST}" \
.
docker push "${TENANT_IMAGE_NAME}:${TAG_SHA}"
docker push "${TENANT_IMAGE_NAME}:${TAG_LATEST}"

View File

@ -1,191 +0,0 @@
name: Secret scan
# Hard CI gate. Refuses any PR / push whose diff additions contain a
# recognisable credential. Defense-in-depth for the #2090-class incident
# (2026-04-24): GitHub's hosted Copilot Coding Agent leaked a ghs_*
# installation token into tenant-proxy/package.json via `npm init`
# slurping the URL from a token-embedded origin remote. We can't fix
# upstream's clone hygiene, so we gate here.
#
# Same regex set as the runtime's bundled pre-commit hook
# (molecule-ai-workspace-runtime: molecule_runtime/scripts/pre-commit-checks.sh).
# Keep the two sides aligned when adding patterns.
#
# Ported from .github/workflows/secret-scan.yml so the gate actually
# fires on Gitea Actions. Differences from the GitHub version:
# - drops `merge_group` event (Gitea has no merge queue)
# - drops `workflow_call` (no cross-repo reusable invocation on Gitea)
# - SELF path updated to .gitea/workflows/secret-scan.yml
# The job name + step name are identical to the GitHub workflow so the
# status-check context (`Secret scan / Scan diff for credential-shaped
# strings (pull_request)`) matches branch protection on molecule-core/main.
on:
pull_request:
types: [opened, synchronize, reopened]
push:
branches: [main, staging]
jobs:
scan:
name: Scan diff for credential-shaped strings
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2 # need previous commit to diff against on push events
# For pull_request events the diff base may be many commits behind
# HEAD and absent from the shallow clone. Fetch it explicitly.
- name: Fetch PR base SHA (pull_request events only)
if: github.event_name == 'pull_request'
run: git fetch --depth=1 origin ${{ github.event.pull_request.base.sha }}
- name: Refuse if credential-shaped strings appear in diff additions
env:
# Plumb event-specific SHAs through env so the script doesn't
# need conditional `${{ ... }}` interpolation per event type.
# github.event.before/after only exist on push events;
# pull_request has pull_request.base.sha / pull_request.head.sha.
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PUSH_BEFORE: ${{ github.event.before }}
PUSH_AFTER: ${{ github.event.after }}
run: |
# Pattern set covers GitHub family (the actual #2090 vector),
# Anthropic / OpenAI / Slack / AWS. Anchored on prefixes with low
# false-positive rates against agent-generated content. Mirror of
# molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh
# — keep aligned.
SECRET_PATTERNS=(
'ghp_[A-Za-z0-9]{36,}' # GitHub PAT (classic)
'ghs_[A-Za-z0-9]{36,}' # GitHub App installation token
'gho_[A-Za-z0-9]{36,}' # GitHub OAuth user-to-server
'ghu_[A-Za-z0-9]{36,}' # GitHub OAuth user
'ghr_[A-Za-z0-9]{36,}' # GitHub OAuth refresh
'github_pat_[A-Za-z0-9_]{82,}' # GitHub fine-grained PAT
'sk-ant-[A-Za-z0-9_-]{40,}' # Anthropic API key
'sk-proj-[A-Za-z0-9_-]{40,}' # OpenAI project key
'sk-svcacct-[A-Za-z0-9_-]{40,}' # OpenAI service-account key
'sk-cp-[A-Za-z0-9_-]{60,}' # MiniMax API key (F1088 vector — caught only after the fact)
'xox[baprs]-[A-Za-z0-9-]{20,}' # Slack tokens
'AKIA[0-9A-Z]{16}' # AWS access key ID
'ASIA[0-9A-Z]{16}' # AWS STS temp access key ID
)
# Determine the diff base. Each event type stores its SHAs in
# a different place — see the env block above.
case "${{ github.event_name }}" in
pull_request)
BASE="$PR_BASE_SHA"
HEAD="$PR_HEAD_SHA"
;;
*)
BASE="$PUSH_BEFORE"
HEAD="$PUSH_AFTER"
;;
esac
# On push events with shallow clones, BASE may be present in
# the event payload but absent from the local object DB
# (fetch-depth=2 doesn't always reach the previous commit
# across true merges). Try fetching it on demand. If the
# fetch fails — e.g. the SHA was force-overwritten — we fall
# through to the empty-BASE branch below, which scans the
# entire tree as if every file were new. Correct, just slow.
if [ -n "$BASE" ] && ! echo "$BASE" | grep -qE '^0+$'; then
if ! git cat-file -e "$BASE" 2>/dev/null; then
git fetch --depth=1 origin "$BASE" 2>/dev/null || true
fi
fi
# Files added or modified in this change.
if [ -z "$BASE" ] || echo "$BASE" | grep -qE '^0+$' || ! git cat-file -e "$BASE" 2>/dev/null; then
# New branch / no previous SHA / BASE unreachable — check the
# entire tree as added content. Slower, but correct on first
# push.
CHANGED=$(git ls-tree -r --name-only HEAD)
DIFF_RANGE=""
else
CHANGED=$(git diff --name-only --diff-filter=AM "$BASE" "$HEAD")
DIFF_RANGE="$BASE $HEAD"
fi
if [ -z "$CHANGED" ]; then
echo "No changed files to inspect."
exit 0
fi
# Self-exclude: this workflow file legitimately contains the
# pattern strings as regex literals. Without an exclude it would
# block its own merge. Both the .github/ original and this
# .gitea/ port are excluded so a sync between them stays clean.
SELF_GITHUB=".github/workflows/secret-scan.yml"
SELF_GITEA=".gitea/workflows/secret-scan.yml"
OFFENDING=""
# `while IFS= read -r` (not `for f in $CHANGED`) so filenames
# containing whitespace don't word-split silently — a path
# with a space would otherwise produce two iterations on
# tokens that aren't real filenames, breaking the
# self-exclude + diff lookup.
while IFS= read -r f; do
[ -z "$f" ] && continue
[ "$f" = "$SELF_GITHUB" ] && continue
[ "$f" = "$SELF_GITEA" ] && continue
if [ -n "$DIFF_RANGE" ]; then
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
else
# No diff range (new branch first push) — scan the full file
# contents as if every line were new.
ADDED=$(cat "$f" 2>/dev/null || true)
fi
[ -z "$ADDED" ] && continue
for pattern in "${SECRET_PATTERNS[@]}"; do
if echo "$ADDED" | grep -qE "$pattern"; then
OFFENDING="${OFFENDING}${f} (matched: ${pattern})\n"
break
fi
done
done <<< "$CHANGED"
if [ -n "$OFFENDING" ]; then
echo "::error::Credential-shaped strings detected in diff additions:"
# `printf '%b' "$OFFENDING"` interprets backslash escapes
# (the literal `\n` we appended above becomes a newline)
# WITHOUT treating OFFENDING as a format string. Plain
# `printf "$OFFENDING"` is a format-string sink: a filename
# containing `%` would be interpreted as a conversion
# specifier, corrupting the error message (or printing
# `%(missing)` artifacts).
printf '%b' "$OFFENDING"
echo ""
echo "The actual matched values are NOT echoed here, deliberately —"
echo "round-tripping a leaked credential into CI logs widens the blast"
echo "radius (logs are searchable + retained)."
echo ""
echo "Recovery:"
echo " 1. Remove the secret from the file. Replace with an env var"
echo " reference (e.g. \${{ secrets.GITHUB_TOKEN }} in workflows,"
echo " process.env.X in code)."
echo " 2. If the credential was already pushed (this PR's commit"
echo " history reaches a public ref), treat it as compromised —"
echo " ROTATE it immediately, do not just remove it. The token"
echo " remains valid in git history forever and may be in any"
echo " log/cache that consumed this branch."
echo " 3. Force-push the cleaned commit (or stack a revert) and"
echo " re-run CI."
echo ""
echo "If the match is a false positive (test fixture, docs example,"
echo "or this workflow's own regex literals): use a clearly-fake"
echo "placeholder like ghs_EXAMPLE_DO_NOT_USE that doesn't satisfy"
echo "the length suffix, OR add the file path to the SELF exclude"
echo "list in this workflow with a short reason."
echo ""
echo "Mirror of the regex set lives in the runtime's bundled"
echo "pre-commit hook (molecule-ai-workspace-runtime:"
echo "molecule_runtime/scripts/pre-commit-checks.sh) — keep aligned."
exit 1
fi
echo "✓ No credential-shaped strings in this change."

View File

@ -1,100 +0,0 @@
# sop-tier-check — canonical Gitea Actions workflow for §SOP-6 enforcement.
#
# Logic lives in `.gitea/scripts/sop-tier-check.sh` (extracted 2026-05-09
# from the previous inline-bash version). The script is the single source
# of truth; this workflow file just sets env + invokes it.
#
# Copy BOTH files (`.gitea/workflows/sop-tier-check.yml` +
# `.gitea/scripts/sop-tier-check.sh`) into any repo that wants the
# §SOP-6 PR gate enforced. Pair with branch protection on the protected
# branch:
# required_status_checks: ["sop-tier-check / tier-check (pull_request)"]
# required_approving_reviews: 1
# approving_review_teams: ["ceo", "managers", "engineers"]
#
# Tier → required-team expression (internal#189 AND-composition):
# tier:low → engineers,managers,ceo (OR: any one suffices)
# tier:medium → managers AND engineers AND qa???,security??? (AND: all required)
# tier:high → ceo (OR: single team, wired for AND)
#
# "???" = teams not yet created in Gitea. When qa + security teams are
# added, update TIER_EXPR["tier:medium"] in the script to remove the
# markers. PRs already in-flight when qa/security are created continue
# to work because their authors explicitly requested those reviews.
#
# Force-merge: Owners-team override remains available out-of-band via
# the Gitea merge API; force-merge writes `incident.force_merge` to
# `structure_events` per §Persistent structured logging gate (Phase 3).
#
# Environment variables:
# SOP_DEBUG=1 — per-API-call diagnostic lines. Default: off.
# SOP_LEGACY_CHECK=1 — revert to OR-gate for this run. Grace window
# for PRs in-flight when AND-composition deployed.
# Burn-in: remove after 2026-05-17 (7-day window).
#
# BURN-IN NOTE (internal#189 Phase 1): continue-on-error: true is set on
# the tier-check job below. This prevents AND-composition from blocking
# PRs during the 7-day burn-in. After 2026-05-17:
# 1. Remove `continue-on-error: true` from this job block.
# 2. Update this BURN-IN NOTE comment to mark the window closed.
name: sop-tier-check
# SECURITY: triggers MUST use `pull_request_target`, not `pull_request`.
# `pull_request_target` loads the workflow definition from the BASE
# branch (i.e. `main`), not the PR's HEAD. With `pull_request`, anyone
# with write access to a feature branch could rewrite this file in
# their PR to dump SOP_TIER_CHECK_TOKEN (org-read scope) to logs and
# exfiltrate it. Verified 2026-05-09 against Gitea 1.22.6 —
# `pull_request_target` (added in Gitea 1.21 via go-gitea/gitea#25229)
# is the documented mitigation.
#
# This workflow does NOT call `actions/checkout` of PR HEAD code, so no
# untrusted code is ever executed in the runner — we only HTTP-call the
# Gitea API. If a future change adds a checkout step, it MUST pin to
# `${{ github.event.pull_request.base.sha }}` (NOT `head.sha`) to keep
# the trust boundary.
on:
pull_request_target:
types: [opened, edited, synchronize, reopened, labeled, unlabeled]
pull_request_review:
types: [submitted, dismissed, edited]
jobs:
tier-check:
runs-on: ubuntu-latest
# BURN-IN: continue-on-error prevents AND-composition from blocking
# PRs during the 7-day window. Remove after 2026-05-17 (internal#189).
continue-on-error: true
permissions:
contents: read
pull-requests: read
steps:
- name: Check out base branch (for the script)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# Pin to base.sha — pull_request_target's protection only
# works if we never check out PR HEAD. Same SHA the workflow
# itself was loaded from.
ref: ${{ github.event.pull_request.base.sha }}
- name: Verify tier label + reviewer team membership
env:
# SOP_TIER_CHECK_TOKEN is the org-level secret for the
# sop-tier-bot PAT (read:organization,read:user,read:issue,
# read:repository). Stored at the org level
# (/api/v1/orgs/molecule-ai/actions/secrets) so per-repo
# configuration is unnecessary — every repo in the org
# picks it up automatically.
# Falls back to GITHUB_TOKEN with a clear error if missing.
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
GITEA_HOST: git.moleculesai.app
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
# Set to '1' for diagnostic per-API-call output. Off by default
# so production logs aren't noisy.
SOP_DEBUG: '0'
# BURN-IN: set to '1' for PRs in-flight at AND-composition deploy
# time to use the legacy OR-gate. Remove after 2026-05-17.
SOP_LEGACY_CHECK: '0'
run: bash .gitea/scripts/sop-tier-check.sh

View File

@ -37,7 +37,7 @@ CANONICAL_FILE = Path(".github/workflows/secret-scan.yml")
CONSUMERS: list[tuple[str, str]] = [ CONSUMERS: list[tuple[str, str]] = [
( (
"molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh", "molecule-ai-workspace-runtime/molecule_runtime/scripts/pre-commit-checks.sh",
"https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime/raw/branch/main/molecule_runtime/scripts/pre-commit-checks.sh", "https://raw.githubusercontent.com/Molecule-AI/molecule-ai-workspace-runtime/main/molecule_runtime/scripts/pre-commit-checks.sh",
), ),
] ]

View File

@ -0,0 +1,467 @@
name: Auto-promote :latest after main image build
# Retags `ghcr.io/molecule-ai/{platform,platform-tenant}:staging-<sha>`
# → `:latest` after either the image build or E2E completes on a `main`
# push, gated on E2E Staging SaaS not being red for that SHA.
#
# Why two triggers:
#
# `publish-workspace-server-image` and `e2e-staging-saas` are both
# paths-filtered, but with DIFFERENT path sets:
#
# publish-workspace-server-image:
# workspace-server/**, canvas/**, manifest.json
#
# e2e-staging-saas (full lifecycle):
# workspace-server/internal/handlers/{registry,workspace_provision,
# a2a_proxy}.go, workspace-server/internal/middleware/**,
# workspace-server/internal/provisioner/**, tests/e2e/test_staging_full_saas.sh
#
# The E2E set is a strict SUBSET of the publish set. So:
# - canvas/** changes → publish fires, E2E does not
# - workspace-server/cmd/** changes → publish fires, E2E does not
# - workspace-server/internal/sweep/** → publish fires, E2E does not
#
# The previous version triggered ONLY on E2E completion, which meant
# non-E2E-path changes (canvas, cmd, sweep, etc.) rebuilt the image
# but never advanced `:latest`. Result: as of 2026-04-28 this workflow
# had run zero times since merge despite eight main pushes — `:latest`
# was ~7 hours / 9 PRs behind main with no human realising. See
# `molecule-core` Slack discussion 2026-04-28.
#
# Adding `publish-workspace-server-image` as a second trigger closes
# the gap: any image rebuild on main eligibly advances `:latest`.
#
# Why E2E remains a kill-switch (not the trigger):
#
# When E2E DID run for this SHA and ended red, we abort — `:latest`
# stays on the prior known-good digest. When E2E didn't run (paths
# filtered out), we proceed: pre-merge gates already validated this
# SHA on staging via auto-promote-staging requiring CI + E2E Canvas +
# E2E API + CodeQL all green. Image content for non-E2E-paths
# (canvas, cmd, sweep) is exercised by those staging gates.
#
# Why `main` only:
#
# `:latest` is what prod tenants pull. We only want SHAs that have
# reached main (via auto-promote-staging) to advance `:latest`.
# Triggering on staging would let a staging-only revert advance
# `:latest` to a SHA that never reaches main, breaking the "production
# runs what's on main" invariant.
#
# Idempotency:
#
# When a SHA touches paths that match BOTH publish and E2E, both
# workflows fire and complete. Both trigger this workflow on
# completion → two runs race. Both retag `:staging-<sha>` →
# `:latest`. crane tag is idempotent (re-tagging the same digest is a
# no-op), so the second run is harmless. concurrency group serializes
# them anyway.
on:
workflow_run:
workflows:
- 'E2E Staging SaaS (full lifecycle)'
- 'publish-workspace-server-image'
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
sha:
description: 'Short sha to promote (override; defaults to upstream workflow_run head_sha)'
required: false
type: string
permissions:
contents: read
packages: write
concurrency:
# Serialize promotes per-SHA so the publish+E2E both-fired race lands
# cleanly. Different SHAs can promote in parallel.
group: auto-promote-latest-${{ github.event.workflow_run.head_sha || github.event.inputs.sha || github.sha }}
cancel-in-progress: false
env:
IMAGE_NAME: ghcr.io/molecule-ai/platform
TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant
jobs:
promote:
# Proceed if upstream succeeded OR manual dispatch. Upstream-failure
# paths are filtered here; the E2E-was-red kill-switch lives in the
# gate-check step below (covers the case where upstream is publish
# success but E2E for the same SHA failed).
if: |
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
runs-on: ubuntu-latest
steps:
- name: Compute short sha
id: sha
run: |
set -euo pipefail
if [ -n "${{ github.event.inputs.sha }}" ]; then
FULL="${{ github.event.inputs.sha }}"
else
FULL="${{ github.event.workflow_run.head_sha }}"
fi
echo "short=${FULL:0:7}" >> "$GITHUB_OUTPUT"
echo "full=${FULL}" >> "$GITHUB_OUTPUT"
- name: Gate — E2E Staging SaaS state for this SHA
# When upstream IS E2E success, we know it's green (filtered by
# the job-level `if` already). When upstream is publish, look up
# E2E state for the same SHA. Four buckets:
#
# - completed/success: E2E confirmed safe → proceed
# - completed/failure|cancelled|timed_out: E2E found a
# regression → ABORT (exit 1), `:latest` stays put
# - in_progress|queued|requested: E2E is RACING with publish
# for a runtime-touching SHA. publish typically completes
# ~5-10min before E2E (~10-15min). If we promote on the
# publish signal here, a later E2E failure can't roll back
# `:latest` — it'd already be wrongly advanced. So we DEFER:
# skip subsequent steps (proceed=false) and let E2E's own
# completion event re-fire this workflow, which then takes
# the upstream-is-E2E path. exit 0 so the run shows as
# success rather than a noisy fake-failure.
# - none/none: E2E was paths-filtered out for this SHA (the
# change touched canvas/cmd/sweep/etc. — paths covered by
# publish but not by E2E). pre-merge gates on staging
# already validated this SHA → proceed.
#
# Manual dispatch skips this check — operator override.
id: gate
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
SHA: ${{ steps.sha.outputs.full }}
UPSTREAM_NAME: ${{ github.event.workflow_run.name }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
echo "::notice::Manual dispatch — skipping E2E gate (operator override)"
exit 0
fi
if [ "$UPSTREAM_NAME" = "E2E Staging SaaS (full lifecycle)" ]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
echo "::notice::Upstream is E2E itself (success per job-level if) — gate trivially satisfied"
exit 0
fi
# Upstream is publish-workspace-server-image. Check E2E state
# for the same SHA via Gitea's commit-status API.
#
# GitHub-era this was `gh run list --workflow=X --commit=SHA
# --json status,conclusion` returning either `[]` (no run on
# this SHA) or `[{status, conclusion}]` (the run's state).
# Gitea has NO workflow-runs API at all — `/api/v1/repos/.../
# actions/runs` returns 404 (verified 2026-05-07, issue #75).
# However Gitea Actions DOES emit a commit status per workflow
# job, with `context = "<Workflow Name> / <Job Name> (<event>)"`,
# which is exactly what we need: each E2E run leg becomes one
# status row on the SHA, and the aggregate state encodes the
# run's outcome.
#
# Mapping:
# 0 matched contexts → "none/none" (E2E paths-
# filtered
# out — same
# semantic
# as before)
# any context = pending → "in_progress/none" (defer)
# any context = error|failure → "completed/failure" (abort)
# all contexts = success → "completed/success" (proceed)
#
# The "completed/cancelled" and "completed/timed_out" buckets
# don't have direct Gitea analogs (Gitea statuses are
# success / failure / error / pending / warning). Per-SHA
# concurrency cancellation surfaces as `error` on Gitea, which
# we map to "completed/failure" rather than "completed/cancelled"
# — losing the soft-defer semantic of the cancelled bucket on
# this fleet. Tradeoff: the staleness alarm (auto-promote-stale-
# alarm.yml) still catches a stuck :latest within 4h, and a
# legitimate cancel is rare enough that aborting + manual
# re-dispatch is acceptable. If we measure cancel frequency
# > 1/week, revisit by reading the run-step-summary text via
# a follow-up script.
#
# Network or auth blips collapse to "none/none" via the curl
# `|| true` fallback, matching the pre-Gitea behaviour where
# an empty list also degenerated to none/none.
GITEA_API_URL="${GITHUB_SERVER_URL:-https://git.moleculesai.app}/api/v1"
STATUSES_JSON=$(curl --fail-with-body -sS \
-H "Authorization: token ${GH_TOKEN}" \
-H "Accept: application/json" \
"${GITEA_API_URL}/repos/${REPO}/commits/${SHA}/statuses?limit=100" \
2>/dev/null || echo "[]")
RESULT=$(printf '%s' "$STATUSES_JSON" | jq -r '
# Filter to E2E Staging SaaS (full lifecycle) statuses.
# Match by leading workflow-name prefix so the "<job>
# (<event>)" tail is irrelevant. Gitea emits the workflow
# name verbatim from the YAML `name:` field.
[.[] | select(.context | startswith("E2E Staging SaaS (full lifecycle) /"))] as $rows
| if ($rows | length) == 0 then
"none/none"
elif any($rows[]; .status == "pending") then
"in_progress/none"
elif any($rows[]; .status == "failure" or .status == "error") then
"completed/failure"
elif all($rows[]; .status == "success") then
"completed/success"
else
# Mixed / unknown — fall through to *) bucket below.
"completed/" + ($rows[0].status // "unknown")
end
' 2>/dev/null || echo "none/none")
echo "E2E Staging SaaS for ${SHA:0:7}: $RESULT"
case "$RESULT" in
completed/success)
echo "proceed=true" >> "$GITHUB_OUTPUT"
echo "::notice::E2E green for this SHA — proceeding with promote"
;;
completed/failure|completed/timed_out)
echo "proceed=false" >> "$GITHUB_OUTPUT"
{
echo "## ❌ Auto-promote aborted — E2E Staging SaaS failed"
echo
echo "E2E Staging SaaS for \`${SHA:0:7}\`: \`$RESULT\`"
echo "\`:latest\` stays on the prior known-good digest."
echo
echo "If the failure was a flake, manually dispatch this workflow with the same sha to override."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
;;
completed/cancelled)
# GitHub-era only: cancelled ≠ failure. Gitea statuses
# don't expose a "cancelled" state — a per-SHA concurrency
# cancellation surfaces as `failure` or `error` on Gitea
# and is now handled by the failure branch above. This
# arm is kept for backwards compatibility / dual-host
# operation (if we ever add a non-Gitea fallback) but
# under the post-#75 flow it's unreachable.
echo "proceed=false" >> "$GITHUB_OUTPUT"
{
echo "## ⏭ Auto-promote deferred — E2E Staging SaaS was cancelled"
echo
echo "E2E Staging SaaS for \`${SHA:0:7}\`: \`$RESULT\`"
echo "Likely per-SHA concurrency (newer push superseded this E2E run)."
echo "The newer SHA's E2E will fire its own promote when it lands."
echo "If you need this specific SHA promoted, manually dispatch."
} >> "$GITHUB_STEP_SUMMARY"
;;
in_progress/*|queued/*|requested/*|waiting/*|pending/*)
echo "proceed=false" >> "$GITHUB_OUTPUT"
{
echo "## ⏳ Auto-promote deferred — E2E Staging SaaS still running"
echo
echo "Publish completed before E2E for \`${SHA:0:7}\` (state: \`$RESULT\`)."
echo "Skipping retag here — E2E's own completion event will re-fire this workflow."
echo "If E2E ends green, that run promotes \`:latest\`. If red, it aborts."
} >> "$GITHUB_STEP_SUMMARY"
;;
none/none)
echo "proceed=true" >> "$GITHUB_OUTPUT"
echo "::notice::E2E paths-filtered out for this SHA — pre-merge staging gates carry"
;;
*)
echo "proceed=false" >> "$GITHUB_OUTPUT"
{
echo "## ❓ Auto-promote aborted — unexpected E2E state"
echo
echo "E2E Staging SaaS for \`${SHA:0:7}\`: \`$RESULT\` (unhandled)"
echo "Manual investigation needed; re-dispatch with the same sha once resolved."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
;;
esac
- if: steps.gate.outputs.proceed == 'true'
uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
- name: GHCR login
if: steps.gate.outputs.proceed == 'true'
run: |
echo "${{ secrets.GITHUB_TOKEN }}" | \
crane auth login ghcr.io -u "${{ github.actor }}" --password-stdin
- name: Verify :staging-<sha> exists for both images
# Better to fail fast with a clear message than to half-tag
# (platform retagged but platform-tenant missing → tenants pull
# a stale image).
if: steps.gate.outputs.proceed == 'true'
run: |
set -euo pipefail
for img in "${IMAGE_NAME}" "${TENANT_IMAGE_NAME}"; do
tag="${img}:staging-${{ steps.sha.outputs.short }}"
if ! crane manifest "$tag" >/dev/null 2>&1; then
echo "::error::Missing tag: $tag"
echo "::error::publish-workspace-server-image must complete on this SHA before auto-promote can retag :latest."
exit 1
fi
echo " ok: $tag exists"
done
- name: Ancestry check — refuse to promote :latest backwards
# #2244: workflow_run completions arrive in arbitrary order. If
# SHA-A and SHA-B both reach main within ~10 min and SHA-B's E2E
# completes before SHA-A's, this workflow can fire for SHA-A
# AFTER it already promoted SHA-B → :latest goes backwards. The
# orphan-reconciler "next run corrects it" doesn't apply: there's
# no auto-corrective re-promote, :latest stays wrong until the
# next main push lands.
#
# Detection: read current :latest's `org.opencontainers.image.revision`
# label (set by publish-workspace-server-image.yml at build time)
# and ask the GitHub compare API whether the candidate SHA is
# ahead-of / identical-to / behind / diverged-from current.
# Hard-fail on `behind` and `diverged` per the approved design —
# silent-bypass is the class we're moving away from. Workflow
# goes red, oncall sees it, operator decides how to recover
# (manual dispatch with the right SHA, force-promote, etc.).
#
# Manual dispatch skips this check — operator override semantics
# match the gate-check step above.
#
# Backward-compat: when current :latest carries no revision
# label (legacy image pre-publish-with-label), skip-with-warning.
# All :latest images on main are post-label as of 2026-04-29, so
# this branch will be dead within 90 days; remove then.
if: steps.gate.outputs.proceed == 'true' && github.event_name != 'workflow_dispatch'
id: ancestry
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
TARGET_SHA: ${{ steps.sha.outputs.full }}
run: |
set -euo pipefail
# Read the current :latest config and pull the revision label.
# `crane config` returns the OCI image config blob (not the manifest);
# labels live under `.config.Labels`. `// empty` makes jq return ""
# rather than the literal "null" so the test below works.
CURRENT_REVISION=$(crane config "${IMAGE_NAME}:latest" 2>/dev/null \
| jq -r '.config.Labels["org.opencontainers.image.revision"] // empty' \
|| true)
if [ -z "$CURRENT_REVISION" ]; then
echo "decision=skip-no-label" >> "$GITHUB_OUTPUT"
{
echo "## ⚠ Ancestry check skipped — current :latest has no revision label"
echo
echo "Likely a legacy image built before \`org.opencontainers.image.revision\` was set."
echo "Falling through to retag. After all \`:latest\` images are post-label (TODO 90 days), this branch is dead and should be removed."
} >> "$GITHUB_STEP_SUMMARY"
echo "::warning::Current :latest carries no revision label — skipping ancestry check (legacy image)"
exit 0
fi
if [ "$CURRENT_REVISION" = "$TARGET_SHA" ]; then
echo "decision=identical" >> "$GITHUB_OUTPUT"
echo "::notice:::latest already at ${TARGET_SHA:0:7} — retag will be a no-op"
exit 0
fi
# Ask GitHub which side of the merge graph TARGET_SHA sits on
# relative to CURRENT_REVISION. Returns one of: ahead | identical
# | behind | diverged. Network or auth errors collapse to "error"
# via the explicit fallback so the case below always matches.
STATUS=$(gh api \
"repos/${REPO}/compare/${CURRENT_REVISION}...${TARGET_SHA}" \
--jq '.status' 2>/dev/null || echo "error")
echo "ancestry compare ${CURRENT_REVISION:0:7} → ${TARGET_SHA:0:7}: $STATUS"
case "$STATUS" in
ahead)
echo "decision=ahead" >> "$GITHUB_OUTPUT"
echo "::notice::Target ${TARGET_SHA:0:7} is ahead of current :latest (${CURRENT_REVISION:0:7}) — proceeding with retag"
;;
identical)
echo "decision=identical" >> "$GITHUB_OUTPUT"
echo "::notice::Target identical to :latest — retag will be a no-op"
;;
behind)
echo "decision=behind" >> "$GITHUB_OUTPUT"
{
echo "## ❌ Auto-promote refused — target is BEHIND current :latest"
echo
echo "| Field | Value |"
echo "|---|---|"
echo "| Target SHA | \`$TARGET_SHA\` |"
echo "| Current :latest revision | \`$CURRENT_REVISION\` |"
echo "| GitHub compare status | \`behind\` |"
echo
echo "This guard catches the workflow_run-completion-order race (#2244):"
echo "two rapid main pushes whose E2Es complete out-of-order can otherwise"
echo "promote \`:latest\` backwards. \`:latest\` stays on \`${CURRENT_REVISION:0:7}\`."
echo
echo "**Recovery:** if this is a legitimate revert that should land on \`:latest\`,"
echo "manually dispatch this workflow with the target sha as input — the manual-dispatch"
echo "path skips the ancestry check (operator override)."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
;;
diverged)
echo "decision=diverged" >> "$GITHUB_OUTPUT"
{
echo "## ❓ Auto-promote refused — history diverged"
echo
echo "| Field | Value |"
echo "|---|---|"
echo "| Target SHA | \`$TARGET_SHA\` |"
echo "| Current :latest revision | \`$CURRENT_REVISION\` |"
echo "| GitHub compare status | \`diverged\` |"
echo
echo "Likely cause: force-push rewrote main's history, leaving the previous"
echo "\`:latest\` revision orphaned. Needs human review before \`:latest\` advances."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
;;
error|*)
echo "decision=error" >> "$GITHUB_OUTPUT"
{
echo "## ❌ Auto-promote aborted — ancestry-check API error"
echo
echo "\`gh api repos/${REPO}/compare/${CURRENT_REVISION}...${TARGET_SHA}\` returned unexpected status: \`$STATUS\`"
echo
echo "Manual dispatch with the target sha bypasses this check."
} >> "$GITHUB_STEP_SUMMARY"
exit 1
;;
esac
- name: Retag platform :staging-<sha> → :latest
if: steps.gate.outputs.proceed == 'true'
run: |
crane tag "${IMAGE_NAME}:staging-${{ steps.sha.outputs.short }}" latest
- name: Retag tenant :staging-<sha> → :latest
if: steps.gate.outputs.proceed == 'true'
run: |
crane tag "${TENANT_IMAGE_NAME}:staging-${{ steps.sha.outputs.short }}" latest
- name: Summary
if: steps.gate.outputs.proceed == 'true'
run: |
{
echo "## :latest promoted to ${{ steps.sha.outputs.short }}"
echo
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "- Trigger: manual dispatch"
else
echo "- Upstream: \`${{ github.event.workflow_run.name }}\` ([run](${{ github.event.workflow_run.html_url }}))"
fi
echo "- platform:staging-${{ steps.sha.outputs.short }} → :latest"
echo "- platform-tenant:staging-${{ steps.sha.outputs.short }} → :latest"
echo
echo "Tenant fleet auto-pulls within 5 min via IMAGE_AUTO_REFRESH=true."
echo "Force immediate fanout: dispatch redeploy-tenants-on-main.yml."
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -0,0 +1,492 @@
name: Auto-promote staging → main
# Fires after any of the staging-branch quality gates complete. When ALL
# required gates are green on the same staging SHA, opens (or re-uses)
# a PR `staging → main` and schedules Gitea auto-merge so the PR lands
# automatically once approval + status checks are satisfied.
#
# ============================================================
# What this workflow does
# ============================================================
#
# 1. On a workflow_run completion event for one of the staging gate
# workflows (CI, E2E Staging Canvas, E2E API Smoke, CodeQL),
# checks if the combined status on the staging head SHA is green.
# 2. If green, opens (or re-uses) a PR `head: staging → base: main`
# via Gitea REST `POST /api/v1/repos/.../pulls`.
# 3. Schedules auto-merge via `POST /api/v1/repos/.../pulls/{index}/merge`
# with `merge_when_checks_succeed: true`. Gitea waits for the
# approval requirement on `main` (`required_approvals: 1`) and
# the status-check gates, then merges.
# 4. The merge commit lands on `main` and fires
# `publish-workspace-server-image.yml` naturally via its
# `on: push: branches: [main]` trigger — no explicit dispatch
# needed (see "Why no workflow_dispatch tail" below).
#
# `auto-sync-main-to-staging.yml` is the reverse-direction
# counterpart (main → staging, fast-forward push). Together they
# keep the staging-superset-of-main invariant tight.
#
# ============================================================
# Why Gitea REST (and not `gh pr create`)
# ============================================================
#
# Pre-2026-05-06 this workflow used `gh pr create`, `gh pr merge --auto`,
# `gh run list`, and `gh workflow run` against GitHub. After the
# GitHub→Gitea cutover those calls fail because:
#
# - `gh pr create / merge / view / list` route to GitHub GraphQL
# (`/api/graphql`). Gitea does not expose a GraphQL endpoint;
# every call returns `HTTP 405 Method Not Allowed` — same root
# cause as #65 (auto-sync) which PR #66 fixed by dropping `gh`
# entirely.
# - `gh run list --workflow=...` GitHub-shape; Gitea has the
# simpler `GET /repos/.../commits/{ref}/status` combined-status
# endpoint instead.
# - `gh workflow run X.yml` calls `POST /repos/.../actions/workflows/{id}/dispatches`,
# which does NOT exist on Gitea 1.22.6 (verified via swagger.v1.json).
#
# So this workflow uses direct `curl` calls to Gitea REST. No `gh`
# CLI dependency, no GraphQL, no missing-endpoint footgun.
#
# ============================================================
# Why no workflow_dispatch tail (was load-bearing on GitHub, dead on Gitea)
# ============================================================
#
# The GitHub-era version had a 60-line polling step that waited for
# the promote PR to merge, then explicitly dispatched
# `publish-workspace-server-image.yml` on `--ref main`. That step
# existed because GitHub's GITHUB_TOKEN-initiated merges suppress
# downstream `on: push` workflows (the documented "no recursion" rule
# — https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow).
# The explicit dispatch was the workaround.
#
# Gitea Actions does NOT have this no-recursion rule. PR #66's auto-
# sync merge to main fired `auto-promote-staging` on the next push
# trigger naturally. So the cascade fires on the natural push event;
# the explicit dispatch is dead code. (And even if we wanted to
# preserve it, Gitea has no `workflow_dispatch` REST endpoint.)
#
# Removed in this rewrite. If we ever observe the cascade misfire,
# operator can push an empty commit to `main` to wake it.
#
# ============================================================
# Why open a PR (and not direct push)
# ============================================================
#
# `main` branch protection has `enable_push: false` with NO
# `push_whitelist_usernames`. Direct push is impossible for any
# persona, including admins. PR-mediated merge is the only path,
# which is intentional: prod state mutations (and staging→main IS a
# prod mutation, since the next deploy fans out to tenants) require
# Hongming's approval per `feedback_prod_apply_needs_hongming_chat_go`.
#
# The auto-merge schedule preserves this gate: `merge_when_checks_succeed`
# does NOT bypass `required_approvals: 1`. Gitea waits for BOTH
# approval AND green checks before merging. Hongming reviews via the
# canvas/chat-handle of the PR notification, approves, and Gitea
# auto-merges within seconds.
#
# ============================================================
# Identity + token (anti-bot-ring per saved-memory
# `feedback_per_agent_gitea_identity_default`)
# ============================================================
#
# This workflow uses `secrets.AUTO_SYNC_TOKEN` — a personal access
# token issued to the `devops-engineer` Gitea persona. NOT the
# founder PAT. The bot-ring fingerprint that triggered the GitHub
# org suspension on 2026-05-06 was characterised by founder PAT
# acting as CI at machine speed.
#
# Token scope: `push: true` (read+write) on this repo. The persona
# can: open PRs, comment on PRs, schedule auto-merge. The persona
# CANNOT bypass main's branch protection (`required_approvals: 1`
# still applies — only Hongming's review unblocks merge).
#
# Authorship: the PR is opened by `devops-engineer`; the merge
# commit credits Hongming-as-approver and `devops-engineer` as
# the merger.
#
# ============================================================
# Failure modes & operational notes
# ============================================================
#
# A — staging gates not all green at trigger time:
# - The combined-status check returns `state: pending|failure`.
# Workflow exits 0 with a step-summary "not all green; staying
# on current main". Re-fires on the next gate completion.
#
# B — Gitea PR-create returns non-201 (e.g. 422 already-exists):
# - Idempotent: the workflow first GETs the existing open
# staging→main PR. If found, reuse it; if not, POST a new one.
# 422 should never surface; if it does (race), step summary
# captures the body and the next workflow_run picks up.
#
# C — `merge_when_checks_succeed` schedule fails:
# - 422 with "Pull request is not mergeable" if there are
# conflicts or stale base. Step summary surfaces it; operator
# (or `auto-sync-main-to-staging`) needs to bring staging up
# to date with main first. Workflow exits 1 to surface red.
#
# D — `AUTO_SYNC_TOKEN` rotated / wrong scope:
# - 401/403 on first REST call. Step summary surfaces it.
# Re-issue the token from `~/.molecule-ai/personas/` on the
# operator host and update the repo Actions secret.
#
# ============================================================
# Loop safety
# ============================================================
#
# When the promote PR merges to main, `auto-sync-main-to-staging.yml`
# fires (on:push:main) and pushes the merge commit back to staging.
# That push to staging is by `devops-engineer`, NOT this workflow's
# token, and triggers the staging gate workflows. When they all
# complete, we end up back here — but the tree-diff guard catches
# it: staging tree == main tree (the merge commit changes nothing),
# so we skip and the cycle terminates.
on:
workflow_run:
workflows:
- CI
- E2E Staging Canvas (Playwright)
- E2E API Smoke Test
- CodeQL
types: [completed]
workflow_dispatch:
inputs:
force:
description: "Force promote even when AUTO_PROMOTE_ENABLED is unset (manual override)"
required: false
default: "false"
permissions:
contents: read
pull-requests: write
# Serialize auto-promote runs. Multiple staging gate completions can land
# in quick succession (CI + E2E + CodeQL all finish within seconds of
# each other on a green PR) — without this, two parallel runs both:
# 1. Would race the GET-or-POST PR step.
# 2. Would both call merge-schedule (idempotent — fine on Gitea).
# cancel-in-progress: false because the second run on a fresh staging
# tip should NOT kill the first which has already opened the PR.
concurrency:
group: auto-promote-staging
cancel-in-progress: false
jobs:
check-all-gates-green:
# Only consider staging pushes. PRs into staging don't promote.
if: >
(github.event_name == 'workflow_run' &&
github.event.workflow_run.head_branch == 'staging' &&
github.event.workflow_run.event == 'push')
|| github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
outputs:
all_green: ${{ steps.gates.outputs.all_green }}
head_sha: ${{ steps.gates.outputs.head_sha }}
steps:
# Skip empty-tree promotes (the perpetual auto-promote↔auto-sync
# cycle observed pre-cutover on GitHub). On Gitea the cycle shape
# is different (auto-sync uses fast-forward, no merge commit),
# but the tree-diff guard is cheap insurance and protects against
# any future merge-style regression.
- name: Checkout for tree-diff check
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: staging
- name: Skip if staging tree == main tree (cycle-break safety)
id: tree-diff
env:
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
run: |
set -eu
git fetch origin main --depth=50 || { echo "::warning::git fetch main failed — proceeding (fail-open)"; exit 0; }
if git diff --quiet origin/main "$HEAD_SHA" -- 2>/dev/null; then
{
echo "## Skipped — no code to promote"
echo
echo "staging tip (\`${HEAD_SHA:0:8}\`) and \`main\` have identical trees."
echo "Skipping to avoid opening an empty promote PR."
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::auto-promote: staging tree == main tree — no code to promote, skipping"
echo "skip=true" >> "$GITHUB_OUTPUT"
else
echo "skip=false" >> "$GITHUB_OUTPUT"
fi
- name: Check combined status on staging head
if: steps.tree-diff.outputs.skip != 'true'
id: gates
env:
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
REPO: ${{ github.repository }}
GITEA_HOST: ${{ vars.GITEA_HOST || 'https://git.moleculesai.app' }}
run: |
set -euo pipefail
# Gitea-native combined-status endpoint aggregates every
# check context attached to a SHA. This is structurally
# cleaner than the GitHub-era per-workflow `gh run list`
# loop because:
#
# 1. There's no risk of "workflow name collision" (the
# GitHub-era code had to switch from `--workflow=NAME`
# to `--workflow=FILE.YML` to disambiguate "CodeQL"
# between the explicit workflow and GitHub's UI-
# configured default setup; Gitea has no such
# duplicate-name surface).
# 2. Gitea's combined state already encodes the AND
# across all contexts: success only if EVERY context
# is success. Pending or failure on any context
# produces non-success state.
#
# See https://docs.gitea.com/api/1.22 for the schema —
# `state` is one of: success, pending, failure, error.
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
echo "Checking combined status on SHA ${HEAD_SHA}"
# `set +o pipefail` for the http-code capture pattern; restore
# immediately. Pattern hardened per `feedback_curl_status_capture_pollution`.
BODY_FILE=$(mktemp)
set +e
STATUS=$(curl -sS \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Accept: application/json" \
-o "${BODY_FILE}" \
-w "%{http_code}" \
"${GITEA_HOST}/api/v1/repos/${REPO}/commits/${HEAD_SHA}/status")
CURL_RC=$?
set -e
if [ "${CURL_RC}" -ne 0 ] || [ "${STATUS}" != "200" ]; then
echo "::error::combined-status fetch failed: curl=${CURL_RC} http=${STATUS}"
cat "${BODY_FILE}" | head -c 500 || true
rm -f "${BODY_FILE}"
echo "all_green=false" >> "$GITHUB_OUTPUT"
exit 0
fi
STATE=$(jq -r '.state // "missing"' < "${BODY_FILE}")
TOTAL=$(jq -r '.total_count // 0' < "${BODY_FILE}")
rm -f "${BODY_FILE}"
echo "Combined status: state=${STATE} total_count=${TOTAL}"
if [ "${STATE}" = "success" ] && [ "${TOTAL}" -gt 0 ]; then
echo "all_green=true" >> "$GITHUB_OUTPUT"
echo "::notice::All gates green on ${HEAD_SHA} (${TOTAL} contexts)"
else
echo "all_green=false" >> "$GITHUB_OUTPUT"
{
echo "## Not promoting — combined status not green"
echo
echo "- SHA: \`${HEAD_SHA:0:8}\`"
echo "- Combined state: \`${STATE}\`"
echo "- Context count: ${TOTAL}"
echo
echo "Will re-fire on the next gate completion. Investigate any red gate via the Actions UI."
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::auto-promote: combined status is ${STATE} on ${HEAD_SHA} — staying on current main"
fi
promote:
needs: check-all-gates-green
if: needs.check-all-gates-green.outputs.all_green == 'true'
runs-on: ubuntu-latest
steps:
- name: Check rollout gate
env:
AUTO_PROMOTE_ENABLED: ${{ vars.AUTO_PROMOTE_ENABLED }}
FORCE_INPUT: ${{ github.event.inputs.force }}
run: |
set -eu
# Repo variable AUTO_PROMOTE_ENABLED=true flips this on. While
# it's unset, the workflow dry-runs (logs what it would have
# done) but doesn't open the promote PR. Set the variable in
# Settings → Actions → Variables.
if [ "${AUTO_PROMOTE_ENABLED:-}" != "true" ] && [ "${FORCE_INPUT:-false}" != "true" ]; then
{
echo "## Auto-promote disabled"
echo
echo "Repo variable \`AUTO_PROMOTE_ENABLED\` is not set to \`true\`."
echo "All gates are green on staging; would have opened a promote PR to \`main\`."
echo
echo "To enable: Settings → Actions → Variables → \`AUTO_PROMOTE_ENABLED=true\`."
echo "To test once manually: workflow_dispatch with \`force=true\`."
} >> "$GITHUB_STEP_SUMMARY"
echo "::notice::auto-promote disabled — dry run only"
exit 0
fi
- name: Open or reuse promote PR + schedule auto-merge
if: ${{ vars.AUTO_PROMOTE_ENABLED == 'true' || github.event.inputs.force == 'true' }}
env:
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
REPO: ${{ github.repository }}
TARGET_SHA: ${{ needs.check-all-gates-green.outputs.head_sha }}
GITEA_HOST: ${{ vars.GITEA_HOST || 'https://git.moleculesai.app' }}
run: |
set -euo pipefail
API="${GITEA_HOST}/api/v1/repos/${REPO}"
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
# http_status_get RESULT_VAR URL
# Sets RESULT_VAR to "<http_code>:<body_file>". Curl status
# capture pattern per `feedback_curl_status_capture_pollution`:
# http_code goes to its own tempfile-equivalent (-w), body to
# another tempfile, set +e/-e bracket protects pipeline state.
http_get() {
local body_file="$1"; shift
local url="$1"; shift
set +e
local code
code=$(curl -sS "${AUTH[@]}" -o "${body_file}" -w "%{http_code}" "${url}")
local rc=$?
set -e
if [ "${rc}" -ne 0 ]; then
echo "::error::curl GET failed (rc=${rc}) on ${url}"
return 99
fi
echo "${code}"
}
http_post_json() {
local body_file="$1"; shift
local data="$1"; shift
local url="$1"; shift
set +e
local code
code=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
-X POST -d "${data}" -o "${body_file}" -w "%{http_code}" "${url}")
local rc=$?
set -e
if [ "${rc}" -ne 0 ]; then
echo "::error::curl POST failed (rc=${rc}) on ${url}"
return 99
fi
echo "${code}"
}
# Step 1: look for an existing open staging→main promote PR
# (idempotent on workflow re-run). Gitea doesn't have a
# head/base filter on the list endpoint that's as ergonomic
# as gh's, but the dedicated `/pulls/{base}/{head}` lookup
# works.
BODY=$(mktemp)
STATUS=$(http_get "${BODY}" "${API}/pulls/main/staging") || true
PR_NUM=""
if [ "${STATUS}" = "200" ]; then
STATE=$(jq -r '.state // "missing"' < "${BODY}")
if [ "${STATE}" = "open" ]; then
PR_NUM=$(jq -r '.number // ""' < "${BODY}")
echo "::notice::Re-using existing open promote PR #${PR_NUM}"
fi
fi
rm -f "${BODY}"
# Step 2: if no open PR, create one.
if [ -z "${PR_NUM}" ]; then
TITLE="staging → main: auto-promote ${TARGET_SHA:0:7}"
BODY_TEXT=$(cat <<EOFBODY
Automated promotion of \`staging\` (\`${TARGET_SHA:0:8}\`) to \`main\`. All required staging gates are green at this SHA (combined status reported success).
This PR is auto-generated by \`.github/workflows/auto-promote-staging.yml\` whenever every required gate completes green on the same staging SHA.
**Approval gate:** \`main\` branch protection requires 1 approval before this can land. Once approved, Gitea will auto-merge (the workflow scheduled \`merge_when_checks_succeed: true\` immediately after open).
The reverse-direction sync (the merge commit on \`main\` → \`staging\`) is handled automatically by \`auto-sync-main-to-staging.yml\` after this PR lands.
---
- Source: staging at \`${TARGET_SHA}\`
- Opened by: \`devops-engineer\` persona (anti-bot-ring; never founder PAT)
- Refs: #65, #73, #195
EOFBODY
)
REQ=$(jq -n \
--arg title "${TITLE}" \
--arg body "${BODY_TEXT}" \
--arg base "main" \
--arg head "staging" \
'{title:$title, body:$body, base:$base, head:$head}')
BODY=$(mktemp)
STATUS=$(http_post_json "${BODY}" "${REQ}" "${API}/pulls")
if [ "${STATUS}" = "201" ]; then
PR_NUM=$(jq -r '.number // ""' < "${BODY}")
echo "::notice::Opened promote PR #${PR_NUM}"
else
echo "::error::Failed to create promote PR: HTTP ${STATUS}"
jq -r '.message // .' < "${BODY}" | head -c 500
rm -f "${BODY}"
exit 1
fi
rm -f "${BODY}"
fi
# Step 3: schedule auto-merge. merge_when_checks_succeed
# tells Gitea to wait for both:
# - all required status checks to pass
# - the required-approvals gate (1 approval on main)
# before merging. On approval+green, Gitea merges within
# seconds. On any check failing or approval being denied,
# the schedule stays armed but doesn't fire.
#
# Idempotent: re-arming on an already-armed PR is a no-op.
REQ=$(jq -n '{Do:"merge", merge_when_checks_succeed:true}')
BODY=$(mktemp)
STATUS=$(http_post_json "${BODY}" "${REQ}" "${API}/pulls/${PR_NUM}/merge")
# Gitea returns:
# - 200/204 on successful immediate merge (gates already green AND approved)
# - 405 "Please try again later" when scheduled successfully but waiting
# - 422 on "Pull request is not mergeable" (conflict, stale base, etc.)
#
# 405 here is benign — Gitea's way of saying "scheduled, not merging now".
# We treat 200/204/405 as success, anything else as failure.
case "${STATUS}" in
200|204)
MERGE_OUTCOME="merged-immediately"
echo "::notice::Promote PR #${PR_NUM} merged immediately (gates+approval already green)"
;;
405)
MERGE_OUTCOME="auto-merge-scheduled"
echo "::notice::Promote PR #${PR_NUM}: auto-merge scheduled (Gitea will land on approval+green)"
;;
422)
MERGE_OUTCOME="not-mergeable"
echo "::warning::Promote PR #${PR_NUM}: not mergeable (conflict, stale base, or already merging)."
jq -r '.message // .' < "${BODY}" | head -c 500
;;
*)
echo "::error::Unexpected status ${STATUS} on merge schedule"
jq -r '.message // .' < "${BODY}" | head -c 500
rm -f "${BODY}"
exit 1
;;
esac
rm -f "${BODY}"
{
echo "## Auto-promote PR opened"
echo
echo "- Source: staging at \`${TARGET_SHA:0:8}\`"
echo "- PR: #${PR_NUM}"
echo "- Outcome: \`${MERGE_OUTCOME}\`"
echo
if [ "${MERGE_OUTCOME}" = "auto-merge-scheduled" ]; then
echo "Gitea will auto-merge once Hongming approves and all checks are green. No human action needed beyond approval."
elif [ "${MERGE_OUTCOME}" = "merged-immediately" ]; then
echo "Merged immediately. \`publish-workspace-server-image.yml\` will fire naturally on the resulting \`main\` push."
else
echo "PR is not auto-merging. Operator may need to bring staging up to date with main, then re-trigger this workflow via workflow_dispatch."
fi
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -0,0 +1,83 @@
name: auto-promote-stale-alarm
# Hourly cron + on-demand alarm for the silent-block failure mode that
# motivated issue #2975:
# - The auto-promote-staging.yml workflow opened a PR + armed
# auto-merge, but main's branch protection requires a human review
# (reviewDecision=REVIEW_REQUIRED). The PR sat BLOCKED with no
# surface-up-the-stack for 12+ hours, holding 25 commits hostage
# including the Memory v2 redesign and a reno-stars data-loss fix.
#
# This workflow runs `scripts/check-stale-promote-pr.sh` against the
# repo's open auto-promote PRs (base=main head=staging). When a PR has
# been BLOCKED on REVIEW_REQUIRED for >4h, it:
# 1. Emits a workflow-level warning (visible in run summary + the
# Actions UI feed).
# 2. Posts a comment on the PR (idempotent — one alarm per PR).
#
# The detection logic lives in scripts/check-stale-promote-pr.sh so
# it's unit-testable with stubbed `gh` (see test-check-stale-promote-pr.sh).
# This file is the schedule + invocation surface only — SSOT for the
# detector itself.
on:
schedule:
# Hourly. Cheap (one `gh pr list` + jq), and 1h granularity is
# plenty for a 4h staleness threshold — operators see the alarm
# within at most 1h of crossing the threshold.
- cron: "27 * * * *" # at :27 to dodge the cron herd at :00
workflow_dispatch:
inputs:
stale_hours:
description: "Hours after which a BLOCKED+REVIEW_REQUIRED PR is stale (default 4)"
required: false
default: "4"
post_comment:
description: "Post a comment on stale PRs (default true)"
required: false
default: "true"
permissions:
contents: read
pull-requests: write # post comments on stale PRs
# Serialize so the on-demand and scheduled runs don't double-comment
# the same PR. cancel-in-progress=false because the script is idempotent
# (existing comment marker prevents dupes), but a scheduled run firing
# while a manual one runs would just re-list the same PR set.
concurrency:
group: auto-promote-stale-alarm
cancel-in-progress: false
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout (need scripts/ only)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: |
scripts/check-stale-promote-pr.sh
sparse-checkout-cone-mode: false
- name: Run stale-PR detector
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_REPOSITORY: ${{ github.repository }}
STALE_HOURS: ${{ inputs.stale_hours || '4' }}
POST_COMMENT: ${{ inputs.post_comment || 'true' }}
run: |
# The script's exit code reflects the count of stale PRs.
# We don't want a stale finding to fail the workflow run —
# the warning + comment are the signal, the green/red is
# noise. So convert any non-zero exit to a workflow notice
# and exit 0.
set +e
bash scripts/check-stale-promote-pr.sh
rc=$?
set -e
if [ "$rc" -ne 0 ]; then
echo "::notice::Stale PR detector found $rc PR(s) needing attention. See warnings above + comments on the PRs."
fi
# Always succeed — operator-facing surface is the warning,
# not the workflow status.
exit 0

404
.github/workflows/auto-sync-canary.yml vendored Normal file
View File

@ -0,0 +1,404 @@
name: Auto-sync canary — AUTO_SYNC_TOKEN rotation drift
# Synthetic health check for the AUTO_SYNC_TOKEN secret consumed by
# auto-sync-main-to-staging.yml (PR #66) and publish-workspace-server-image.yml.
#
# ============================================================
# Why this workflow exists
# ============================================================
#
# PR #66 fixed auto-sync (replaced GitHub-era `gh pr create` — which
# 405s on Gitea's GraphQL endpoint — with a direct git push from the
# `devops-engineer` persona's `AUTO_SYNC_TOKEN`). Hostile self-review
# weakest spot #3 of that PR:
#
# "Token rotation silently breaks auto-sync. If AUTO_SYNC_TOKEN is
# rotated without updating the repo secret, every push to main
# fails red on the auto-sync push step. The workflow surfaces the
# failure mode in the step summary (failure mode B in the header),
# but there's no proactive monitoring."
#
# Detection latency under the status quo: rotation is only caught on
# the next push to `main`. During quiet periods (no main push for
# many hours) the staging-superset-of-main invariant silently breaks.
#
# This workflow closes the gap: every 6 hours, it fires the auth
# surface that auto-sync depends on and emits a red workflow status
# if AUTO_SYNC_TOKEN has drifted out of validity.
#
# ============================================================
# What this checks (Option B — read-only verify)
# ============================================================
#
# 1. `GET /api/v1/user` against Gitea with the token → validates the
# token authenticates AND resolves to `devops-engineer` (catches
# the case where the token was regenerated under a different
# persona by mistake).
# 2. `GET /api/v1/repos/molecule-ai/molecule-core` with the token →
# validates the token has `read:repository` scope on this repo
# (the v2 scope contract — see saved memory
# `reference_persona_token_v2_scope`).
# 3. `git push --dry-run` of the current staging SHA back to
# `refs/heads/staging` via `https://oauth2:<token>@<gitea>/...`
# → validates the EXACT HTTPS basic-auth path that
# `actions/checkout` + `git push origin staging` use inside
# auto-sync-main-to-staging.yml. NOP by construction (push the
# current tip to itself = "Everything up-to-date"); auth is
# checked at the smart-protocol handshake BEFORE the empty-diff
# computation, so bad token → exit 128 with "Authentication
# failed". `git ls-remote` is NOT used here because Gitea
# falls back to anonymous read on public repos and would
# silently green-light a rotated token.
#
# Each step exits non-zero with an actionable error message if it
# fails. The workflow status itself is the operator-facing surface.
#
# ============================================================
# What this does NOT check (intentional)
# ============================================================
#
# - **Branch-protection authz** (failure mode C in auto-sync header):
# would require an actual write to staging. Already monitored by
# `branch-protection-drift.yml` daily. Don't duplicate.
# - **Conflict resolution** (failure mode A): a real conflict is data-
# driven, not auth-driven; can't synthesise it without polluting
# staging. Already surfaces immediately on the next main push.
# - **Concurrency** (failure mode D): handled by workflow concurrency
# group on auto-sync, not a credential issue.
#
# ============================================================
# Why Option B (read-only) and not the alternatives
# ============================================================
#
# Considered + rejected (see issue #72 for full write-up):
#
# - **Option A — full auto-sync on schedule**: every run creates a
# no-op merge commit on staging when main hasn't advanced. 4 noise
# commits/day. And races the real `push:` trigger when main has
# advanced. Rejected.
#
# - **Option C — push to dedicated `auto-sync-canary` branch**: would
# exercise authz too, but adds branch noise on Gitea AND requires
# maintaining a second branch protection (or expanding staging's
# whitelist to a junk branch). Authz already covered by
# `branch-protection-drift.yml`. Rejected.
#
# Prior art for the chosen Option B shape:
# - Cloudflare's `/user/tokens/verify` endpoint (read-only auth
# probe explicitly designed for credential canaries).
# - AWS Secrets Manager rotation Lambda's `testSecret` step (auth
# probe before promoting AWSPENDING → AWSCURRENT).
# - HashiCorp Vault's `vault token lookup` for renewal canaries.
#
# ============================================================
# Operator runbook — what to do when this workflow goes RED
# ============================================================
#
# 1. **Identify which step failed**:
# - Step "Verify token authenticates as devops-engineer" red →
# token is invalid OR resolves to wrong persona.
# - Step "Verify token has repo read scope" red → token valid but
# stripped of `read:repository` scope (or repo perms changed).
# - Step "Verify git HTTPS auth path via no-op dry-run push to
# staging" red → token rotated/revoked OR Gitea git-HTTPS
# surface is broken (rare). Auth check happens on the
# smart-protocol handshake, separate from the API path.
#
# 2. **Re-issue the token** on the operator host:
# ```
# ssh root@5.78.80.188 'docker exec --user git molecule-gitea-1 \
# gitea admin user generate-access-token \
# --username devops-engineer \
# --token-name persona-devops-engineer-vN \
# --scopes "read:repository,write:repository,read:user,read:organization,read:issue,write:issue,read:notification,read:misc"'
# ```
# Update `/etc/molecule-bootstrap/agent-secrets.env` in place
# (per `feedback_unified_credentials_file`). The previous token
# file lands at `.bak.<date>`.
#
# 3. **Update the repo Actions secret** at:
# Settings → Secrets and variables → Actions → AUTO_SYNC_TOKEN
# Paste the new token. (Don't echo it in chat — but per
# `feedback_passwords_in_chat_are_burned`, a paste in a 1:1
# Claude session is within trust boundary.)
#
# 4. **Re-run this canary** via workflow_dispatch. Confirm GREEN.
#
# 5. **Backfill any missed main → staging syncs** by re-running
# `auto-sync-main-to-staging.yml` from its workflow_dispatch
# surface, OR by pushing an empty commit to main (if you'd
# rather force a real trigger).
#
# ============================================================
# Security notes
# ============================================================
#
# - Token usage: read-only (`GET /api/v1/user`, `GET /api/v1/repos/...`,
# `git ls-remote`). No write paths. Same blast-radius profile as
# `actions/checkout` on a public repo.
# - The token NEVER appears in logs: every `curl` uses a header
# variable, never inline; the `git ls-remote` URL builds the
# `oauth2:$TOKEN@host` form into a single env var that's not
# echoed. GitHub Actions secret-masking covers anything that does
# slip through.
# - No new token introduced — same `AUTO_SYNC_TOKEN` the workflow
# under monitor uses. Per least-privilege we deliberately do NOT
# broaden scope for the canary.
on:
schedule:
# Every 6 hours at :17 (offsets the cron herd at :00). Justification
# from issue #72: cheap to run (~5s wall-clock, no quota), 3h average
# detection latency, 6h max. 1h would be 24× the runs for marginal
# benefit; daily would be 6× longer latency and worse than status
# quo on a quiet-main day.
- cron: '17 */6 * * *'
workflow_dispatch:
# No concurrency group needed — the canary is read-only and idempotent.
# Two parallel runs (e.g. operator dispatch during a scheduled tick) are
# harmless: same result, doubled HTTPS calls, no shared state.
permissions:
contents: read
jobs:
verify-token:
name: Verify AUTO_SYNC_TOKEN validity
runs-on: ubuntu-latest
# 2 min surfaces hangs (Gitea API stall, DNS issue) within one
# cron interval. Realistic worst case is ~10s: 2 curls + 1 git
# ls-remote, each capped by the explicit timeouts below.
timeout-minutes: 2
env:
# Pinned in env so individual steps can read it without
# repeating the secret reference. GitHub masks the value in
# logs automatically.
AUTO_SYNC_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
# MUST stay in sync with auto-sync-main-to-staging.yml's
# `git config user.name "devops-engineer"` line. Renaming the
# devops-engineer persona requires updating both files (and
# the staging branch protection's `push_whitelist_usernames`).
EXPECTED_PERSONA: devops-engineer
GITEA_HOST: git.moleculesai.app
REPO_PATH: molecule-ai/molecule-core
steps:
- name: Verify AUTO_SYNC_TOKEN secret is configured
# Schedule-vs-dispatch behaviour split, per
# `feedback_schedule_vs_dispatch_secrets_hardening`:
#
# - schedule: hard-fail when the secret is missing. The
# whole point of the canary is to surface drift; soft-
# skipping on missing-secret would make the canary
# itself drift-invisible (sweep-cf-orphans #2088 lesson).
# - workflow_dispatch: hard-fail too — there's no scenario
# where an operator wants this canary to silently no-op.
# The workflow has no other ad-hoc utility; if you ran
# it, you wanted the answer.
run: |
if [ -z "${AUTO_SYNC_TOKEN}" ]; then
echo "::error::AUTO_SYNC_TOKEN secret is not set on this repo." >&2
echo "::error::Set it at Settings → Secrets and variables → Actions." >&2
echo "::error::Without it, auto-sync-main-to-staging.yml will fail every push to main." >&2
exit 1
fi
echo "AUTO_SYNC_TOKEN is configured (value masked)."
- name: Verify token authenticates as ${{ env.EXPECTED_PERSONA }}
# Calls Gitea's `/api/v1/user` — the canonical
# auth-probe-with-no-side-effects endpoint (mirrors
# Cloudflare's /user/tokens/verify).
#
# Failure surfaces:
# - HTTP 401: token invalid (rotated, revoked, or never
# correctly registered).
# - HTTP 200 but username != devops-engineer: token was
# regenerated under the wrong persona — this would let
# auth pass but commit attribution would be wrong, and
# branch-protection authz would fail because only
# `devops-engineer` is whitelisted.
run: |
set -euo pipefail
response_file="$(mktemp)"
code_file="$(mktemp)"
# `--max-time 30`: full call ceiling. `--connect-timeout 10`:
# DNS + TCP. `-w "%{http_code}"` routed to a tempfile so curl's
# exit code can't pollute the captured status — see
# feedback_curl_status_capture_pollution + the
# `lint-curl-status-capture.yml` gate that rejects the unsafe
# `$(curl ... || echo "000")` shape.
set +e
curl -sS -o "$response_file" \
--max-time 30 --connect-timeout 10 \
-w "%{http_code}" \
-H "Authorization: token ${AUTO_SYNC_TOKEN}" \
-H "Accept: application/json" \
"https://${GITEA_HOST}/api/v1/user" >"$code_file" 2>/dev/null
set -e
status=$(cat "$code_file" 2>/dev/null || true)
[ -z "$status" ] && status="000"
if [ "$status" != "200" ]; then
echo "::error::Token rotation suspected: GET /api/v1/user returned HTTP $status (expected 200)." >&2
echo "::error::Likely cause: AUTO_SYNC_TOKEN has been rotated/revoked on Gitea but the repo Actions secret was not updated." >&2
echo "::error::Runbook: see header comment of this workflow file." >&2
# Print response body but redact anything that looks like a token.
sed -E 's/[A-Fa-f0-9]{32,}/<redacted>/g' "$response_file" >&2 || true
exit 1
fi
username=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('login',''))" "$response_file")
if [ "$username" != "${EXPECTED_PERSONA}" ]; then
echo "::error::Token resolves to user '$username', expected '${EXPECTED_PERSONA}'." >&2
echo "::error::AUTO_SYNC_TOKEN must be the devops-engineer persona PAT (not founder PAT, not another persona)." >&2
echo "::error::Auto-sync push will fail because only 'devops-engineer' is whitelisted on staging branch protection." >&2
exit 1
fi
echo "Token authenticates as: $username ✓"
- name: Verify token has repo read scope
# `GET /api/v1/repos/<owner>/<repo>` requires `read:repository`
# on the persona's v2 scope contract. If the scope was
# narrowed/dropped on rotation we catch it here, before the
# next main push reveals it via a checkout failure.
run: |
set -euo pipefail
response_file="$(mktemp)"
code_file="$(mktemp)"
# See first probe step for the rationale on the tempfile-routed
# `-w "%{http_code}"` pattern — the unsafe `|| echo "000"` shape
# is rejected by lint-curl-status-capture.yml.
set +e
curl -sS -o "$response_file" \
--max-time 30 --connect-timeout 10 \
-w "%{http_code}" \
-H "Authorization: token ${AUTO_SYNC_TOKEN}" \
-H "Accept: application/json" \
"https://${GITEA_HOST}/api/v1/repos/${REPO_PATH}" >"$code_file" 2>/dev/null
set -e
status=$(cat "$code_file" 2>/dev/null || true)
[ -z "$status" ] && status="000"
if [ "$status" != "200" ]; then
echo "::error::Token lacks read:repository scope on ${REPO_PATH}: HTTP $status." >&2
echo "::error::Auto-sync's actions/checkout step will fail with this token." >&2
echo "::error::Re-issue with v2 scope contract: read:repository,write:repository,read:user,read:organization,read:issue,write:issue,read:notification,read:misc" >&2
sed -E 's/[A-Fa-f0-9]{32,}/<redacted>/g' "$response_file" >&2 || true
exit 1
fi
echo "Token has read:repository on ${REPO_PATH} ✓"
- name: Verify git HTTPS auth path via no-op dry-run push to staging
# Final probe: exercise the EXACT auth path that
# `actions/checkout` + `git push origin staging` use in
# auto-sync-main-to-staging.yml. Gitea's API and git-HTTPS
# surfaces share the token-lookup code path internally but
# the wire-level error shapes differ — historically (#173)
# the API path was healthy while git-HTTPS rejected, so
# checking only the API would have given false-green.
#
# IMPORTANT: `git ls-remote` on a public repo (which
# molecule-core is) succeeds even with a junk token because
# Gitea falls back to anonymous-read. `ls-remote` therefore
# CANNOT validate auth on this surface. We use
# `git push --dry-run` instead — push is auth-gated even on
# public repos.
#
# NOP shape: read the current staging SHA via authenticated
# ls-remote (the SHA itself is public; auth is incidental
# here, used only to colocate the discovery in one step), then
# `git push --dry-run <SHA>:refs/heads/staging`. Pushing the
# current tip back to itself is "Everything up-to-date" with
# exit 0 when auth succeeds. With a bad token Gitea returns
# HTTP 401 in the smart-protocol handshake and git exits 128
# with "Authentication failed".
#
# The dry-run never reaches Gitea's pre-receive hook (which
# is where branch-protection authz runs), so this probe does
# not validate failure mode C. That's intentional —
# branch-protection-drift.yml owns authz monitoring; this
# canary owns auth.
env:
# Don't hang waiting for password prompt if auth fails on a
# terminal-attached run. (In Actions there's no terminal,
# but the env-var hardens against an interactive runner
# config.)
GIT_TERMINAL_PROMPT: "0"
run: |
set -euo pipefail
# Token is in $AUTO_SYNC_TOKEN (job-level env). Compose the
# URL as a local var that's never echoed.
url="https://oauth2:${AUTO_SYNC_TOKEN}@${GITEA_HOST}/${REPO_PATH}"
# Step a: read current staging SHA. ~1KB; auth-gated only
# on private repos but always works on public — used here
# only to discover the SHA, not to validate auth.
staging_ref=$(timeout 30s git ls-remote --refs "$url" refs/heads/staging 2>&1) || {
redacted=$(echo "$staging_ref" | sed -E "s|oauth2:[^@]+@|oauth2:<redacted>@|g")
echo "::error::ls-remote against staging failed (network/DNS issue):" >&2
echo "$redacted" >&2
exit 1
}
if ! echo "$staging_ref" | grep -qE '^[0-9a-f]{40}[[:space:]]+refs/heads/staging$'; then
echo "::error::ls-remote returned unexpected shape:" >&2
echo "$staging_ref" | sed -E "s|oauth2:[^@]+@|oauth2:<redacted>@|g" >&2
exit 1
fi
staging_sha=$(echo "$staging_ref" | awk '{print $1}')
# Step b: spin up an ephemeral local repo. `git push` always
# requires a local repo even when pushing a remote SHA that
# isn't in the local object DB (the protocol negotiates and
# discovers we don't need to send any objects). We don't use
# `actions/checkout` for this — it would clone the whole
# repo (~hundreds of MB) for what's essentially `git init`.
tmp_repo="$(mktemp -d)"
trap 'rm -rf "$tmp_repo"' EXIT
git -C "$tmp_repo" init -q
# Author config required for any git operation; values are
# arbitrary because nothing gets committed here.
git -C "$tmp_repo" config user.email canary@auto-sync.local
git -C "$tmp_repo" config user.name auto-sync-canary
# Step c: dry-run push the current staging SHA back to
# staging. NOP by construction — the remote tip equals the
# SHA we're pushing, so "Everything up-to-date" is the
# success path.
#
# Authentication is checked at the smart-protocol handshake,
# BEFORE the dry-run can compute an empty diff. Bad token
# → "Authentication failed", exit 128. Good token → exit 0.
set +e
push_out=$(timeout 30s git -C "$tmp_repo" push --dry-run "$url" "${staging_sha}:refs/heads/staging" 2>&1)
push_rc=$?
set -e
if [ "$push_rc" -ne 0 ]; then
redacted=$(echo "$push_out" | sed -E "s|oauth2:[^@]+@|oauth2:<redacted>@|g")
echo "::error::Token rotation suspected: git push --dry-run against staging failed via the AUTO_SYNC_TOKEN HTTPS auth path (exit $push_rc)." >&2
echo "::error::This is the EXACT auth path that actions/checkout + git push use in auto-sync-main-to-staging.yml." >&2
echo "::error::Likely cause: AUTO_SYNC_TOKEN was rotated/revoked on Gitea but the repo Actions secret was not updated. Runbook: see header." >&2
echo "$redacted" >&2
exit 1
fi
echo "git HTTPS auth path: NOP push --dry-run to staging → ${staging_sha:0:8} ✓"
- name: Summarise canary result
# Everything passed — surface a green summary. (Failures
# already wrote ::error:: lines and exited above; if we got
# here, all three probes passed.)
run: |
{
echo "## Auto-sync canary: GREEN"
echo ""
echo "AUTO_SYNC_TOKEN is healthy:"
echo "- Authenticates as \`${EXPECTED_PERSONA}\` ✓"
echo "- Has \`read:repository\` scope on \`${REPO_PATH}\` ✓"
echo "- Git HTTPS auth path: no-op dry-run push to \`refs/heads/staging\` succeeds ✓"
echo ""
echo "Auto-sync main → staging will succeed on the next push to main."
echo "If this canary ever goes RED, see the runbook in this workflow's header."
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -0,0 +1,255 @@
name: Auto-sync main → staging
# Reflects every push to `main` back onto `staging` so the
# staging-as-superset-of-main invariant holds.
#
# ============================================================
# What this workflow does
# ============================================================
#
# On every push to `main`:
# 1. Checks if staging already contains main → no-op.
# 2. Fetches both branches, merges main into staging in the
# runner workspace (fast-forward if possible, else
# `--no-ff` merge commit).
# 3. Pushes staging directly to origin via the
# `devops-engineer` persona's `AUTO_SYNC_TOKEN`.
#
# Authoritative path: a single `git push origin staging` from
# inside this workflow is the SSOT for advancing staging after
# a main push. No PR, no merge queue, no human approval —
# staging is mechanically maintained as a superset of main.
#
# `auto-promote-staging.yml` is the reverse-direction
# counterpart (staging → main, gated on green CI). Together
# they keep the staging-superset-of-main invariant tight.
#
# ============================================================
# Why direct push (and not "open a PR")
# ============================================================
#
# Pre-2026-05-06 the canonical SCM was GitHub.com, where:
# - The `staging` branch had a `merge_queue` ruleset that
# blocked ALL direct pushes (no bypass even for org
# admins or the GitHub Actions integration).
# - Therefore this workflow opened a PR via `gh pr create`
# and let auto-merge land it through the queue.
#
# Post-2026-05-06 the canonical SCM is Gitea
# (`git.moleculesai.app/molecule-ai/molecule-core`). Gitea:
# - Has no `merge_queue` concept.
# - Allows direct push to protected branches via per-user
# `push_whitelist_usernames` on the branch protection.
# - Does not expose a GraphQL endpoint, so `gh pr create`
# returns `HTTP 405 Method Not Allowed
# (https://git.moleculesai.app/api/graphql)` — the
# pre-suspension architecture cannot work on Gitea.
#
# The molecule-ai/molecule-core staging branch protection
# (verified via `GET /api/v1/repos/.../branch_protections`)
# whitelists `devops-engineer` for direct push. So the
# correct Gitea-shape architecture is: authenticate as
# `devops-engineer`, merge locally, push staging directly.
#
# This is structurally simpler than the GitHub-era PR dance
# and removes the dependence on `gh` CLI / GraphQL entirely.
#
# ============================================================
# Identity + token (anti-bot-ring per saved-memory
# `feedback_per_agent_gitea_identity_default`)
# ============================================================
#
# This workflow uses `secrets.AUTO_SYNC_TOKEN`, which is a
# personal access token issued to the `devops-engineer`
# persona on Gitea — NOT the founder PAT. The bot-ring
# fingerprint that triggered the GitHub org suspension on
# 2026-05-06 was characterised by founder PAT acting as CI
# at machine speed; per-persona identities split the
# attribution honestly.
#
# Token scope on Gitea: repo write. Push target restricted
# to `staging` (this workflow is the only writer; main is
# untouched). Compromise blast radius: bounded to staging
# branch + this repo's read surface.
#
# Commits are authored by the persona email
# `devops-engineer@agents.moleculesai.app` so commit history
# reflects which automation produced the merge.
#
# ============================================================
# Failure modes & operational notes
# ============================================================
#
# A — staging has commits main doesn't, and the merge
# conflicts:
# - The `--no-ff` merge step exits non-zero. Workflow
# fails red. Operator (devops-engineer or human)
# resolves manually:
# git fetch origin
# git checkout staging
# git merge --no-ff origin/main
# # resolve conflicts
# git push origin staging
# - Step summary surfaces the conflict so the failed run
# is self-explanatory.
#
# B — `AUTO_SYNC_TOKEN` rotated / wrong scope:
# - `git push` step exits non-zero with `HTTP 401` /
# `403`. Step summary surfaces the failed push.
# - Re-issue the token from `~/.molecule-ai/personas/`
# on the operator host and update the repo Actions
# secret. Re-run the workflow.
#
# C — staging branch protection no longer whitelists
# `devops-engineer`:
# - `git push` exits non-zero with a Gitea protected-
# branch rejection. Step summary surfaces it.
# - Re-add `devops-engineer` to
# `push_whitelist_usernames` on the staging
# protection (Settings → Branches → staging).
#
# D — concurrent push to main while a sync is in flight:
# - The `concurrency` group below serialises runs.
# The second waits for the first; if main advances
# again while we're syncing, the second run picks
# up the new tip on its own fetch.
#
# ============================================================
# Loop safety
# ============================================================
#
# The push to staging from this workflow does NOT itself
# fire a `push: branches: [main]` event (different branch),
# so there's no risk of self-recursion. `auto-promote-staging.yml`
# fires on `workflow_run` of CI etc. — it sees the new
# staging tip on its next gate-completion event, NOT on this
# push directly. No loop.
on:
push:
branches: [main]
# workflow_dispatch lets operators manually backfill a
# missed sync (e.g. if AUTO_SYNC_TOKEN was rotated and a
# main push slipped through while the secret was stale).
workflow_dispatch:
permissions:
contents: write
concurrency:
group: auto-sync-main-to-staging
cancel-in-progress: false
jobs:
sync-staging:
runs-on: ubuntu-latest
steps:
- name: Checkout staging (with devops-engineer push token)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
ref: staging
# AUTO_SYNC_TOKEN authenticates as the
# `devops-engineer` Gitea persona — the only
# identity whitelisted for direct push to
# staging. See header comment for context.
token: ${{ secrets.AUTO_SYNC_TOKEN }}
- name: Configure git author
run: |
# Per-persona identity, NOT founder PAT.
# `feedback_per_agent_gitea_identity_default`.
git config user.name "devops-engineer"
git config user.email "devops-engineer@agents.moleculesai.app"
- name: Check if staging already contains main
id: check
run: |
set -euo pipefail
git fetch origin main
if git merge-base --is-ancestor origin/main HEAD; then
echo "needs_sync=false" >> "$GITHUB_OUTPUT"
{
echo "## No-op"
echo
echo "staging already contains \`origin/main\` ($(git rev-parse --short=8 origin/main))."
} >> "$GITHUB_STEP_SUMMARY"
else
echo "needs_sync=true" >> "$GITHUB_OUTPUT"
MAIN_SHORT=$(git rev-parse --short=8 origin/main)
echo "main_short=${MAIN_SHORT}" >> "$GITHUB_OUTPUT"
echo "::notice::staging is missing main's tip (${MAIN_SHORT}) — merging in-runner and pushing"
fi
- name: Merge main into staging (in-runner)
if: steps.check.outputs.needs_sync == 'true'
id: merge
run: |
set -euo pipefail
# Already on staging from checkout. Try fast-forward
# first (cleanest history); fall back to merge commit
# if staging has commits main doesn't.
if git merge --ff-only origin/main; then
echo "did_ff=true" >> "$GITHUB_OUTPUT"
echo "::notice::Fast-forwarded staging to origin/main"
else
echo "did_ff=false" >> "$GITHUB_OUTPUT"
if ! git merge --no-ff origin/main \
-m "chore: sync main → staging (auto, ${{ steps.check.outputs.main_short }})"; then
# Hygiene: leave the work tree clean before failing.
git merge --abort || true
{
echo "## Conflict"
echo
echo "Auto-merge \`main → staging\` failed with conflicts."
echo "A human (or devops-engineer persona) needs to resolve manually:"
echo
echo '```'
echo "git fetch origin"
echo "git checkout staging"
echo "git merge --no-ff origin/main"
echo "# resolve conflicts"
echo "git push origin staging"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
fi
- name: Push staging to origin
if: steps.check.outputs.needs_sync == 'true'
run: |
set -euo pipefail
# Direct push to staging. devops-engineer persona is
# whitelisted for direct push on the staging branch
# protection (Settings → Branches → staging).
#
# No --force / --force-with-lease: a fast-forward or
# legitimate merge commit on top of current staging
# is the only thing we'd ever push. If origin/staging
# advanced under us (concurrent merge), the push
# legitimately rejects and the next run picks up the
# new state.
if ! git push origin staging; then
{
echo "## Push rejected"
echo
echo "Direct push to \`staging\` failed. Likely causes:"
echo "- \`AUTO_SYNC_TOKEN\` rotated / wrong scope (HTTP 401/403)"
echo "- \`devops-engineer\` no longer in"
echo " \`push_whitelist_usernames\` on the staging"
echo " branch protection (HTTP 422)"
echo "- staging advanced concurrently — re-running this"
echo " workflow on the new main tip will pick it up"
} >> "$GITHUB_STEP_SUMMARY"
exit 1
fi
{
echo "## Auto-sync succeeded"
echo
echo "- staging advanced to: \`$(git rev-parse --short=8 HEAD)\`"
echo "- main tip: \`${{ steps.check.outputs.main_short }}\`"
echo "- Strategy: $([ "${{ steps.merge.outputs.did_ff }}" = "true" ] && echo "fast-forward" || echo "merge commit")"
echo "- Pushed by: \`devops-engineer\` (per-agent persona, anti-bot-ring)"
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -20,19 +20,6 @@ on:
# a few minutes under load — that's fine for a canary. # a few minutes under load — that's fine for a canary.
- cron: '*/30 * * * *' - cron: '*/30 * * * *'
workflow_dispatch: workflow_dispatch:
inputs:
keep_on_failure:
description: >-
Skip teardown when the canary fails (debugging only). The
tenant org + EC2 + CF tunnel + DNS stay alive so an operator
can SSM into the workspace EC2 and capture docker logs of the
failing claude-code container. REMEMBER to manually delete
via DELETE /cp/admin/tenants/<slug> when done so the org
doesn't accumulate cost. Only honored on workflow_dispatch;
cron runs always tear down (we don't want unattended cron
to leak resources).
type: boolean
default: false
# Serialise with the full-SaaS workflow so they don't contend for the # Serialise with the full-SaaS workflow so they don't contend for the
# same org-create quota on staging. Different group key from # same org-create quota on staging. Different group key from
@ -93,14 +80,6 @@ jobs:
# is "Token Plan only" but cheap-per-token and fast. # is "Token Plan only" but cheap-per-token and fast.
E2E_MODEL_SLUG: MiniMax-M2.7-highspeed E2E_MODEL_SLUG: MiniMax-M2.7-highspeed
E2E_RUN_ID: "canary-${{ github.run_id }}" E2E_RUN_ID: "canary-${{ github.run_id }}"
# Debug-only: when an operator dispatches with keep_on_failure=true,
# the canary script's E2E_KEEP_ORG=1 path skips teardown so the
# tenant org + EC2 stay alive for SSM-based log capture. Cron runs
# never set this (the input only exists on workflow_dispatch) so
# unattended cron always tears down. See molecule-core#129
# failure mode #1 — capturing the actual exception requires
# docker logs from the live container.
E2E_KEEP_ORG: ${{ github.event.inputs.keep_on_failure == 'true' && '1' || '0' }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -158,28 +137,27 @@ jobs:
id: canary id: canary
run: bash tests/e2e/test_staging_full_saas.sh run: bash tests/e2e/test_staging_full_saas.sh
# Alerting: open a sticky issue on the FIRST failure; comment on # Alerting: open an issue only after THREE consecutive failures so
# subsequent failures; auto-close on next green. Comment-on-existing # transient flakes (Cloudflare DNS hiccup, AWS API blip) don't spam
# de-duplicates so a single open issue accumulates the streak — # the issue list. If an issue is already open, we still comment on
# ops sees one issue with N comments rather than N issues. # every failure so ops sees the streak. Auto-close on next green.
# #
# Why no consecutive-failures threshold (e.g., wait 3 runs before # Threshold rationale: canary fires every 30 min, so 3 failures =
# filing): the prior threshold check used # ~90 min of consecutive red — well past any single-run flake but
# `github.rest.actions.listWorkflowRuns()` which Gitea 1.22.6 does # still tight enough that a real outage gets surfaced before the
# not expose (returns 404). On Gitea Actions the threshold call # next deploy window.
# ALWAYS failed, breaking the entire alerting step and going days
# silent on real regressions (38h+ chronic red on 2026-05-07/08
# before this fix; tracked in molecule-core#129). Filing on first
# failure is also better UX — we want to know about the first red,
# not wait 90 min for it to "count." Real flakes get one issue +
# a quick close-on-green; persistent reds accumulate comments.
- name: Open issue on failure - name: Open issue on failure
if: failure() if: failure()
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
env:
# Inject the workflow path explicitly — context.workflow is
# the *name*, not the file path the actions API needs.
WORKFLOW_PATH: '.github/workflows/canary-staging.yml'
CONSECUTIVE_THRESHOLD: '3'
with: with:
script: | script: |
const title = '🔴 Canary failing: staging SaaS smoke'; const title = '🔴 Canary failing: staging SaaS smoke';
const runURL = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const runURL = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
// Find an existing open canary issue (stable title match). // Find an existing open canary issue (stable title match).
// If one exists, this isn't a "first failure" — comment and exit. // If one exists, this isn't a "first failure" — comment and exit.
@ -199,12 +177,32 @@ jobs:
return; return;
} }
// No open issue yet — file one on this first failure. The // No open issue yet — check the last N-1 runs' conclusions.
// comment-on-existing branch above means subsequent failures // We open the issue only if the last (THRESHOLD-1) runs ALSO
// accumulate as comments on this same issue, so we don't // failed (so this is the 3rd consecutive red).
// spam new issues per run. const threshold = parseInt(process.env.CONSECUTIVE_THRESHOLD, 10);
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner: context.repo.owner, repo: context.repo.repo,
workflow_id: process.env.WORKFLOW_PATH,
status: 'completed',
per_page: threshold,
// Skip the current in-progress run; it isn't 'completed' yet.
});
// listWorkflowRuns returns recent first. We need (threshold-1)
// prior failures (current run is the threshold-th).
const priorFailures = (runs.workflow_runs || [])
.slice(0, threshold - 1)
.filter(r => r.id !== context.runId)
.filter(r => r.conclusion === 'failure')
.length;
if (priorFailures < threshold - 1) {
core.info(`Below threshold: ${priorFailures + 1}/${threshold} consecutive failures — not filing yet`);
return;
}
const body = const body =
`Canary run failed at ${new Date().toISOString()}.\n\n` + `Canary run failed at ${new Date().toISOString()}, ` +
`${threshold} consecutive runs red.\n\n` +
`Run: ${runURL}\n\n` + `Run: ${runURL}\n\n` +
`This issue auto-closes on the next green canary run. ` + `This issue auto-closes on the next green canary run. ` +
`Consecutive failures add a comment here rather than a new issue.`; `Consecutive failures add a comment here rather than a new issue.`;
@ -213,7 +211,7 @@ jobs:
title, body, title, body,
labels: ['canary-staging', 'bug'], labels: ['canary-staging', 'bug'],
}); });
core.info('Opened canary failure issue (first red)'); core.info(`Opened canary failure issue (${threshold} consecutive reds)`);
- name: Auto-close canary issue on success - name: Auto-close canary issue on success
if: success() if: success()

View File

@ -1,34 +1,19 @@
name: canary-verify name: canary-verify
# Runs the canary smoke suite against the staging canary tenant fleet # Runs the canary smoke suite against the staging canary tenant fleet
# after a new :staging-<sha> image lands in ECR. On green, calls the # after a new :staging-<sha> image lands in GHCR. On green, promotes
# CP redeploy-fleet endpoint to promote :staging-<sha> → :latest so # :staging-<sha> → :latest so the prod tenant fleet's 5-minute
# the prod tenant fleet's 5-minute auto-updater picks up the verified # auto-updater picks up the verified digest. On red, :latest stays
# digest. On red, :latest stays on the prior known-good digest and # on the prior known-good digest and prod is untouched.
# prod is untouched.
#
# Registry note (2026-05-10): This workflow previously used GHCR
# (ghcr.io/molecule-ai/platform-tenant) — that registry was retired
# during the 2026-05-06 Gitea suspension migration when publish-
# workspace-server-image.yml switched to the operator's ECR org
# (153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/
# platform-tenant). The GHCR → ECR migration was never applied to
# this file, so canary-verify was silently smoke-testing the stale
# GHCR image while the actual staging/prod tenants ran the ECR image.
# Result: smoke tests could not catch a broken ECR build. Fix:
# - Wait step: reads SHA from running canary /health (tenant-
# agnostic, works regardless of registry).
# - Promote step: calls CP redeploy-fleet endpoint with target_tag=
# staging-<sha>, same mechanism as redeploy-tenants-on-main.yml.
# No longer attempts GHCR crane ops.
# #
# Dependencies: # Dependencies:
# - publish-workspace-server-image.yml publishes :staging-<sha> # - publish-workspace-server-image.yml publishes :staging-<sha>
# to ECR on staging and main merges. # (NOT :latest) on main merge
# - Canary tenants are configured to pull :staging-<sha> from ECR # - canary tenants are configured to pull :staging-<sha> as their
# (TENANT_IMAGE env set to the ECR :staging-<sha> tag). # tenant image (set TENANT_IMAGE=ghcr.io/…:staging-<sha> on the
# canary provisioner code path OR rotate via an admin endpoint)
# - Repo secrets CANARY_TENANT_URLS / CANARY_ADMIN_TOKENS / # - Repo secrets CANARY_TENANT_URLS / CANARY_ADMIN_TOKENS /
# CANARY_CP_SHARED_SECRET are populated. # CANARY_CP_SHARED_SECRET are populated
on: on:
workflow_run: workflow_run:
@ -42,12 +27,8 @@ permissions:
actions: read actions: read
env: env:
# ECR registry (post-2026-05-06 SSOT for tenant images). IMAGE_NAME: ghcr.io/molecule-ai/platform
# publish-workspace-server-image.yml pushes here. TENANT_IMAGE_NAME: ghcr.io/molecule-ai/platform-tenant
IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform
TENANT_IMAGE_NAME: 153263036946.dkr.ecr.us-east-2.amazonaws.com/molecule-ai/platform-tenant
# CP endpoint for redeploy-fleet (used in promote step below).
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
jobs: jobs:
canary-smoke: canary-smoke:
@ -71,12 +52,6 @@ jobs:
# the new SHA (~2-3 min typical vs 6 min fixed). Falls back to # the new SHA (~2-3 min typical vs 6 min fixed). Falls back to
# proceeding after 7 min even if not all canaries responded — # proceeding after 7 min even if not all canaries responded —
# the smoke suite will catch any that didn't update. # the smoke suite will catch any that didn't update.
#
# NOTE: The SHA is read from the running tenant's /health response,
# NOT from a registry lookup. This is registry-agnostic and works
# regardless of whether the tenant pulls from ECR, GHCR, or any
# other registry — the canary is telling us what it's actually
# running, which is the ground truth for smoke testing.
env: env:
CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }} CANARY_TENANT_URLS: ${{ secrets.CANARY_TENANT_URLS }}
EXPECTED_SHA: ${{ steps.compute.outputs.sha }} EXPECTED_SHA: ${{ steps.compute.outputs.sha }}
@ -133,7 +108,7 @@ jobs:
echo echo
echo "One or more canary secrets are unset (\`CANARY_TENANT_URLS\`, \`CANARY_ADMIN_TOKENS\`, \`CANARY_CP_SHARED_SECRET\`)." echo "One or more canary secrets are unset (\`CANARY_TENANT_URLS\`, \`CANARY_ADMIN_TOKENS\`, \`CANARY_CP_SHARED_SECRET\`)."
echo "Phase 2 canary fleet has not been stood up yet —" echo "Phase 2 canary fleet has not been stood up yet —"
echo "see [canary-tenants.md](https://git.moleculesai.app/molecule-ai/molecule-controlplane/blob/main/docs/canary-tenants.md)." echo "see [canary-tenants.md](https://github.com/molecule-ai/molecule-controlplane/blob/main/docs/canary-tenants.md)."
echo echo
echo "**Skipped — promote-to-latest will NOT auto-fire.** Dispatch \`promote-latest.yml\` manually when ready." echo "**Skipped — promote-to-latest will NOT auto-fire.** Dispatch \`promote-latest.yml\` manually when ready."
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
@ -158,98 +133,42 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"
promote-to-latest: promote-to-latest:
# On green, calls the CP redeploy-fleet endpoint with target_tag= # On green, retag :staging-<sha> → :latest for BOTH images.
# staging-<sha> to promote the verified ECR image. This is the same # crane is a lightweight registry client (no Docker daemon needed on
# mechanism as redeploy-tenants-on-main.yml — no GHCR crane ops. # the runner) that can retag remotely with a single API call each.
# # Gated on smoke_ran=true — without a real canary fleet the smoke
# Pre-fix history: the old GHCR promote step used `crane tag` against # step no-ops with success, and we don't want that to silently
# ghcr.io/molecule-ai/platform-tenant, but publish-workspace-server- # auto-promote every main merge.
# image.yml had already migrated to ECR on 2026-05-07 (commit
# 10e510f5). The GHCR tags were never updated, so this step was
# silently promoting a stale GHCR image while actual prod tenants
# pulled from ECR. Canary smoke tests were GHCR-targeted and could
# not catch a broken ECR build.
needs: canary-smoke needs: canary-smoke
if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }} if: ${{ needs.canary-smoke.result == 'success' && needs.canary-smoke.outputs.smoke_ran == 'true' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
SHA: ${{ needs.canary-smoke.outputs.sha }}
CP_URL: ${{ vars.CP_URL || 'https://staging-api.moleculesai.app' }}
# CP_ADMIN_API_TOKEN gates write access to the redeploy endpoint.
# Stored at the repo level so all workflows pick it up automatically.
CP_ADMIN_API_TOKEN: ${{ secrets.CP_ADMIN_API_TOKEN }}
# canary_slug pin: deploy the verified :staging-<sha> to the canary
# first (soak 120s), then fan out to the rest of the fleet.
CANARY_SLUG: ${{ vars.CANARY_PROMOTE_SLUG || '' }}
SOAK_SECONDS: ${{ vars.CANARY_PROMOTE_SOAK || '120' }}
BATCH_SIZE: ${{ vars.CANARY_PROMOTE_BATCH || '3' }}
steps: steps:
- name: Check CP credentials - uses: imjasonh/setup-crane@6da1ae018866400525525ce74ff892880c099987 # v0.5
- name: GHCR login
run: | run: |
if [ -z "${CP_ADMIN_API_TOKEN:-}" ]; then echo "${{ secrets.GITHUB_TOKEN }}" | \
echo "::error::CP_ADMIN_API_TOKEN secret is not set — promote step cannot call redeploy-fleet." crane auth login ghcr.io -u "${{ github.actor }}" --password-stdin
echo "::error::Set it at: repo Settings → Actions → Variables and Secrets → New Secret."
exit 1
fi
- name: Promote verified ECR image to :latest - name: Retag platform :staging-<sha> → :latest
run: | run: |
set -euo pipefail crane tag \
"${IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}" \
latest
TARGET_TAG="staging-${SHA}" - name: Retag tenant :staging-<sha> → :latest
BODY=$(jq -nc \ run: |
--arg tag "$TARGET_TAG" \ crane tag \
--argjson soak "${SOAK_SECONDS:-120}" \ "${TENANT_IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}" \
--argjson batch "${BATCH_SIZE:-3}" \ latest
--argjson dry false \
'{
target_tag: $tag,
soak_seconds: $soak,
batch_size: $batch,
dry_run: $dry
}')
if [ -n "${CANARY_SLUG:-}" ]; then
BODY=$(jq '. * {canary_slug: $slug}' --arg slug "$CANARY_SLUG" <<<"$BODY")
fi
echo "Calling: POST $CP_URL/cp/admin/tenants/redeploy-fleet"
echo " target_tag: $TARGET_TAG"
echo " body: $BODY"
HTTP_RESPONSE=$(mktemp)
HTTP_CODE_FILE=$(mktemp)
set +e
curl -sS -o "$HTTP_RESPONSE" -w '%{http_code}' \
-m 1200 \
-H "Authorization: Bearer $CP_ADMIN_API_TOKEN" \
-H "Content-Type: application/json" \
-X POST "$CP_URL/cp/admin/tenants/redeploy-fleet" \
-d "$BODY" >"$HTTP_CODE_FILE"
CURL_EXIT=$?
set -e
HTTP_CODE=$(cat "$HTTP_CODE_FILE" 2>/dev/null || echo "000")
[ -z "$HTTP_CODE" ] && HTTP_CODE="000"
echo "HTTP $HTTP_CODE (curl exit $CURL_EXIT)"
cat "$HTTP_RESPONSE" | jq . || cat "$HTTP_RESPONSE"
if [ "$HTTP_CODE" -ge 400 ]; then
echo "::error::CP redeploy-fleet returned HTTP $HTTP_CODE — refusing to proceed."
exit 1
fi
- name: Summary - name: Summary
run: | run: |
{ {
echo "## Canary verified — :latest promoted via CP redeploy-fleet" echo "## Canary verified — :latest promoted"
echo "" echo
echo "- **Target tag:** \`staging-${{ needs.canary-smoke.outputs.sha }}\`" echo "- \`${IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}\` → \`${IMAGE_NAME}:latest\`"
echo "- **Registry:** ECR (\`${TENANT_IMAGE_NAME}\`)" echo "- \`${TENANT_IMAGE_NAME}:staging-${{ needs.canary-smoke.outputs.sha }}\` → \`${TENANT_IMAGE_NAME}:latest\`"
echo "- **Canary slug:** \`${CANARY_SLUG:-<none>}\` (soak ${SOAK_SECONDS}s)" echo
echo "- **Batch size:** ${BATCH_SIZE:-3}" echo "Prod tenant fleet will pick up the new digest on its next 5-min auto-update cycle."
echo ""
echo "CP redeploy-fleet is rolling out the verified image across the prod fleet."
echo "The fleet's 5-minute health-check loop will pick up the update automatically."
} >> "$GITHUB_STEP_SUMMARY" } >> "$GITHUB_STEP_SUMMARY"

View File

@ -14,13 +14,6 @@ name: Check merge_group trigger on required workflows
# Reasoning for staging-only: main has its own CI gating model (PR review), # Reasoning for staging-only: main has its own CI gating model (PR review),
# but staging is what the merge queue runs on, so it's the trigger that # but staging is what the merge queue runs on, so it's the trigger that
# matters. # matters.
#
# Gitea stub: Gitea has no merge queue feature and no `merge_group:`
# event type. The linter would find no `merge_group:` triggers to verify
# (they don't exist on Gitea), so the lint is vacuously satisfied.
# Converting to a no-op stub keeps the workflow+job name stable for any
# commit-status context consumers while eliminating the `gh api` call
# that fails against Gitea's REST surface (#75 / PR-D).
on: on:
pull_request: pull_request:
@ -32,6 +25,9 @@ on:
paths: paths:
- '.github/workflows/**.yml' - '.github/workflows/**.yml'
- '.github/workflows/**.yaml' - '.github/workflows/**.yaml'
# Self-listen on merge_group so the linter passes its own queue run.
merge_group:
types: [checks_requested]
jobs: jobs:
check: check:
@ -40,9 +36,88 @@ jobs:
permissions: permissions:
contents: read contents: read
steps: steps:
- name: Gitea no-op (merge queue not applicable) - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify merge_group trigger on required-check workflows
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
shell: bash
run: | run: |
echo "Gitea Actions — merge queue not supported; no-op." set -euo pipefail
echo "On GitHub this workflow lints that required-check workflows declare"
echo "merge_group: triggers to prevent queue deadlock. On Gitea that" # Branch we care about — the one merge queue runs on.
echo "constraint is inapplicable — all workflows pass vacuously." BRANCH=staging
# Pull the list of required status check contexts. If the branch
# has no protection or no required checks, exit clean — nothing
# to lint.
REQUIRED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection/required_status_checks" \
--jq '.contexts[]' 2>/dev/null || true)
if [ -z "$REQUIRED" ]; then
echo "No required status checks on ${BRANCH} — nothing to verify."
exit 0
fi
echo "Required checks on ${BRANCH}:"
echo "${REQUIRED}" | sed 's/^/ - /'
echo
# Build a map: workflow file -> set of job names declared in it.
# We use yq if available, otherwise grep the `name:` lines under
# `jobs:`. Stick with grep for portability — runner image always
# has it; yq isn't in the default image as of 2026-04.
declare -A workflow_jobs
shopt -s nullglob
for wf in .github/workflows/*.yml .github/workflows/*.yaml; do
[ -f "$wf" ] || continue
# Extract the workflow name (the `name:` at file root).
wf_name=$(awk '/^name:[[:space:]]/ {sub(/^name:[[:space:]]+/,""); gsub(/^"|"$/,""); print; exit}' "$wf")
# Extract job step names from the `jobs:` block. A job step is:
# - id under `jobs:` (key with 2-space indent followed by colon)
# - the `name:` field inside that job (4-space indent)
# We collect both because required_status_checks contexts can
# match either, depending on how the workflow was authored.
jobs_block=$(awk '/^jobs:/{flag=1; next} flag' "$wf")
job_names=$(echo "$jobs_block" | awk '/^[[:space:]]{4}name:[[:space:]]/ {sub(/^[[:space:]]+name:[[:space:]]+/,""); gsub(/^["'"'"']|["'"'"']$/,""); print}')
workflow_jobs["$wf"]="${wf_name}"$'\n'"${job_names}"
done
# For each required check, find the workflow that produces it.
# Then verify that workflow lists merge_group as a trigger.
FAILED=0
while IFS= read -r check; do
[ -z "$check" ] && continue
owning_wf=""
for wf in "${!workflow_jobs[@]}"; do
if echo "${workflow_jobs[$wf]}" | grep -Fxq "$check"; then
owning_wf="$wf"
break
fi
done
if [ -z "$owning_wf" ]; then
echo "::warning::Required check '${check}' has no matching workflow in this repo. Skipping (may be from an external app)."
continue
fi
# Does the workflow's trigger list include merge_group?
# Match either bare `merge_group:` line or merge_group with
# subsequent indented config (types: [checks_requested]).
if grep -qE '^[[:space:]]*merge_group:' "$owning_wf"; then
echo "OK: '${check}' (in $owning_wf) — has merge_group trigger"
else
echo "::error file=${owning_wf}::Required check '${check}' is produced by ${owning_wf}, but the workflow does not declare a 'merge_group:' trigger. With merge queue enabled on ${BRANCH}, this will deadlock the queue (every PR sits AWAITING_CHECKS forever). Add this to the workflow's 'on:' block:"
echo "::error file=${owning_wf}:: merge_group:"
echo "::error file=${owning_wf}:: types: [checks_requested]"
FAILED=1
fi
done <<< "$REQUIRED"
if [ "$FAILED" -ne 0 ]; then
echo
echo "::error::Block. See errors above. Reference: $(grep -l 'reference_merge_queue' /dev/null 2>/dev/null || echo 'memory: reference_merge_queue_enablement.md')."
exit 1
fi
echo
echo "All required workflows on ${BRANCH} declare merge_group triggers."

View File

@ -304,9 +304,13 @@ jobs:
needs: [changes, canvas-build] needs: [changes, canvas-build]
# Only fires on direct pushes to main (i.e. after staging→main promotion). # Only fires on direct pushes to main (i.e. after staging→main promotion).
if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main' if: needs.changes.outputs.canvas == 'true' && github.event_name == 'push' && github.ref == 'refs/heads/main'
permissions:
# Required to post commit comments via the GitHub API.
contents: write
steps: steps:
- name: Write deploy reminder to step summary - name: Post deploy reminder as commit comment
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
COMMIT_SHA: ${{ github.sha }} COMMIT_SHA: ${{ github.sha }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: | run: |
@ -333,13 +337,10 @@ jobs:
printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \ printf '\n> Posted automatically by CI · commit `%s` · [build log](%s)\n' \
"$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md "$COMMIT_SHA" "$RUN_URL" >> /tmp/deploy-reminder.md
# Gitea has no commit-comments API (no equivalent of gh api \
# POST /repos/{owner}/{repo}/commits/{commit_sha}/comments). --method POST \
# Write to GITHUB_STEP_SUMMARY instead — both GitHub Actions and "repos/${{ github.repository }}/commits/${{ github.sha }}/comments" \
# Gitea Actions render this as the workflow run's summary page, --field "body=@/tmp/deploy-reminder.md"
# which is where operators look for post-deploy action items.
# (#75 / PR-D)
cat /tmp/deploy-reminder.md >> "$GITHUB_STEP_SUMMARY"
# Python Lint & Test — required check, always runs. See platform-build # Python Lint & Test — required check, always runs. See platform-build
# for the rationale. # for the rationale.

View File

@ -51,7 +51,7 @@ name: E2E API Smoke Test
# * Pre-pull `alpine:latest` so the platform-server's provisioner # * Pre-pull `alpine:latest` so the platform-server's provisioner
# (`internal/handlers/container_files.go`) can stand up its # (`internal/handlers/container_files.go`) can stand up its
# ephemeral token-write helper without a daemon.io round-trip. # ephemeral token-write helper without a daemon.io round-trip.
# * Create `molecule-core-net` bridge network if missing so the # * Create `molecule-monorepo-net` bridge network if missing so the
# provisioner's container.HostConfig {NetworkMode: ...} attach # provisioner's container.HostConfig {NetworkMode: ...} attach
# succeeds. # succeeds.
# Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/ # Item #1 (timeouts) — evidence on recent runs (77/3191, ae/4270, 0e/
@ -163,12 +163,12 @@ jobs:
# when the image is already present. # when the image is already present.
docker pull alpine:latest >/dev/null docker pull alpine:latest >/dev/null
# Provisioner attaches workspace containers to # Provisioner attaches workspace containers to
# molecule-core-net (workspace-server/internal/provisioner/ # molecule-monorepo-net (workspace-server/internal/provisioner/
# provisioner.go::DefaultNetwork). The bridge already exists on # provisioner.go::DefaultNetwork). The bridge already exists on
# the operator host's docker daemon — `network create` is # the operator host's docker daemon — `network create` is
# idempotent via `|| true`. # idempotent via `|| true`.
docker network create molecule-core-net >/dev/null 2>&1 || true docker network create molecule-monorepo-net >/dev/null 2>&1 || true
echo "alpine:latest pre-pulled; molecule-core-net ensured." echo "alpine:latest pre-pulled; molecule-monorepo-net ensured."
- name: Start Postgres (docker) - name: Start Postgres (docker)
if: needs.detect-changes.outputs.api == 'true' if: needs.detect-changes.outputs.api == 'true'
run: | run: |

View File

@ -22,9 +22,9 @@ on:
# spending CI cycles. See e2e-api.yml for the rationale on why this # spending CI cycles. See e2e-api.yml for the rationale on why this
# is a single job rather than two-jobs-sharing-name. # is a single job rather than two-jobs-sharing-name.
push: push:
branches: [main] branches: [main, staging]
pull_request: pull_request:
branches: [main] branches: [main, staging]
workflow_dispatch: workflow_dispatch:
schedule: schedule:
# Weekly on Sunday 08:00 UTC — catches Chrome / Playwright / Next.js # Weekly on Sunday 08:00 UTC — catches Chrome / Playwright / Next.js

View File

@ -32,7 +32,7 @@ name: E2E Staging External Runtime
on: on:
push: push:
branches: [main] branches: [staging, main]
paths: paths:
- 'workspace-server/internal/handlers/workspace.go' - 'workspace-server/internal/handlers/workspace.go'
- 'workspace-server/internal/handlers/registry.go' - 'workspace-server/internal/handlers/registry.go'
@ -44,7 +44,7 @@ on:
- 'tests/e2e/test_staging_external_runtime.sh' - 'tests/e2e/test_staging_external_runtime.sh'
- '.github/workflows/e2e-staging-external.yml' - '.github/workflows/e2e-staging-external.yml'
pull_request: pull_request:
branches: [main] branches: [staging, main]
paths: paths:
- 'workspace-server/internal/handlers/workspace.go' - 'workspace-server/internal/handlers/workspace.go'
- 'workspace-server/internal/handlers/registry.go' - 'workspace-server/internal/handlers/registry.go'

View File

@ -20,12 +20,13 @@ name: E2E Staging SaaS (full lifecycle)
# via the same paths watcher that e2e-api.yml uses) # via the same paths watcher that e2e-api.yml uses)
on: on:
# Trunk-based (Phase 3 of internal#81): main is the only branch. # Fire on staging push too — previously this only ran on main, which
# Previously this fired on staging push too because staging was a # meant the most thorough end-to-end test caught regressions AFTER
# superset of main and ran the gate ahead of auto-promote; with no # they shipped to staging (and then to the auto-promote PR). Running
# staging branch, main is where E2E gates the deploy. # on staging push catches them BEFORE the staging→main promotion
# opens, so a green canary into auto-promote is more meaningful.
push: push:
branches: [main] branches: [staging, main]
paths: paths:
- 'workspace-server/internal/handlers/registry.go' - 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace_provision.go' - 'workspace-server/internal/handlers/workspace_provision.go'
@ -35,7 +36,7 @@ on:
- 'tests/e2e/test_staging_full_saas.sh' - 'tests/e2e/test_staging_full_saas.sh'
- '.github/workflows/e2e-staging-saas.yml' - '.github/workflows/e2e-staging-saas.yml'
pull_request: pull_request:
branches: [main] branches: [staging, main]
paths: paths:
- 'workspace-server/internal/handlers/registry.go' - 'workspace-server/internal/handlers/registry.go'
- 'workspace-server/internal/handlers/workspace_provision.go' - 'workspace-server/internal/handlers/workspace_provision.go'

View File

@ -14,42 +14,12 @@ name: Handlers Postgres Integration
# self-review caught it took 2 minutes to set up and would have caught # self-review caught it took 2 minutes to set up and would have caught
# the bug at PR-time. # the bug at PR-time.
# #
# Why this workflow does NOT use `services: postgres:` (Class B fix) # This job spins a Postgres service container, applies the migration,
# ------------------------------------------------------------------ # and runs `go test -tags=integration` against a live DB. Required
# Our act_runner config has `container.network: host` (operator host # check on staging branch protection — backend handler PRs cannot
# /opt/molecule/runners/config.yaml), which act_runner applies to BOTH # merge without a real-DB regression gate.
# the job container AND every service container. With host-net, two
# concurrent runs of this workflow both try to bind 0.0.0.0:5432 — the
# second postgres FATALs with `could not create any TCP/IP sockets:
# Address in use`, and Docker auto-removes it (act_runner sets
# AutoRemove:true on service containers). By the time the migrations
# step runs `psql`, the postgres container is gone, hence
# `Connection refused` then `failed to remove container: No such
# container` at cleanup time.
# #
# Per-job `container.network` override is silently ignored by # Cost: ~30s job (postgres pull from GH cache + go build + 4 tests).
# act_runner — `--network and --net in the options will be ignored.`
# appears in the runner log. Documented constraint.
#
# So we sidestep `services:` entirely. The job container still uses
# host-net (inherited from runner config; required for cache server
# discovery on the bridge IP 172.18.0.17:42631). We launch a sibling
# postgres on the existing `molecule-core-net` bridge with a
# UNIQUE name per run — `pg-handlers-${RUN_ID}-${RUN_ATTEMPT}` — and
# read its bridge IP via `docker inspect`. A host-net job container
# can reach a bridge-net container directly via the bridge IP (verified
# manually on operator host 2026-05-08).
#
# Trade-offs vs. the original `services:` shape:
# + No host-port collision; N parallel runs share the bridge cleanly
# + `if: always()` cleanup runs even on test-step failure
# - One more step in the workflow (+~3 lines)
# - Requires `molecule-core-net` to exist on the operator host
# (it does; declared in docker-compose.yml + docker-compose.infra.yml)
#
# Class B Hongming-owned CICD red sweep, 2026-05-08.
#
# Cost: ~30s job (postgres pull from cache + go build + 4 tests).
on: on:
push: push:
@ -89,14 +59,20 @@ jobs:
name: Handlers Postgres Integration name: Handlers Postgres Integration
needs: detect-changes needs: detect-changes
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: services:
# Unique name per run so concurrent jobs don't collide on the postgres:
# bridge network. ${RUN_ID}-${RUN_ATTEMPT} is unique even across image: postgres:15-alpine
# workflow_dispatch reruns of the same run_id. env:
PG_NAME: pg-handlers-${{ github.run_id }}-${{ github.run_attempt }} POSTGRES_PASSWORD: test
# Bridge network already exists on the operator host (declared POSTGRES_DB: molecule
# in docker-compose.yml + docker-compose.infra.yml). ports:
PG_NETWORK: molecule-core-net - 5432:5432
# GHA spins this with --health-cmd built in for postgres images.
options: >-
--health-cmd pg_isready
--health-interval 5s
--health-timeout 5s
--health-retries 10
defaults: defaults:
run: run:
working-directory: workspace-server working-directory: workspace-server
@ -113,57 +89,16 @@ jobs:
with: with:
go-version: 'stable' go-version: 'stable'
- if: needs.detect-changes.outputs.handlers == 'true'
name: Start sibling Postgres on bridge network
working-directory: .
run: |
# Sanity: the bridge network must exist on the operator host.
# Hard-fail loud if it doesn't — easier to spot than a silent
# auto-create that diverges from the rest of the stack.
if ! docker network inspect "${PG_NETWORK}" >/dev/null 2>&1; then
echo "::error::Bridge network '${PG_NETWORK}' missing on operator host. Re-run docker-compose.infra.yml or check ops handbook."
exit 1
fi
# If a stale container with the same name exists (rerun on
# the same run_id), wipe it first.
docker rm -f "${PG_NAME}" >/dev/null 2>&1 || true
docker run -d \
--name "${PG_NAME}" \
--network "${PG_NETWORK}" \
--health-cmd "pg_isready -U postgres" \
--health-interval 5s \
--health-timeout 5s \
--health-retries 10 \
-e POSTGRES_PASSWORD=test \
-e POSTGRES_DB=molecule \
postgres:15-alpine >/dev/null
# Read back the bridge IP. Always present immediately after
# `docker run -d` for bridge networks.
PG_HOST=$(docker inspect "${PG_NAME}" \
--format "{{(index .NetworkSettings.Networks \"${PG_NETWORK}\").IPAddress}}")
if [ -z "${PG_HOST}" ]; then
echo "::error::Could not resolve PG_HOST for ${PG_NAME} on ${PG_NETWORK}"
docker logs "${PG_NAME}" || true
exit 1
fi
echo "PG_HOST=${PG_HOST}" >> "$GITHUB_ENV"
echo "INTEGRATION_DB_URL=postgres://postgres:test@${PG_HOST}:5432/molecule?sslmode=disable" >> "$GITHUB_ENV"
echo "Started ${PG_NAME} at ${PG_HOST}:5432"
- if: needs.detect-changes.outputs.handlers == 'true' - if: needs.detect-changes.outputs.handlers == 'true'
name: Apply migrations to Postgres service name: Apply migrations to Postgres service
env: env:
PGPASSWORD: test PGPASSWORD: test
run: | run: |
# Wait for postgres to actually accept connections. Docker's # Wait for postgres to actually accept connections (the
# health-cmd handles container-side readiness, but the wire # GHA --health-cmd is best-effort but psql can still race).
# to the bridge IP is best-tested with pg_isready directly.
for i in {1..15}; do for i in {1..15}; do
if pg_isready -h "${PG_HOST}" -p 5432 -U postgres -q; then break; fi if pg_isready -h localhost -p 5432 -U postgres -q; then break; fi
echo "waiting for postgres at ${PG_HOST}:5432..."; sleep 2 echo "waiting for postgres..."; sleep 2
done done
# Apply every .up.sql in lexicographic order with # Apply every .up.sql in lexicographic order with
@ -196,7 +131,7 @@ jobs:
# not fine once a cross-table atomicity test came in. # not fine once a cross-table atomicity test came in.
set +e set +e
for migration in $(ls migrations/*.sql 2>/dev/null | grep -v '\.down\.sql$' | sort); do for migration in $(ls migrations/*.sql 2>/dev/null | grep -v '\.down\.sql$' | sort); do
if psql -h "${PG_HOST}" -U postgres -d molecule -v ON_ERROR_STOP=1 \ if psql -h localhost -U postgres -d molecule -v ON_ERROR_STOP=1 \
-f "$migration" >/dev/null 2>&1; then -f "$migration" >/dev/null 2>&1; then
echo "✓ $(basename "$migration")" echo "✓ $(basename "$migration")"
else else
@ -210,7 +145,7 @@ jobs:
# fail if any didn't land — that would be a real regression we # fail if any didn't land — that would be a real regression we
# want loud. # want loud.
for tbl in delegations workspaces activity_logs pending_uploads; do for tbl in delegations workspaces activity_logs pending_uploads; do
if ! psql -h "${PG_HOST}" -U postgres -d molecule -tA \ if ! psql -h localhost -U postgres -d molecule -tA \
-c "SELECT 1 FROM information_schema.tables WHERE table_name = '$tbl'" \ -c "SELECT 1 FROM information_schema.tables WHERE table_name = '$tbl'" \
| grep -q 1; then | grep -q 1; then
echo "::error::$tbl table missing after migration replay — handler integration tests would be meaningless" echo "::error::$tbl table missing after migration replay — handler integration tests would be meaningless"
@ -221,32 +156,16 @@ jobs:
- if: needs.detect-changes.outputs.handlers == 'true' - if: needs.detect-changes.outputs.handlers == 'true'
name: Run integration tests name: Run integration tests
env:
INTEGRATION_DB_URL: postgres://postgres:test@localhost:5432/molecule?sslmode=disable
run: | run: |
# INTEGRATION_DB_URL is exported by the start-postgres step;
# points at the per-run bridge IP, not 127.0.0.1, so concurrent
# workflow runs don't fight over a host-net 5432 port.
go test -tags=integration -timeout 5m -v ./internal/handlers/ -run "^TestIntegration_" go test -tags=integration -timeout 5m -v ./internal/handlers/ -run "^TestIntegration_"
- if: failure() && needs.detect-changes.outputs.handlers == 'true' - if: needs.detect-changes.outputs.handlers == 'true' && failure()
name: Diagnostic dump on failure name: Diagnostic dump on failure
env: env:
PGPASSWORD: test PGPASSWORD: test
run: | run: |
echo "::group::postgres container status"
docker ps -a --filter "name=${PG_NAME}" --format '{{.Status}} {{.Names}}' || true
docker logs "${PG_NAME}" 2>&1 | tail -50 || true
echo "::endgroup::"
echo "::group::delegations table state" echo "::group::delegations table state"
psql -h "${PG_HOST}" -U postgres -d molecule -c "SELECT * FROM delegations LIMIT 50;" || true psql -h localhost -U postgres -d molecule -c "SELECT * FROM delegations LIMIT 50;" || true
echo "::endgroup::" echo "::endgroup::"
- if: always() && needs.detect-changes.outputs.handlers == 'true'
name: Stop sibling Postgres
working-directory: .
run: |
# always() so containers don't leak when migrations or tests
# fail. The cleanup is best-effort: if the container is
# already gone (e.g. concurrent rerun race), don't fail the job.
docker rm -f "${PG_NAME}" >/dev/null 2>&1 || true
echo "Cleaned up ${PG_NAME}"

View File

@ -56,40 +56,21 @@ jobs:
run: ${{ steps.decide.outputs.run }} run: ${{ steps.decide.outputs.run }}
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: filter
with:
filters: |
run:
- 'workspace-server/**'
- 'canvas/**'
- 'tests/harness/**'
- '.github/workflows/harness-replays.yml'
- id: decide - id: decide
run: | run: |
# workflow_dispatch: always run (manual trigger)
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "run=true" >> "$GITHUB_OUTPUT" echo "run=true" >> "$GITHUB_OUTPUT"
echo "debug=manual-trigger" >> "$GITHUB_OUTPUT"
exit 0
fi
# Determine the base commit to diff against.
# For pull_request: use base.sha (the merge-base with main/staging).
# For push: use github.event.before (the previous tip of the branch).
# Fallback for new branches (all-zeros SHA): run everything.
if [ "${{ github.event_name }}" = "pull_request" ] && \
[ -n "${{ github.event.pull_request.base.sha }}" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "${{ github.event.before }}" ] && \
! echo "${{ github.event.before }}" | grep -qE '^0+$'; then
BASE="${{ github.event.before }}"
else else
# New branch or github.event.before unavailable — run everything. echo "run=${{ steps.filter.outputs.run }}" >> "$GITHUB_OUTPUT"
echo "run=true" >> "$GITHUB_OUTPUT"
echo "debug=new-branch-fallback" >> "$GITHUB_OUTPUT"
exit 0
fi
# GitHub Actions and Gitea Actions both expose github.sha for HEAD.
DIFF=$(git diff --name-only "$BASE" "${{ github.sha }}" 2>/dev/null)
echo "debug=diff-base=$BASE diff-files=$DIFF" >> "$GITHUB_OUTPUT"
if echo "$DIFF" | grep -qE '^workspace-server/|^canvas/|^tests/harness/|^.github/workflows/harness-replays\.yml$'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
fi fi
# ONE job that always runs. Real work is gated per-step on # ONE job that always runs. Real work is gated per-step on
@ -110,17 +91,10 @@ jobs:
run: | run: |
echo "No workspace-server / canvas / tests/harness / workflow changes — Harness Replays gate satisfied without running." echo "No workspace-server / canvas / tests/harness / workflow changes — Harness Replays gate satisfied without running."
echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)." echo "::notice::Harness Replays no-op pass (paths filter excluded this commit)."
echo "::notice::Debug: ${{ needs.detect-changes.outputs.debug }}"
- if: needs.detect-changes.outputs.run == 'true' - if: needs.detect-changes.outputs.run == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
# Log what files were detected so future failures include the diff.
- name: Log detected changes
if: needs.detect-changes.outputs.run == 'true'
run: |
echo "::notice::detect-changes debug: ${{ needs.detect-changes.outputs.debug }}"
# github-app-auth sibling-checkout removed 2026-05-07 (#157): # github-app-auth sibling-checkout removed 2026-05-07 (#157):
# the plugin was dropped + Dockerfile.tenant no longer COPYs it. # the plugin was dropped + Dockerfile.tenant no longer COPYs it.
@ -145,17 +119,6 @@ jobs:
# symptom, different root cause: staging still has the in-image # symptom, different root cause: staging still has the in-image
# clone path, hits the auth error directly). # clone path, hits the auth error directly).
# #
# 2026-05-08 sub-finding (#192): the clone step ALSO fails when
# any referenced workspace-template repo is private and the
# AUTO_SYNC_TOKEN bearer (devops-engineer persona) lacks read
# access. Root cause: 5 of 9 workspace-template repos
# (openclaw, codex, crewai, deepagents, gemini-cli) had been
# marked private with no team grant. Resolution: flipped them
# to public per `feedback_oss_first_repo_visibility_default`
# (the OSS surface should be public). Layer-3 (customer-private +
# marketplace third-party repos) tracked separately in
# internal#102.
#
# Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN # Token shape matches publish-workspace-server-image.yml: AUTO_SYNC_TOKEN
# is the devops-engineer persona PAT, NOT the founder PAT (per # is the devops-engineer persona PAT, NOT the founder PAT (per
# `feedback_per_agent_gitea_identity_default`). clone-manifest.sh # `feedback_per_agent_gitea_identity_default`). clone-manifest.sh

View File

@ -1,25 +1,14 @@
name: pr-guards name: pr-guards
# PR-time guards. Today the only guard is "disable auto-merge when a # Thin caller that delegates to the molecule-ci reusable guard. Today
# new commit is pushed after auto-merge was enabled" — added 2026-04-27 # the guard is just "disable auto-merge when a new commit is pushed
# after PR #2174 auto-merged with only its first commit because the # after auto-merge was enabled" — added 2026-04-27 after PR #2174
# second commit was pushed after the merge queue had locked the PR's # auto-merged with only its first commit because the second commit
# SHA. # was pushed after the merge queue had locked the PR's SHA.
# #
# Why this is inlined (not delegated to molecule-ci's reusable # When more PR-time guards land in molecule-ci, add them here as
# workflow): the reusable workflow uses `gh pr merge --disable-auto`, # additional jobs that share the same pull_request:synchronize
# which calls GitHub's GraphQL API. Gitea has no GraphQL endpoint and # trigger.
# returns HTTP 405 on /api/graphql, so the job failed on every Gitea
# PR push since the 2026-05-06 migration. Gitea also has no `--auto`
# merge primitive that this job could be acting on, so the right
# behaviour on Gitea is "no-op + green status" — not a 405.
#
# Inlining (vs. an `if:` on the `uses:` line) keeps the job ALWAYS
# running, which matters for branch protection: required-check names
# need a job that emits SUCCESS terminal state, not SKIPPED. See
# `feedback_branch_protection_check_name_parity` and `feedback_pr_merge_safety_guards`.
#
# Issue #88 item 1.
on: on:
pull_request: pull_request:
@ -30,34 +19,4 @@ permissions:
jobs: jobs:
disable-auto-merge-on-push: disable-auto-merge-on-push:
runs-on: ubuntu-latest uses: molecule-ai/molecule-ci/.github/workflows/disable-auto-merge-on-push.yml@main
steps:
# Detect Gitea Actions. act_runner sets GITEA_ACTIONS=true in the
# step env on every job. Belt-and-suspenders: also check the repo
# url's host, which is independent of any runner-side env config
# (covers a future Gitea host where the env var is forgotten).
- name: Detect runner host
id: host
run: |
if [[ "${GITEA_ACTIONS:-}" == "true" ]] || [[ "${{ github.server_url }}" == *moleculesai.app* ]] || [[ "${{ github.event.repository.html_url }}" == *moleculesai.app* ]]; then
echo "is_gitea=true" >> "$GITHUB_OUTPUT"
echo "::notice::Gitea Actions detected — auto-merge gating is not applicable here (Gitea has no --auto merge primitive). Job will no-op."
else
echo "is_gitea=false" >> "$GITHUB_OUTPUT"
fi
- name: Disable auto-merge (GitHub only)
if: steps.host.outputs.is_gitea != 'true'
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
NEW_SHA: ${{ github.sha }}
run: |
set -eu
gh pr merge "$PR" --disable-auto -R "$REPO" || true
gh pr comment "$PR" -R "$REPO" --body "🔒 Auto-merge disabled — new commit (\`${NEW_SHA:0:7}\`) pushed after auto-merge was enabled. The merge queue locks SHAs at entry, so subsequent pushes can race. Verify the new commit and re-enable with \`gh pr merge --auto\`."
- name: Gitea no-op
if: steps.host.outputs.is_gitea == 'true'
run: echo "Gitea Actions — auto-merge gating not applicable; no-op (job intentionally green so branch protection's required-check name lands SUCCESS)."

View File

@ -54,22 +54,6 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
- name: Compute tags - name: Compute tags
id: tags id: tags
shell: bash shell: bash

View File

@ -1,15 +1,5 @@
name: publish-runtime name: publish-runtime
# DEPRECATED on Gitea Actions — this file is kept for reference only.
# Gitea Actions reads .gitea/workflows/, not .github/workflows/.
# The canonical version is now: .gitea/workflows/publish-runtime.yml
# That port:
# - Drops OIDC trusted publisher (Gitea has no environments/OIDC)
# - Uses PYPI_TOKEN secret instead of gh-action-pypi-publish
# - Uses ${GITHUB_REF#refs/tags/} instead of github.ref_name
# - Drops staging branch trigger (staging branch does not exist)
# - Drops merge_group trigger (Gitea has no merge queue)
#
# Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/. # Publishes molecule-ai-workspace-runtime to PyPI from monorepo workspace/.
# Monorepo workspace/ is the only source-of-truth for runtime code; this # Monorepo workspace/ is the only source-of-truth for runtime code; this
# workflow is the bridge from monorepo edits to the PyPI artifact that # workflow is the bridge from monorepo edits to the PyPI artifact that
@ -180,7 +170,7 @@ jobs:
# environment pypi-publish. The action mints a short-lived OIDC # environment pypi-publish. The action mints a short-lived OIDC
# token and exchanges it for a PyPI upload credential — no static # token and exchanges it for a PyPI upload credential — no static
# API token in this repo's secrets. # API token in this repo's secrets.
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 uses: pypa/gh-action-pypi-publish@release/v1
with: with:
packages-dir: ${{ runner.temp }}/runtime-build/dist/ packages-dir: ${{ runner.temp }}/runtime-build/dist/
@ -292,33 +282,42 @@ jobs:
echo "::error::Refusing to fan out cascade against stale or corrupt PyPI surfaces." echo "::error::Refusing to fan out cascade against stale or corrupt PyPI surfaces."
exit 1 exit 1
- name: Fan out via push to .runtime-version - name: Fan out repository_dispatch
env: env:
# Gitea PAT with write:repository scope on the 8 cascade-active # Fine-grained PAT with `actions:write` on the 8 template repos.
# template repos. Used here for `git push` (NOT for an API # GITHUB_TOKEN can't fire dispatches across repos — needs an explicit
# dispatch — Gitea 1.22.6 has no repository_dispatch endpoint; # token. Stored as a repo secret; rotate per the standard schedule.
# empirically verified across 6 candidate paths in molecule- DISPATCH_TOKEN: ${{ secrets.TEMPLATE_DISPATCH_TOKEN }}
# core#20 issuecomment-913). The push trips each template's # Single source of truth: the publish job's output, which handles
# existing `on: push: branches: [main]` trigger on # tag/manual-input/auto-bump uniformly. The previous fallback
# publish-image.yml, which then reads the updated # (`steps.version.outputs.version` from inside the cascade job)
# .runtime-version via its resolve-version job. # was a dead reference — different job, no shared step scope.
DISPATCH_TOKEN: ${{ secrets.DISPATCH_TOKEN }}
RUNTIME_VERSION: ${{ needs.publish.outputs.version }} RUNTIME_VERSION: ${{ needs.publish.outputs.version }}
run: | run: |
set +e # don't abort on a single repo failure — collect them all set +e # don't abort on a single repo failure — collect them all
# Schedule-vs-dispatch behaviour split (hardened 2026-04-28
# Soft-skip on workflow_dispatch when the token is missing # after the sweep-cf-orphans soft-skip incident — same class
# (operator ad-hoc test); hard-fail on push so unattended # of bug):
# publishes can't silently skip the cascade. Same shape as #
# the original v1, intentional split per the schedule-vs- # The earlier "skipping cascade. templates will pick up the
# dispatch hardening 2026-04-28. # new version on their own next rebuild" message was wrong —
# templates only build on this dispatch trigger; without it
# they stay pinned to whatever runtime version they last saw.
# A silent skip here means "PyPI is current, templates are
# not" and the gap is invisible until someone notices a
# template still on the old version weeks later.
#
# - push → exit 1 (red CI surfaces the gap)
# - workflow_dispatch → exit 0 with a warning (operator
# ran this ad-hoc; let them rerun
# after fixing the secret)
if [ -z "$DISPATCH_TOKEN" ]; then if [ -z "$DISPATCH_TOKEN" ]; then
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "::warning::DISPATCH_TOKEN secret not set — skipping cascade." echo "::warning::TEMPLATE_DISPATCH_TOKEN secret not set — skipping cascade."
echo "::warning::set it at Settings → Secrets and Variables → Actions, then rerun. Templates will stay on the prior runtime version until either this token is set or each template is rebuilt manually." echo "::warning::set it at Settings → Secrets and Variables → Actions, then rerun. Templates will stay on the prior runtime version until either this token is set or each template is rebuilt manually."
exit 0 exit 0
fi fi
echo "::error::DISPATCH_TOKEN secret missing — cascade cannot fan out." echo "::error::TEMPLATE_DISPATCH_TOKEN secret missing — cascade cannot fan out."
echo "::error::PyPI was published, but the 8 template repos will NOT pick up the new version until this token is restored and a republish dispatches the cascade." echo "::error::PyPI was published, but the 8 template repos will NOT pick up the new version until this token is restored and a republish dispatches the cascade."
echo "::error::set it at Settings → Secrets and Variables → Actions; then re-trigger publish-runtime via workflow_dispatch." echo "::error::set it at Settings → Secrets and Variables → Actions; then re-trigger publish-runtime via workflow_dispatch."
exit 1 exit 1
@ -328,119 +327,37 @@ jobs:
echo "::error::publish job did not expose a version output — cascade cannot fan out" echo "::error::publish job did not expose a version output — cascade cannot fan out"
exit 1 exit 1
fi fi
# All 9 active workspace template repos. The PR #2536 pruning
# All 9 workspace templates declared in manifest.json. The list # ("deprecated, no shipping images") was empirically wrong:
# MUST stay aligned with manifest.json's workspace_templates — # continuous-synth-e2e.yml defaults to langgraph as its primary
# cascade-list-drift-gate.yml enforces this in CI per the # canary (line 44), and every excluded template had successful
# codex-stuck-on-stale-runtime invariant from PR #2556. # publish-image runs as of 2026-05-03 — none were dormant.
# Long-term goal: derive this list from manifest.json so it # Symptom of the prune: today's a2a-sdk strict-mode fix
# can't drift even on a manifest edit (RFC #388 Phase-1). # (#2566 / commit e1628c4) cascaded to 4 templates but never
# # reached langgraph, so the synth-E2E correctly canary'd a fix
# Per-template publish-image.yml presence is checked at # that had landed but not deployed. Re-added the 5 templates.
# cascade-time below: codex doesn't ship one today, so the # Long-term: derive this list from manifest.json so cascade
# cascade soft-skips it with an informational message rather # scope can't drift from E2E scope — tracked in RFC #388 as a
# than dropping it from this list (which would re-introduce # Phase-1 invariant.
# the drift the gate exists to catch).
GITEA_URL="${GITEA_URL:-https://git.moleculesai.app}"
TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli" TEMPLATES="claude-code hermes openclaw codex langgraph crewai autogen deepagents gemini-cli"
FAILED="" FAILED=""
SKIPPED=""
# Configure git identity once. The persona owning DISPATCH_TOKEN
# is the same identity that authored this commit on each
# template; using a generic "publish-runtime cascade" co-author
# trailer in the message keeps the audit trail honest about the
# workflow-driven origin.
git config --global user.name "publish-runtime cascade"
git config --global user.email "publish-runtime@moleculesai.app"
WORKDIR="$(mktemp -d)"
for tpl in $TEMPLATES; do for tpl in $TEMPLATES; do
REPO="molecule-ai/molecule-ai-workspace-template-$tpl" REPO="molecule-ai/molecule-ai-workspace-template-$tpl"
CLONE="$WORKDIR/$tpl" STATUS=$(curl -sS -o /tmp/dispatch.out -w "%{http_code}" \
-X POST "https://api.github.com/repos/$REPO/dispatches" \
# Pre-check: skip templates without a publish-image.yml. -H "Authorization: Bearer $DISPATCH_TOKEN" \
# The cascade's job is to trip the template's on-push -H "Accept: application/vnd.github+json" \
# rebuild — if there's no rebuild workflow, pushing a -H "X-GitHub-Api-Version: 2022-11-28" \
# .runtime-version commit is just noise on the target -d "{\"event_type\":\"runtime-published\",\"client_payload\":{\"runtime_version\":\"$VERSION\"}}")
# repo. Use the Gitea contents API (no clone required for if [ "$STATUS" = "204" ]; then
# the probe). 200 = present; 404 = absent. echo "✓ dispatched $tpl ($VERSION)"
HTTP=$(curl -sS -o /dev/null -w "%{http_code}" \ else
-H "Authorization: token $DISPATCH_TOKEN" \ echo "::warning::✗ failed to dispatch $tpl: HTTP $STATUS — $(cat /tmp/dispatch.out)"
"$GITEA_URL/api/v1/repos/$REPO/contents/.github/workflows/publish-image.yml")
if [ "$HTTP" = "404" ]; then
echo "↷ $tpl has no publish-image.yml — soft-skip (informational; manifest still tracks it)"
SKIPPED="$SKIPPED $tpl"
continue
fi
if [ "$HTTP" != "200" ]; then
echo "::warning::$tpl publish-image.yml probe returned HTTP $HTTP — proceeding anyway, push will surface the real failure if any"
fi
# Use a per-template attempt loop so a transient race (e.g.
# human pushing to the same template at the same instant)
# doesn't lose the cascade. Bounded retries (3) — beyond
# that we surface the failure and let the operator retry.
attempt=0
success=false
while [ $attempt -lt 3 ]; do
attempt=$((attempt + 1))
rm -rf "$CLONE"
if ! git clone --depth=1 \
"https://x-access-token:${DISPATCH_TOKEN}@${GITEA_URL#https://}/$REPO.git" \
"$CLONE" >/tmp/clone.log 2>&1; then
echo "::warning::clone $tpl attempt $attempt failed: $(tail -n3 /tmp/clone.log)"
sleep 2
continue
fi
cd "$CLONE"
echo "$VERSION" > .runtime-version
# Idempotency guard: if the file already matches, this
# publish is a re-run for a version already cascaded.
# Don't push a no-op commit (would spuriously re-trip the
# template's on-push and rebuild for nothing).
if git diff --quiet -- .runtime-version; then
echo "✓ $tpl already at $VERSION — no commit needed (idempotent)"
success=true
cd - >/dev/null
break
fi
git add .runtime-version
git commit -m "chore: pin runtime to $VERSION (publish-runtime cascade)" \
-m "Co-Authored-By: publish-runtime cascade <publish-runtime@moleculesai.app>" \
>/dev/null
if git push origin HEAD:main >/tmp/push.log 2>&1; then
echo "✓ $tpl pushed $VERSION on attempt $attempt"
success=true
cd - >/dev/null
break
fi
# Likely a non-fast-forward — pull-rebase and retry.
# Don't force-push: that would silently overwrite a racing
# human/cascade commit.
echo "::warning::push $tpl attempt $attempt failed, pull-rebasing: $(tail -n3 /tmp/push.log)"
git pull --rebase origin main >/tmp/rebase.log 2>&1 || true
cd - >/dev/null
done
if [ "$success" != "true" ]; then
FAILED="$FAILED $tpl" FAILED="$FAILED $tpl"
fi fi
done done
rm -rf "$WORKDIR"
if [ -n "$FAILED" ]; then if [ -n "$FAILED" ]; then
echo "::error::Cascade incomplete after 3 retries each. Failed templates:$FAILED" echo "::warning::Cascade incomplete. Failed templates:$FAILED"
echo "::error::PyPI publish succeeded; failed templates lag the new version. Re-run this workflow_dispatch with the same version to retry only the laggers (idempotent — already-cascaded templates skip)." # Don't fail the whole job — PyPI publish already succeeded;
exit 1 # operators can retry the failed templates manually.
fi
if [ -n "$SKIPPED" ]; then
echo "Cascade complete: pinned $VERSION on cascade-active templates. Soft-skipped (no publish-image.yml):$SKIPPED"
else
echo "Cascade complete: $VERSION pinned across all manifest workspace_templates."
fi fi

View File

@ -32,7 +32,7 @@ name: publish-workspace-server-image
on: on:
push: push:
branches: [main] branches: [staging, main]
paths: paths:
- 'workspace-server/**' - 'workspace-server/**'
- 'canvas/**' - 'canvas/**'
@ -107,22 +107,6 @@ jobs:
run: | run: |
echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT" echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
# Health check: verify Docker daemon is accessible before attempting any
# build steps. This fails loudly at step 1 when the runner's docker.sock
# is inaccessible rather than silently continuing to the build step
# where docker build fails deep in ECR auth with a cryptic error.
- name: Verify Docker daemon access
run: |
set -euo pipefail
echo "::group::Docker daemon health check"
docker info 2>&1 | head -5 || {
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
exit 1
}
echo "Docker daemon OK"
echo "::endgroup::"
# Pre-clone manifest deps before docker build (Task #173 fix). # Pre-clone manifest deps before docker build (Task #173 fix).
# #
# Why pre-clone: post-2026-05-06, every workspace-template-* repo on # Why pre-clone: post-2026-05-06, every workspace-template-* repo on

View File

@ -3,9 +3,9 @@ name: redeploy-tenants-on-main
# Auto-refresh prod tenant EC2s after every main merge. # Auto-refresh prod tenant EC2s after every main merge.
# #
# Why this workflow exists: publish-workspace-server-image builds and # Why this workflow exists: publish-workspace-server-image builds and
# pushes a new platform-tenant :<sha> to ECR on every merge to main, # pushes a new platform-tenant:latest + :<sha> to GHCR on every merge
# but running tenants pulled their image once at boot and never re-pull. # to main, but running tenants pulled their image once at boot and
# Users see stale code indefinitely. # never re-pull. Users see stale code indefinitely.
# #
# This workflow closes the gap by calling the control-plane admin # This workflow closes the gap by calling the control-plane admin
# endpoint that performs a canary-first, batched, health-gated rolling # endpoint that performs a canary-first, batched, health-gated rolling
@ -13,18 +13,12 @@ name: redeploy-tenants-on-main
# molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet # molecule-controlplane as POST /cp/admin/tenants/redeploy-fleet
# (feat/tenant-auto-redeploy, landing alongside this workflow). # (feat/tenant-auto-redeploy, landing alongside this workflow).
# #
# Registry: ECR (153263036946.dkr.ecr.us-east-2.amazonaws.com/
# molecule-ai/platform-tenant). GHCR was retired 2026-05-07 during the
# Gitea suspension migration. The canary-verify.yml promote step now
# uses the same redeploy-fleet endpoint (fixes the silent-GHCR gap).
#
# Runtime ordering: # Runtime ordering:
# 1. publish-workspace-server-image completes → new :staging-<sha> in ECR. # 1. publish-workspace-server-image completes → new :latest in GHCR.
# 2. This workflow fires via workflow_run, calls redeploy-fleet with # 2. This workflow fires via workflow_run, waits 30s for GHCR's
# target_tag=staging-<sha>. No CDN propagation wait needed — # CDN to propagate the new tag to the region the tenants pull from.
# ECR image manifest is consistent immediately after push. # 3. Calls redeploy-fleet with canary_slug=hongming and a 60s
# 3. Calls redeploy-fleet with canary_slug (if set) and a soak # soak. Canary proves the image boots; batches follow.
# period. Canary proves the image boots; batches follow.
# 4. Any failure aborts the rollout and leaves older tenants on the # 4. Any failure aborts the rollout and leaves older tenants on the
# prior image — safer default than half-and-half state. # prior image — safer default than half-and-half state.
# #
@ -114,11 +108,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 25
steps: steps:
- name: Note on ECR propagation - name: Wait for GHCR tag propagation
# ECR image manifests are consistent immediately after push — no # GHCR's edge cache takes ~15-30s to consistently serve the new
# CDN cache to wait for. The old GHCR-based workflow had a 30s # manifest after the registry accepts the push. Without this
# sleep to avoid race conditions; ECR makes that unnecessary. # sleep, the first tenant's docker pull sometimes races and
run: echo "ECR image available immediately after push — proceeding." # fetches the previous digest; sleeping is the cheapest way to
# reduce that without polling GHCR for the new digest.
run: sleep 30
- name: Compute target tag - name: Compute target tag
id: tag id: tag

View File

@ -36,7 +36,7 @@ on:
workflow_run: workflow_run:
workflows: ['publish-workspace-server-image'] workflows: ['publish-workspace-server-image']
types: [completed] types: [completed]
branches: [main] branches: [staging]
workflow_dispatch: workflow_dispatch:
inputs: inputs:
target_tag: target_tag:

View File

@ -0,0 +1,276 @@
name: Retarget main PRs to staging
# Mechanical enforcement of SHARED_RULES rule 8 ("Staging-first
# workflow, no exceptions"). When a bot opens a PR against `main`,
# retarget it to `staging` automatically and leave an explanatory
# comment. Human / CEO-authored PRs (the staging→main promotion
# PRs, etc.) are left alone — they're the authorised exception
# to the rule.
#
# ============================================================
# What this workflow does
# ============================================================
#
# On `pull_request_target` opened/reopened against `main`:
# 1. If the PR head is `staging`, skip (the auto-promote PRs
# MUST stay base=main).
# 2. If the PR author is a bot, retarget the PR base to
# `staging` via Gitea REST `PATCH /pulls/{N}` body
# `{"base":"staging"}`.
# 3. If the retarget returns 422 "pull request already exists
# for base branch 'staging'" (issue #1884 case: another PR
# on the same head already targets staging), close the
# now-redundant main-PR via Gitea REST instead of failing
# red.
# 4. Post an explainer comment on the retargeted PR via
# Gitea REST `POST /issues/{N}/comments`.
#
# ============================================================
# Why Gitea REST (and not `gh api / gh pr close / gh pr comment`)
# ============================================================
#
# Pre-2026-05-06 this workflow used `gh api -X PATCH "repos/{owner}/{repo}/pulls/{N}" -f base=staging`
# plus `gh pr close` and `gh pr comment`. After the GitHub→Gitea
# cutover those calls fail because:
#
# - `gh` CLI defaults to `api.github.com`. Even with `GH_HOST`
# pointing at Gitea, `gh pr close / comment` route through
# GraphQL (`/api/graphql`) which Gitea does not expose.
# Empirical: every `gh pr *` call returns
# `HTTP 405 Method Not Allowed (https://git.moleculesai.app/api/graphql)`
# — same root cause as #65 (auto-sync, fixed in PR #66) and
# #73/#195 (auto-promote, fixed in PR #78).
# - `gh api -X PATCH /pulls/{N}` happens to use a REST path
# that Gitea also has, but the `gh` host-resolution layer
# and pagination/retry logic don't always hit Gitea cleanly,
# and the cost of switching to direct `curl` is one extra
# line of code.
#
# So this workflow uses direct `curl` calls to Gitea REST. No
# `gh` CLI dependency, no GraphQL, no flaky host-resolution.
#
# ============================================================
# Identity + token (anti-bot-ring per saved-memory
# `feedback_per_agent_gitea_identity_default`)
# ============================================================
#
# Pre-fix this workflow used the per-job ephemeral
# `secrets.GITHUB_TOKEN`. On Gitea Actions that token has
# narrow scope and unpredictable cross-PR write capability.
#
# Post-fix: `secrets.AUTO_SYNC_TOKEN` (the `devops-engineer`
# Gitea persona). Same persona used by `auto-sync-main-to-staging.yml`
# (PR #66) and `auto-promote-staging.yml` (PR #78). Token scope:
# `push: true` repo write, sufficient for PR-edit + close + comment.
#
# Why this token does NOT need branch-protection bypass:
# patching a PR's base ref is a PR-level operation that does not
# require push perms on either branch (the PR's own commits stay
# put; only the metadata changes).
#
# ============================================================
# Failure modes & operational notes
# ============================================================
#
# A — PATCH base→staging returns 422 "pull request already exists"
# (issue #1884 case):
# - Detected by string-match on response body. Workflow
# falls through to closing the now-redundant main-PR
# (Gitea REST `PATCH /pulls/{N}` with `state: closed`)
# and posts an explanation comment. Step summary surfaces.
#
# B — `AUTO_SYNC_TOKEN` rotated / wrong scope:
# - First REST call returns 401/403. Step summary surfaces.
# Re-issue token from `~/.molecule-ai/personas/` on the
# operator host and update repo Actions secret.
#
# C — PR was deleted between trigger and run:
# - REST call returns 404. Workflow exits 0 with a notice
# (the rule was already enforced or the PR is gone).
#
# D — author is not actually a bot but the filter mis-fires:
# - Filter is conservative: only triggers on
# `user.type == 'Bot'`, `login` ends with `[bot]`, or
# known bot logins (`molecule-ai[bot]`, `app/molecule-ai`).
# Human PRs slip through unaffected. If a NEW bot login
# starts shipping main-PRs, add it to the filter.
on:
pull_request_target:
types: [opened, reopened]
branches: [main]
permissions:
pull-requests: write
jobs:
retarget:
name: Retarget to staging
runs-on: ubuntu-latest
# Only fire for bot-authored PRs. Human CEO PRs (staging→main
# promotion) are intentional and pass through.
#
# Head-ref guard: never retarget a PR whose head IS `staging`
# — those are the auto-promote staging→main PRs (opened by
# `devops-engineer` since PR #78 / #195 fix). Retargeting
# head=staging onto base=staging fails with HTTP 422 "no new
# commits between base 'staging' and head 'staging'", which
# would surface as a noisy red workflow run on every
# auto-promote (caught 2026-05-03 on the GitHub-era PR #2588).
if: >-
github.event.pull_request.head.ref != 'staging'
&& (
github.event.pull_request.user.type == 'Bot'
|| endsWith(github.event.pull_request.user.login, '[bot]')
|| github.event.pull_request.user.login == 'app/molecule-ai'
|| github.event.pull_request.user.login == 'molecule-ai[bot]'
|| github.event.pull_request.user.login == 'devops-engineer'
)
steps:
- name: Retarget PR base to staging via Gitea REST
id: retarget
env:
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
GITEA_HOST: ${{ vars.GITEA_HOST || 'https://git.moleculesai.app' }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
# Issue #1884 case: when the bot opens a PR against main
# and there's already another PR on the same head branch
# targeting staging, Gitea's PATCH returns 422 with a
# body mentioning "pull request already exists for base
# branch 'staging'" (the Gitea message wording is
# slightly different from GitHub's; the substring match
# below covers both for forward/back compat).
# The retarget can't proceed — but the right response is
# to close the now-redundant main-PR, not to fail the
# workflow noisily. Detect that specific 422 and close
# instead.
run: |
set -euo pipefail
API="${GITEA_HOST}/api/v1/repos/${REPO}"
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
echo "Retargeting PR #${PR_NUMBER} (author: ${PR_AUTHOR}) from main → staging"
# Curl-status-capture pattern per `feedback_curl_status_capture_pollution`:
# http_code via -w to its own scalar, body to a tempfile, set +e/-e
# bracket so curl's non-zero-on-4xx doesn't pollute the script's exit chain.
BODY_FILE=$(mktemp)
REQ='{"base":"staging"}'
set +e
STATUS=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
-X PATCH -d "${REQ}" \
-o "${BODY_FILE}" -w "%{http_code}" \
"${API}/pulls/${PR_NUMBER}")
CURL_RC=$?
set -e
if [ "${CURL_RC}" -ne 0 ]; then
echo "::error::curl PATCH failed (rc=${CURL_RC})"
rm -f "${BODY_FILE}"
exit 1
fi
if [ "${STATUS}" = "201" ] || [ "${STATUS}" = "200" ]; then
NEW_BASE=$(jq -r '.base.ref // "?"' < "${BODY_FILE}")
rm -f "${BODY_FILE}"
if [ "${NEW_BASE}" = "staging" ]; then
echo "::notice::Retargeted PR #${PR_NUMBER} → staging"
echo "outcome=retargeted" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::error::PATCH returned ${STATUS} but base.ref is '${NEW_BASE}', not 'staging'"
exit 1
fi
# Specifically match the 422 duplicate-base/head error so
# any OTHER PATCH failure (auth, deleted PR, etc.) still
# surfaces as a real workflow failure.
BODY=$(cat "${BODY_FILE}" || true)
rm -f "${BODY_FILE}"
if [ "${STATUS}" = "422" ] && echo "${BODY}" | grep -qE "(pull request already exists for base branch 'staging'|already exists.*base.*staging)"; then
echo "::notice::PR #${PR_NUMBER}: duplicate target-staging PR exists on same head — closing this main-PR as redundant."
# Close the now-redundant main-PR via Gitea REST
# (PATCH state=closed). Post comment explaining
# rationale BEFORE close so the comment lands on the
# PR (commenting on a closed PR works on Gitea, but
# historically caused notification ordering surprises).
CLOSE_BODY_FILE=$(mktemp)
CMT_REQ=$(jq -n '{body:"[retarget-bot] Closing — another PR on the same head branch already targets `staging`. This PR is redundant. See issue #1884 for the rationale."}')
set +e
CMT_STATUS=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
-X POST -d "${CMT_REQ}" \
-o "${CLOSE_BODY_FILE}" -w "%{http_code}" \
"${API}/issues/${PR_NUMBER}/comments")
set -e
if [ "${CMT_STATUS}" != "201" ]; then
echo "::warning::dup-close comment POST returned ${CMT_STATUS}; continuing to close anyway"
cat "${CLOSE_BODY_FILE}" | head -c 300 || true
fi
rm -f "${CLOSE_BODY_FILE}"
CLOSE_REQ='{"state":"closed"}'
CLOSE_RESP=$(mktemp)
set +e
CL_STATUS=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
-X PATCH -d "${CLOSE_REQ}" \
-o "${CLOSE_RESP}" -w "%{http_code}" \
"${API}/pulls/${PR_NUMBER}")
set -e
if [ "${CL_STATUS}" = "201" ] || [ "${CL_STATUS}" = "200" ]; then
echo "::notice::Closed PR #${PR_NUMBER} as redundant"
echo "outcome=closed-as-duplicate" >> "$GITHUB_OUTPUT"
rm -f "${CLOSE_RESP}"
exit 0
fi
echo "::error::Failed to close redundant PR: HTTP ${CL_STATUS}"
cat "${CLOSE_RESP}" | head -c 300 || true
rm -f "${CLOSE_RESP}"
exit 1
fi
echo "::error::Retarget PATCH failed and was NOT a duplicate-base error: HTTP ${STATUS}"
echo "${BODY}" | head -c 500 >&2
exit 1
- name: Post explainer comment
if: steps.retarget.outputs.outcome == 'retargeted'
env:
GITEA_TOKEN: ${{ secrets.AUTO_SYNC_TOKEN }}
GITEA_HOST: ${{ vars.GITEA_HOST || 'https://git.moleculesai.app' }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
API="${GITEA_HOST}/api/v1/repos/${REPO}"
AUTH=(-H "Authorization: token ${GITEA_TOKEN}" -H "Accept: application/json")
# PR comments live on the issue endpoint in Gitea
# (PRs ARE issues — same endpoint, different sub-resources
# for diffs/files/etc.). The body uses jq to safely
# encode the multi-line markdown without shell-quote
# nightmares.
REQ=$(jq -n '{body:"[retarget-bot] This PR was opened against `main` and has been retargeted to `staging` automatically.\n\n**Why:** per [SHARED_RULES rule 8](https://git.moleculesai.app/molecule-ai/molecule-ai-org-template-molecule-dev/src/branch/main/SHARED_RULES.md), all feature work targets `staging` first; the CEO promotes `staging → main` separately.\n\n**What changed:** just the base branch — no code change. CI will re-run against `staging`. If you get merge conflicts, rebase on `staging`.\n\n**If this PR is the CEO`s staging→main promotion:** the Action skipped you (only bot-authored PRs are retargeted, head=staging is also exempted). If you see this comment on your CEO PR, that`s a bug — please tag @hongmingwang."}')
BODY_FILE=$(mktemp)
set +e
STATUS=$(curl -sS "${AUTH[@]}" -H "Content-Type: application/json" \
-X POST -d "${REQ}" \
-o "${BODY_FILE}" -w "%{http_code}" \
"${API}/issues/${PR_NUMBER}/comments")
set -e
if [ "${STATUS}" = "201" ]; then
echo "::notice::Posted explainer comment on PR #${PR_NUMBER}"
else
echo "::warning::Failed to post explainer (HTTP ${STATUS}) — retarget itself succeeded"
cat "${BODY_FILE}" | head -c 300 || true
fi
rm -f "${BODY_FILE}"

View File

@ -48,7 +48,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@v6
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:

View File

@ -22,7 +22,7 @@ development workflow, conventions, and how to get your changes merged.
```bash ```bash
# Clone the repo # Clone the repo
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git git clone https://github.com/Molecule-AI/molecule-core.git
cd molecule-core cd molecule-core
# Install git hooks # Install git hooks
@ -57,7 +57,7 @@ See `CLAUDE.md` for a full list of environment variables and their purposes.
This repo is scoped to **code** (canvas, workspace, workspace-server, related This repo is scoped to **code** (canvas, workspace, workspace-server, related
infra). Public content (blog posts, marketing copy, OG images, SEO briefs, infra). Public content (blog posts, marketing copy, OG images, SEO briefs,
DevRel demos) lives in [`Molecule-AI/docs`](https://git.moleculesai.app/molecule-ai/docs). DevRel demos) lives in [`Molecule-AI/docs`](https://github.com/Molecule-AI/docs).
The `Block forbidden paths` CI gate fails any PR that writes to `marketing/` The `Block forbidden paths` CI gate fails any PR that writes to `marketing/`
or other removed paths — open against `Molecule-AI/docs` instead. or other removed paths — open against `Molecule-AI/docs` instead.
@ -110,7 +110,7 @@ causing a render loop when any node position changed.
1. **Repo-wide:** "Automatically delete head branches" is on. Once a PR merges, the branch is deleted server-side. Any subsequent `git push` to that branch fails with `remote rejected — no such branch`. 1. **Repo-wide:** "Automatically delete head branches" is on. Once a PR merges, the branch is deleted server-side. Any subsequent `git push` to that branch fails with `remote rejected — no such branch`.
2. **CI:** the `pr-guards` workflow (calling [molecule-ci `disable-auto-merge-on-push`](https://git.moleculesai.app/molecule-ai/molecule-ci/src/branch/main/.github/workflows/disable-auto-merge-on-push.yml)) fires on every push to an open PR. If auto-merge was already enabled, it's disabled and a comment is posted. You must explicitly re-enable after verifying the new commit. 2. **CI:** the `pr-guards` workflow (calling [molecule-ci `disable-auto-merge-on-push`](https://github.com/Molecule-AI/molecule-ci/blob/main/.github/workflows/disable-auto-merge-on-push.yml)) fires on every push to an open PR. If auto-merge was already enabled, it's disabled and a comment is posted. You must explicitly re-enable after verifying the new commit.
**Workflow rules that follow from the guards:** **Workflow rules that follow from the guards:**
- Push **all** commits before running `gh pr merge --auto`. - Push **all** commits before running `gh pr merge --auto`.
@ -180,9 +180,9 @@ and run CI manually.
Code in this repo lands in molecule-core. Some related runtime artifacts Code in this repo lands in molecule-core. Some related runtime artifacts
live in their own repos: live in their own repos:
- [`Molecule-AI/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue. - [`Molecule-AI/molecule-ai-workspace-runtime`](https://github.com/Molecule-AI/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
- [`Molecule-AI/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow. - [`Molecule-AI/molecule-sdk-python`](https://github.com/Molecule-AI/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
- [`Molecule-AI/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install with `claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel`. - [`Molecule-AI/molecule-mcp-claude-channel`](https://github.com/Molecule-AI/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install with `claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel`.
When extending the **A2A surface** in molecule-core (`workspace-server/internal/handlers/a2a_proxy.go` etc.), consider whether the change has a downstream impact on the runtime SDK or the channel plugin — they're versioned independently but share the wire shape. When extending the **A2A surface** in molecule-core (`workspace-server/internal/handlers/a2a_proxy.go` etc.), consider whether the change has a downstream impact on the runtime SDK or the channel plugin — they're versioned independently but share the wire shape.

View File

@ -1,28 +0,0 @@
# Top-level Makefile — convenience wrappers around docker compose.
#
# Most molecule-core dev work happens via these shortcuts. CI doesn't
# use this Makefile; CI calls docker compose / go test directly so the
# Makefile can evolve without breaking the build.
.PHONY: help dev up down logs build test
help: ## Show this help.
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-12s\033[0m %s\n", $$1, $$2}'
dev: ## Start the full stack with air hot-reload for the platform service.
docker compose -f docker-compose.yml -f docker-compose.dev.yml up
up: ## Start the full stack in production-shape mode (no air, normal Dockerfile).
docker compose up
down: ## Stop the stack and remove containers (volumes preserved).
docker compose down
logs: ## Tail logs from all services (Ctrl-C to detach).
docker compose logs -f
build: ## Force a fresh build of the platform image (no cache).
docker compose build --no-cache platform
test: ## Run Go unit tests in workspace-server/.
cd workspace-server && go test -race ./...

View File

@ -1,7 +1,7 @@
<div align="center"> <div align="center">
<p> <p>
<img src="./docs/assets/branding/molecule-icon.svg" alt="Molecule AI" width="160" /> <img src="./docs/assets/branding/molecule-icon.png" alt="Molecule AI Icon Logo" width="160" />
</p> </p>
<p> <p>
@ -39,8 +39,8 @@
<a href="./docs/agent-runtime/workspace-runtime.md"><strong>Workspace Runtime</strong></a> <a href="./docs/agent-runtime/workspace-runtime.md"><strong>Workspace Runtime</strong></a>
</p> </p>
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https://git.moleculesai.app/molecule-ai/molecule-core) [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-monorepo)
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://git.moleculesai.app/molecule-ai/molecule-core) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-monorepo)
</div> </div>
@ -53,8 +53,8 @@ Molecule AI is the most powerful way to govern an AI agent organization in produ
It combines the parts that are usually scattered across demos, internal glue code, and framework-specific tooling into one product: It combines the parts that are usually scattered across demos, internal glue code, and framework-specific tooling into one product:
- one org-native control plane for teams, roles, hierarchy, and lifecycle - one org-native control plane for teams, roles, hierarchy, and lifecycle
- one runtime layer that lets **eight** agent runtimes — LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, **Hermes**, **Gemini CLI**, and OpenClaw run side by side behind one workspace contract - one runtime layer that lets LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw run side by side
- one memory model that keeps recall, sharing, and skill evolution aligned with organizational boundaries (Memory v2 backed by pgvector for semantic recall) - one memory model that keeps recall, sharing, and skill evolution aligned with organizational boundaries
- one operational surface for observing, pausing, restarting, inspecting, and improving live workspaces - one operational surface for observing, pausing, restarting, inspecting, and improving live workspaces
Most teams can build a workflow, a strong single agent, a coding agent, or a custom multi-agent graph. Most teams can build a workflow, a strong single agent, a coding agent, or a custom multi-agent graph.
@ -75,7 +75,7 @@ You do not wire collaboration paths by hand. Hierarchy defines the default commu
### 3. Runtime choice stops being a dead-end decision ### 3. Runtime choice stops being a dead-end decision
LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, Hermes, Gemini CLI, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime. LangGraph, DeepAgents, Claude Code, CrewAI, AutoGen, and OpenClaw can all plug into the same workspace abstraction. Teams can standardize governance without forcing every group onto one runtime.
### 4. Memory is treated like infrastructure ### 4. Memory is treated like infrastructure
@ -117,8 +117,6 @@ Molecule AI is not trying to replace the frameworks below. It is the system that
| **Claude Code** | Shipping on `main` | Real coding workflows, CLI-native continuity | Secure workspace abstraction, A2A delegation, org boundaries, shared control plane | | **Claude Code** | Shipping on `main` | Real coding workflows, CLI-native continuity | Secure workspace abstraction, A2A delegation, org boundaries, shared control plane |
| **CrewAI** | Shipping on `main` | Role-based crews | Persistent workspace identity, policy consistency, shared canvas and registry | | **CrewAI** | Shipping on `main` | Role-based crews | Persistent workspace identity, policy consistency, shared canvas and registry |
| **AutoGen** | Shipping on `main` | Assistant/tool orchestration | Standardized deployment, hierarchy-aware collaboration, shared ops plane | | **AutoGen** | Shipping on `main` | Assistant/tool orchestration | Standardized deployment, hierarchy-aware collaboration, shared ops plane |
| **Hermes 4** | Shipping on `main` | Hybrid reasoning, native tools, json_schema (NousResearch/hermes-agent) | Option B upstream hook, A2A bridge to OpenAI-compat API, multi-provider provider derivation |
| **Gemini CLI** | Shipping on `main` | Google Gemini CLI continuity | Workspace lifecycle, A2A, hierarchy-aware collaboration, shared ops plane |
| **OpenClaw** | Shipping on `main` | CLI-native runtime with its own session model | Workspace lifecycle, templates, activity logs, topology-aware collaboration | | **OpenClaw** | Shipping on `main` | CLI-native runtime with its own session model | Workspace lifecycle, templates, activity logs, topology-aware collaboration |
| **NemoClaw** | WIP on `feat/nemoclaw-t4-docker` | NVIDIA-oriented runtime path | Planned to join the same abstraction once merged; not yet part of `main` | | **NemoClaw** | WIP on `feat/nemoclaw-t4-docker` | NVIDIA-oriented runtime path | Planned to join the same abstraction once merged; not yet part of `main` |
@ -184,10 +182,9 @@ The result is not just “an agent that learns.” It is **an organization that
## What Ships In `main` ## What Ships In `main`
### Canvas (v4) ### Canvas
- Next.js 15 + React Flow + Zustand - Next.js 15 + React Flow + Zustand
- **warm-paper theme system** — light / dark / follow-system, SSR cookie + nonce'd boot script + ThemeProvider; terminal + code surfaces stay dark unconditionally
- drag-to-nest team building - drag-to-nest team building
- empty-state deployment + onboarding wizard - empty-state deployment + onboarding wizard
- template palette - template palette
@ -196,9 +193,8 @@ The result is not just “an agent that learns.” It is **an organization that
### Platform ### Platform
- Go 1.25 / Gin control plane (80+ HTTP endpoints + Gorilla WebSocket fanout) - Go/Gin control plane
- workspace CRUD and provisioning (pluggable Provisioner — Docker locally, EC2 + SSM in production) - workspace CRUD and provisioning
- **A2A response path is a typed discriminated union (RFC #2967)** — frozen dataclasses + total parser; 100% unit + adversarial fuzz coverage
- registry and heartbeats - registry and heartbeats
- browser-safe A2A proxy - browser-safe A2A proxy
- team expansion/collapse - team expansion/collapse
@ -208,10 +204,10 @@ The result is not just “an agent that learns.” It is **an organization that
### Runtime ### Runtime
- unified `workspace/` image; thin AMI in production (us-east-2) - unified `workspace/` image
- adapter-driven execution across **8 runtimes** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw) - adapter-driven execution
- Agent Card registration - Agent Card registration
- awareness-backed memory integration; **Memory v2 backed by pgvector** for semantic recall - awareness-backed memory integration
- plugin-mounted shared rules/skills - plugin-mounted shared rules/skills
- hot-reloadable local skills - hot-reloadable local skills
- coordinator-only delegation path - coordinator-only delegation path
@ -225,21 +221,6 @@ The result is not just “an agent that learns.” It is **an organization that
- runtime tiers - runtime tiers
- direct workspace inspection through terminal and files - direct workspace inspection through terminal and files
### SaaS (via [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane))
- multi-tenant on AWS EC2 + Neon (per-tenant Postgres branch) + Cloudflare Tunnels (per-tenant, no public ports)
- WorkOS AuthKit + Stripe Checkout + Customer Portal
- AWS KMS envelope encryption (DB / Redis connection strings); AWS Secrets Manager for tenant bootstrap
- `tenant_resources` audit table + 30-min boot-event-aware reconciler — every CF / AWS lifecycle event recorded, claim vs live state diffed
### Bring your own Claude Code session (via [`molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel))
- Claude Code plugin that bridges Molecule A2A traffic into a local Claude Code session via MCP
- subscribe to one or more workspaces; peer messages surface as conversation turns; replies route back through Molecule's A2A
- no tunnel, no public endpoint — the plugin self-registers each watched workspace as `delivery_mode=poll` and long-polls `/activity?since_id=…`
- multi-tenant friendly: one plugin install can watch workspaces across multiple Molecule tenants (`MOLECULE_PLATFORM_URLS` per-workspace)
- install via the standard marketplace flow: `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel``/plugin install molecule-channel@molecule-mcp-claude-channel`
## Built For Teams That Need More Than A Demo ## Built For Teams That Need More Than A Demo
Molecule AI is especially strong when you need to run: Molecule AI is especially strong when you need to run:
@ -252,30 +233,24 @@ Molecule AI is especially strong when you need to run:
## Architecture ## Architecture
```text ```text
Canvas (Next.js 15, warm-paper :3000) <--HTTP / WS--> Platform (Go 1.25 :8080) <---> Postgres + Redis Canvas (Next.js :3000) <--HTTP / WS--> Platform (Go :8080) <---> Postgres + Redis
| | | |
| +--> Provisioner: Docker (local) / EC2 + SSM (prod) | +--> Docker provisioner / bundles / templates / secrets
| +--> bundles · templates · secrets · KMS
| |
+------------------------- shows ------------------------> workspaces, teams, tasks, traces, events +-------------------- shows --------------------> workspaces, teams, tasks, traces, events
Workspace Runtime (Python ≥3.11, image with adapters) Workspace Runtime (Python image with adapters)
- 8 adapters: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw - LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / OpenClaw
- Agent Card + A2A server (typed-SSOT response path, RFC #2967) - Agent Card + A2A server
- heartbeat + activity + awareness-backed memory (Memory v2 — pgvector semantic recall) - heartbeat + activity + awareness-backed memory
- skills + plugins + hot reload - skills + plugins + hot reload
SaaS Control Plane (molecule-controlplane, private)
- per-tenant EC2 + Neon (Postgres branch) + Cloudflare Tunnel
- WorkOS · Stripe · KMS · AWS Secrets Manager
- tenant_resources audit + 30-min reconciler
``` ```
## Quick Start ## Quick Start
```bash ```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git git clone https://github.com/Molecule-AI/molecule-monorepo.git
cd molecule-core cd molecule-monorepo
cp .env.example .env cp .env.example .env
# Defaults boot the stack locally out of the box. See .env.example for # Defaults boot the stack locally out of the box. See .env.example for
@ -284,7 +259,7 @@ cp .env.example .env
./infra/scripts/setup.sh ./infra/scripts/setup.sh
# Boots Postgres (:5432), Redis (:6379), Langfuse (:3001), # Boots Postgres (:5432), Redis (:6379), Langfuse (:3001),
# and Temporal (:7233 gRPC, :8233 UI) on the shared # and Temporal (:7233 gRPC, :8233 UI) on the shared
# `molecule-core-net` Docker network. Temporal runs with # `molecule-monorepo-net` Docker network. Temporal runs with
# no auth on localhost — dev-only; production must gate it. # no auth on localhost — dev-only; production must gate it.
# #
# Also populates the template/plugin registry by cloning every repo # Also populates the template/plugin registry by cloning every repo
@ -328,11 +303,7 @@ Then open `http://localhost:3000`:
## Current Scope ## Current Scope
The current `main` branch ships the core platform, Canvas v4 (warm-paper themed), Memory v2 (pgvector semantic recall), the typed-SSOT A2A response path (RFC #2967), **eight production adapters** (Claude Code, Hermes, Gemini CLI, LangGraph, DeepAgents, CrewAI, AutoGen, OpenClaw), skill lifecycle, and operational surfaces. The current `main` branch already includes the core platform, canvas, memory model, six production adapters, skill lifecycle, and operational surfaces. Adjacent runtime work such as **NemoClaw** remains branch-level until merged, and this README keeps that distinction explicit on purpose.
The companion private repo [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) provides the SaaS surface — multi-tenant orchestration on EC2 + Neon + Cloudflare Tunnels, KMS envelope encryption, WorkOS auth, Stripe billing, and a `tenant_resources` audit table with a 30-min reconciler.
Adjacent runtime work such as **NemoClaw** remains branch-level until merged, and this README keeps that distinction explicit on purpose.
## License ## License

View File

@ -1,7 +1,7 @@
<div align="center"> <div align="center">
<p> <p>
<img src="./docs/assets/branding/molecule-icon.svg" alt="Molecule AI" width="160" /> <img src="./docs/assets/branding/molecule-icon.png" alt="Molecule AI 图案 Logo" width="160" />
</p> </p>
<p> <p>
@ -38,8 +38,8 @@
<a href="./docs/agent-runtime/workspace-runtime.md"><strong>Workspace Runtime</strong></a> <a href="./docs/agent-runtime/workspace-runtime.md"><strong>Workspace Runtime</strong></a>
</p> </p>
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https://git.moleculesai.app/molecule-ai/molecule-core) [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template?template=https://github.com/Molecule-AI/molecule-core)
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://git.moleculesai.app/molecule-ai/molecule-core) [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/Molecule-AI/molecule-core)
</div> </div>
@ -52,8 +52,8 @@ Molecule AI 是目前最强的 AI Agent 组织治理方案之一,用来把 age
它把过去分散在 demo、内部胶水代码和各类 framework 私有工具里的关键能力,收敛成一个产品: 它把过去分散在 demo、内部胶水代码和各类 framework 私有工具里的关键能力,收敛成一个产品:
- 一套组织原生 control plane管理团队、角色、层级和生命周期 - 一套组织原生 control plane管理团队、角色、层级和生命周期
- 一套 runtime abstraction**8 个** agent runtime —— LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、**Hermes**、**Gemini CLI**、OpenClaw —— 共用一套 workspace 契约 - 一套 runtime abstraction让 LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、OpenClaw 并存运行
- 一套与组织边界对齐的 memory 模型,把 recall、sharing 和 skill evolution 放进同一体系Memory v2 由 pgvector 支撑语义召回) - 一套与组织边界对齐的 memory 模型,把 recall、sharing 和 skill evolution 放进同一体系
- 一套面向线上 workspace 的运维面,统一完成观测、暂停、重启、检查和持续改进 - 一套面向线上 workspace 的运维面,统一完成观测、暂停、重启、检查和持续改进
今天很多团队能做好 workflow、单 agent、coding agent或者自定义 multi-agent graph 中的一种。 今天很多团队能做好 workflow、单 agent、coding agent或者自定义 multi-agent graph 中的一种。
@ -74,7 +74,7 @@ Molecule AI 填的就是这个空白。
### 3. Runtime 选择不再是死路 ### 3. Runtime 选择不再是死路
LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、Hermes、Gemini CLI、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。 LangGraph、DeepAgents、Claude Code、CrewAI、AutoGen、OpenClaw 都可以挂到同一个 workspace abstraction 下。团队可以统一治理方式,而不必统一到底层 runtime。
### 4. Memory 被当成基础设施来做 ### 4. Memory 被当成基础设施来做
@ -116,8 +116,6 @@ Molecule AI 并不是要替代下面这些 framework而是把它们纳入更
| **Claude Code** | `main` 已支持 | 真实编码工作流、CLI-native continuity | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane | | **Claude Code** | `main` 已支持 | 真实编码工作流、CLI-native continuity | 安全 workspace 抽象、A2A delegation、组织边界、共享 control plane |
| **CrewAI** | `main` 已支持 | 角色型 crew 模式清晰 | 持久 workspace 身份、统一策略、共享 Canvas 和 registry | | **CrewAI** | `main` 已支持 | 角色型 crew 模式清晰 | 持久 workspace 身份、统一策略、共享 Canvas 和 registry |
| **AutoGen** | `main` 已支持 | assistant/tool orchestration | 统一部署、层级协作、共享运维平面 | | **AutoGen** | `main` 已支持 | assistant/tool orchestration | 统一部署、层级协作、共享运维平面 |
| **Hermes 4** | `main` 已支持 | 混合推理、原生工具调用、json_schema 输出NousResearch/hermes-agent | Option B 上游 hook、A2A 桥接 OpenAI 兼容 API、多 provider 自动派生 |
| **Gemini CLI** | `main` 已支持 | Google Gemini CLI 持续会话 | workspace 生命周期、A2A、层级感知协作、共享运维平面 |
| **OpenClaw** | `main` 已支持 | CLI-native runtime自有 session 模型 | workspace 生命周期、templates、activity logs、拓扑感知协作 | | **OpenClaw** | `main` 已支持 | CLI-native runtime自有 session 模型 | workspace 生命周期、templates、activity logs、拓扑感知协作 |
| **NemoClaw** | `feat/nemoclaw-t4-docker` 分支 WIP | NVIDIA 方向 runtime 路线 | 计划并入同一抽象层,但当前还不是 `main` 已合并能力 | | **NemoClaw** | `feat/nemoclaw-t4-docker` 分支 WIP | NVIDIA 方向 runtime 路线 | 计划并入同一抽象层,但当前还不是 `main` 已合并能力 |
@ -183,10 +181,9 @@ Molecule AI 并不是要替代下面这些 framework而是把它们纳入更
## `main` 分支已经具备什么 ## `main` 分支已经具备什么
### Canvasv4 ### Canvas
- Next.js 15 + React Flow + Zustand - Next.js 15 + React Flow + Zustand
- **warm-paper 主题系统** —— light / dark / 跟随系统SSR cookie + nonce'd boot 脚本 + ThemeProvider终端与代码面板始终保持深色
- drag-to-nest 团队构建 - drag-to-nest 团队构建
- empty state + onboarding wizard - empty state + onboarding wizard
- template palette - template palette
@ -195,9 +192,8 @@ Molecule AI 并不是要替代下面这些 framework而是把它们纳入更
### Platform ### Platform
- Go 1.25 / Gin control plane80+ HTTP 端点 + Gorilla WebSocket fanout - Go/Gin control plane
- workspace CRUD 和 provisioning可插拔 Provisioner —— 本地 Docker、生产 EC2 + SSM - workspace CRUD 和 provisioning
- **A2A 响应路径已收敛为类型化的判别联合RFC #2967** —— 冻结 dataclass + 全量 parser100% 单元测试 + 对抗性 fuzz 覆盖
- registry 与 heartbeat - registry 与 heartbeat
- 浏览器安全的 A2A proxy - 浏览器安全的 A2A proxy
- team expansion/collapse - team expansion/collapse
@ -207,10 +203,10 @@ Molecule AI 并不是要替代下面这些 framework而是把它们纳入更
### Runtime ### Runtime
- 统一 `workspace/` 镜像;生产环境采用 thin AMIus-east-2 - 统一 `workspace/` 镜像
- adapter 驱动执行,覆盖 **8 个 runtime**Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw - adapter 驱动执行
- Agent Card 注册 - Agent Card 注册
- awareness-backed memory**Memory v2 由 pgvector 支撑**语义召回 - awareness-backed memory
- plugin 挂载共享 rules/skills - plugin 挂载共享 rules/skills
- 本地 skills 热加载 - 本地 skills 热加载
- coordinator-only delegation 路径 - coordinator-only delegation 路径
@ -224,21 +220,6 @@ Molecule AI 并不是要替代下面这些 framework而是把它们纳入更
- runtime tiers - runtime tiers
- 终端与文件层面的 workspace 直接排障 - 终端与文件层面的 workspace 直接排障
### SaaS由 [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) 提供)
- 多租户运行在 AWS EC2 + Neon每租户一个 Postgres branch+ Cloudflare Tunnels每租户一条隧道对外不开任何端口
- WorkOS AuthKit + Stripe Checkout + Customer Portal
- AWS KMS 信封加密DB / Redis 连接串AWS Secrets Manager 负责租户 bootstrap
- `tenant_resources` 审计表 + 30 分钟 boot-event-aware reconciler —— 每个 CF / AWS lifecycle 事件都有记录,每 30 分钟比对 claim 与实际状态
### 在 Claude Code 里直接接入(由 [`molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) 提供)
- 把 Molecule A2A 流量桥接到本地 Claude Code 会话的 MCP 插件
- 订阅一个或多个 workspacepeer 的消息会以 user-turn 出现,回复会经 Molecule A2A 路由出去
- 无需公网隧道、无需公开端点 —— 插件启动时自动把每个 watched workspace 注册成 `delivery_mode=poll`,长轮询 `/activity?since_id=…`
- 多租户友好:单次安装即可同时 watch 跨多个 Molecule 租户的 workspace`MOLECULE_PLATFORM_URLS` 按 workspace 配置)
- 通过标准 marketplace 流程安装:`/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
## 适合什么团队 ## 适合什么团队
Molecule AI 特别适合下面这些场景: Molecule AI 特别适合下面这些场景:
@ -251,29 +232,23 @@ Molecule AI 特别适合下面这些场景:
## 架构总览 ## 架构总览
```text ```text
Canvas (Next.js 15, warm-paper :3000) <--HTTP / WS--> Platform (Go 1.25 :8080) <---> Postgres + Redis Canvas (Next.js :3000) <--HTTP / WS--> Platform (Go :8080) <---> Postgres + Redis
| | | |
| +--> Provisioner: Docker (本地) / EC2 + SSM (生产) | +--> Docker provisioner / bundles / templates / secrets
| +--> bundles · templates · secrets · KMS
| |
+------------------------- 展示 ------------------------> workspaces, teams, tasks, traces, events +-------------------- 展示 --------------------> workspaces, teams, tasks, traces, events
Workspace Runtime (Python ≥3.11,含 adapter 集合的镜像) Workspace Runtime (Python image with adapters)
- 8 个 adapter: LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / Hermes / Gemini CLI / OpenClaw - LangGraph / DeepAgents / Claude Code / CrewAI / AutoGen / OpenClaw
- Agent Card + A2A servertyped-SSOT 响应路径RFC #2967 - Agent Card + A2A server
- heartbeat + activity + awareness-backed memoryMemory v2 —— pgvector 语义召回) - heartbeat + activity + awareness-backed memory
- skills + plugins + hot reload - skills + plugins + hot reload
SaaS Control Plane (molecule-controlplane私有)
- 每租户 EC2 + Neon (Postgres branch) + Cloudflare Tunnel
- WorkOS · Stripe · KMS · AWS Secrets Manager
- tenant_resources 审计 + 30 分钟 reconciler
``` ```
## 快速开始 ## 快速开始
```bash ```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-core.git git clone https://github.com/Molecule-AI/molecule-core.git
cd molecule-core cd molecule-core
cp .env.example .env cp .env.example .env
@ -283,7 +258,7 @@ cp .env.example .env
./infra/scripts/setup.sh ./infra/scripts/setup.sh
# 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001) # 启动 Postgres (:5432)、Redis (:6379)、Langfuse (:3001)
# 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的 # 以及 Temporal (:7233 gRPC, :8233 UI),全部挂在共享的
# `molecule-core-net` Docker 网络上。Temporal 默认无鉴权, # `molecule-monorepo-net` Docker 网络上。Temporal 默认无鉴权,
# 仅用于本地开发;生产环境必须加 mTLS / API Key。 # 仅用于本地开发;生产环境必须加 mTLS / API Key。
# #
# 同时会根据 manifest.json 拉取所有模板/插件仓库到 # 同时会根据 manifest.json 拉取所有模板/插件仓库到
@ -321,11 +296,7 @@ npm run dev
## 当前范围说明 ## 当前范围说明
当前 `main` 已经包含核心平台、Canvas v4warm-paper 主题、Memory v2pgvector 语义召回、typed-SSOT A2A 响应路径RFC #2967)、**8 个正式 adapter**Claude Code、Hermes、Gemini CLI、LangGraph、DeepAgents、CrewAI、AutoGen、OpenClaw、skill lifecycle以及主要运维面。 当前 `main` 已经包含核心平台、Canvas、memory model、6 个正式 adapter、skill lifecycle 和主要运维面。像 **NemoClaw** 这样的相邻 runtime 路线仍然属于分支级工作,只有合并后才会进入正式支持列表,这里会明确区分。
配套的私有仓库 [`molecule-controlplane`](https://git.moleculesai.app/molecule-ai/molecule-controlplane) 提供 SaaS 层 —— 多租户编排EC2 + Neon + Cloudflare Tunnels、KMS 信封加密、WorkOS 鉴权、Stripe 计费,以及 `tenant_resources` 审计表加 30 分钟 reconciler。
**NemoClaw** 这样的相邻 runtime 路线仍然属于分支级工作,只有合并后才会进入正式支持列表,这里会明确区分。
## License ## License

View File

@ -1,10 +0,0 @@
# Excluded from `docker build` context. Without this, the COPY . . step in
# canvas/Dockerfile clobbers the freshly-installed node_modules with the
# host's (potentially broken / wrong-arch) copy — the @tailwindcss/oxide
# native binary disagreed and broke `next build`.
node_modules
.next
.git
*.log
.env*
!.env.example

View File

@ -1,11 +1,7 @@
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f AS builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
# `npm ci` (not `install`) for lockfile-exact reproducibility. RUN npm install
# `--include=optional` ensures the platform-specific @tailwindcss/oxide
# native binary lands — without it, postcss fails with "Cannot read
# properties of undefined (reading 'All')" at build time.
RUN npm ci --include=optional
COPY . . COPY . .
ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080 ARG NEXT_PUBLIC_PLATFORM_URL=http://localhost:8080
ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws ARG NEXT_PUBLIC_WS_URL=ws://localhost:8080/ws
@ -15,7 +11,7 @@ ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN ENV NEXT_PUBLIC_ADMIN_TOKEN=$NEXT_PUBLIC_ADMIN_TOKEN
RUN npm run build RUN npm run build
FROM node:22-alpine@sha256:cb15fca92530d7ac113467696cf1001208dac49c3c64355fd1348c11a88ddf8f FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static

View File

@ -17,24 +17,6 @@ import { dirname, join } from "node:path";
// update one heuristic. Production is unaffected: `output: "standalone"` // update one heuristic. Production is unaffected: `output: "standalone"`
// bakes resolved env into the build, and the marker file isn't shipped. // bakes resolved env into the build, and the marker file isn't shipped.
loadMonorepoEnv(); loadMonorepoEnv();
// Boot-time matched-pair guard for ADMIN_TOKEN / NEXT_PUBLIC_ADMIN_TOKEN.
// When ADMIN_TOKEN is set on the workspace-server (server-side bearer
// gate, wsauth_middleware.go ~L245), the canvas MUST send the matching
// NEXT_PUBLIC_ADMIN_TOKEN as `Authorization: Bearer ...` on every API
// call. If only one is set, every workspace API call 401s silently —
// the canvas hydrates with empty data and the user sees a broken page
// with no console hint about the auth-config mismatch.
//
// Pre-fix the matched-pair contract was descriptive only (a comment in
// .env): future devs/agents could re-misconfigure with one of the two
// unset and silently 401. Closes the post-PR-#174 self-review gap.
//
// Warn-only (not exit) — production canvas Docker images bake these
// vars into the build at image-build time, and a missed pair there
// would still emit the warning at runtime via the standalone server's
// startup. Killing the process on misconfiguration would turn a
// recoverable auth issue into a hard crashloop.
checkAdminTokenPair();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
@ -75,43 +57,6 @@ function loadMonorepoEnv() {
); );
} }
// Boot-time matched-pair guard. Runs after .env has been loaded so the
// check sees the post-load state. The two env vars must be set or
// unset together; one-without-the-other is the silent-401 footgun.
//
// Treats empty string ("") as unset. An explicitly-empty `KEY=` in
// .env counts as set-to-empty in `process.env`, but for auth purposes
// an empty bearer token is equivalent to no token — so both
// `ADMIN_TOKEN=` and an unset ADMIN_TOKEN are equivalent relative to
// the matched-pair invariant.
//
// Returns void; side effect is the console.error warning. Kept as a
// separate function (exported) so a future test can reset env, call
// this, and assert on captured stderr.
export function checkAdminTokenPair(): void {
const serverSet = !!process.env.ADMIN_TOKEN;
const clientSet = !!process.env.NEXT_PUBLIC_ADMIN_TOKEN;
if (serverSet === clientSet) return;
// Distinct messages so the operator can tell which half is missing
// — the fix is symmetric (set the other one) but the diagnostic
// mentions which side is currently set so they don't have to grep.
if (serverSet && !clientSet) {
// eslint-disable-next-line no-console
console.error(
"[next.config] ADMIN_TOKEN is set but NEXT_PUBLIC_ADMIN_TOKEN is not — " +
"canvas will 401 against workspace-server because the bearer header " +
"is never attached. Set both to the same value, or unset both.",
);
} else {
// eslint-disable-next-line no-console
console.error(
"[next.config] NEXT_PUBLIC_ADMIN_TOKEN is set but ADMIN_TOKEN is not — " +
"workspace-server will reject the bearer because no AdminAuth gate " +
"is configured. Set both to the same value, or unset both.",
);
}
}
function findMonorepoRoot(start: string): string | null { function findMonorepoRoot(start: string): string | null {
let dir = start; let dir = start;
for (let i = 0; i < 6; i++) { for (let i = 0; i < 6; i++) {

View File

@ -354,7 +354,7 @@ function OrgCTA({ org }: { org: Org }) {
); );
} }
// provisioning / unknown — non-interactive // provisioning / unknown — non-interactive
return <span className="text-sm text-ink-mid">{org.status}</span>; return <span className="text-sm text-ink-soft">{org.status}</span>;
} }
function EmptyState({ banner }: { banner?: React.ReactNode }) { function EmptyState({ banner }: { banner?: React.ReactNode }) {
@ -420,7 +420,7 @@ function CreateOrgForm({ onCreated }: { onCreated: (slug: string) => void }) {
aria-describedby="org-slug-hint" aria-describedby="org-slug-hint"
className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink" className="mt-1 w-full rounded border border-line bg-surface-card px-3 py-2 text-sm text-ink"
/> />
<p id="org-slug-hint" className="mt-1 text-xs text-ink-mid"> <p id="org-slug-hint" className="mt-1 text-xs text-ink-soft">
Lowercase letters, numbers, and hyphens only. Cannot be changed later. Lowercase letters, numbers, and hyphens only. Cannot be changed later.
</p> </p>
</div> </div>

View File

@ -56,7 +56,7 @@ export default function Home() {
<div className="fixed inset-0 flex items-center justify-center bg-surface"> <div className="fixed inset-0 flex items-center justify-center bg-surface">
<div role="status" aria-live="polite" className="flex flex-col items-center gap-3"> <div role="status" aria-live="polite" className="flex flex-col items-center gap-3">
<Spinner size="lg" /> <Spinner size="lg" />
<span className="text-xs text-ink-mid">Loading canvas...</span> <span className="text-xs text-ink-soft">Loading canvas...</span>
</div> </div>
</div> </div>
); );
@ -119,11 +119,11 @@ function PlatformDownDiagnostic() {
Most common cause on a dev host: one of those services stopped. Most common cause on a dev host: one of those services stopped.
</p> </p>
<div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full"> <div className="bg-surface-sunken/80 border border-line/50 rounded-lg px-4 py-3 max-w-lg w-full">
<div className="text-[10px] uppercase tracking-wider text-ink-mid mb-2">Try first</div> <div className="text-[10px] uppercase tracking-wider text-ink-soft mb-2">Try first</div>
<pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14 <pre className="text-[12px] text-ink-mid font-mono whitespace-pre-wrap leading-relaxed">{`brew services start postgresql@14
brew services start redis`}</pre> brew services start redis`}</pre>
</div> </div>
<p className="text-[11px] text-ink-mid max-w-lg text-center"> <p className="text-[11px] text-ink-soft max-w-lg text-center">
If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for If both are running, check <code className="font-mono">/tmp/molecule-server.log</code> for
the underlying error. If you&apos;re on hosted SaaS, this is a platform incident try again in a moment. the underlying error. If you&apos;re on hosted SaaS, this is a platform incident try again in a moment.
</p> </p>

View File

@ -41,7 +41,7 @@ export default function PricingPage() {
<p className="mt-2 text-ink-mid"> <p className="mt-2 text-ink-mid">
We publish the{" "} We publish the{" "}
<a <a
href="https://git.moleculesai.app/molecule-ai/molecule-monorepo" href="https://github.com/Molecule-AI/molecule-monorepo"
className="text-accent underline hover:text-accent" className="text-accent underline hover:text-accent"
> >
full source on GitHub full source on GitHub
@ -55,13 +55,13 @@ export default function PricingPage() {
</a> </a>
. .
</p> </p>
<p className="mt-6 text-sm text-ink-mid"> <p className="mt-6 text-sm text-ink-soft">
Prices shown in USD. Flat-rate per org no per-seat fees on any paid tier. Prices shown in USD. Flat-rate per org no per-seat fees on any paid tier.
Enterprise / self-hosted licensing available contact us. Enterprise / self-hosted licensing available contact us.
</p> </p>
</section> </section>
<footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-mid"> <footer className="mx-auto mt-20 max-w-5xl border-t border-line px-6 py-6 text-center text-sm text-ink-soft">
<p> <p>
© {new Date().getFullYear()} Molecule AI, Inc. ·{" "} © {new Date().getFullYear()} Molecule AI, Inc. ·{" "}
<a href="/legal/terms" className="hover:text-ink-mid"> <a href="/legal/terms" className="hover:text-ink-mid">

View File

@ -127,7 +127,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<span className="text-xs text-ink-mid">Loading audit trail</span> <span className="text-xs text-ink-soft">Loading audit trail</span>
</div> </div>
); );
} }
@ -145,7 +145,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${ className={`px-2 py-1 text-[10px] rounded-md font-medium transition-all shrink-0 ${
filter === f.id filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600" ? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60" : "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
}`} }`}
> >
{f.label} {f.label}
@ -174,9 +174,9 @@ export function AuditTrailPanel({ workspaceId }: Props) {
{entries.length === 0 ? ( {entries.length === 0 ? (
/* Empty state */ /* Empty state */
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"></span> <span className="text-4xl text-ink-soft" aria-hidden="true"></span>
<p className="text-sm font-medium text-ink-mid">No audit events yet</p> <p className="text-sm font-medium text-ink-mid">No audit events yet</p>
<p className="text-[11px] text-ink-mid max-w-[200px] leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
Delegation, decision, gate, and human-in-the-loop events will appear here. Delegation, decision, gate, and human-in-the-loop events will appear here.
</p> </p>
</div> </div>
@ -203,7 +203,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
)} )}
{/* Entry count footer */} {/* Entry count footer */}
<p className="mt-3 text-center text-[9px] text-ink-mid"> <p className="mt-3 text-center text-[9px] text-ink-soft">
{entries.length} event{entries.length !== 1 ? "s" : ""} loaded {entries.length} event{entries.length !== 1 ? "s" : ""} loaded
{cursor ? " · more available" : " · all loaded"} {cursor ? " · more available" : " · all loaded"}
</p> </p>
@ -265,7 +265,7 @@ export function AuditEntryRow({ entry, now }: AuditEntryRowProps) {
)} )}
{/* Relative timestamp */} {/* Relative timestamp */}
<span className="shrink-0 text-[9px] text-ink-mid"> <span className="shrink-0 text-[9px] text-ink-soft">
{formatAuditRelativeTime(entry.created_at, now)} {formatAuditRelativeTime(entry.created_at, now)}
</span> </span>
</div> </div>

View File

@ -125,7 +125,7 @@ export function BundleDropZone() {
<div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center"> <div className="bg-surface-sunken/95 border border-accent/50 rounded-2xl px-8 py-6 shadow-2xl text-center">
<div className="text-3xl mb-2" aria-hidden="true">📦</div> <div className="text-3xl mb-2" aria-hidden="true">📦</div>
<div className="text-sm font-semibold text-ink">Drop Bundle to Import</div> <div className="text-sm font-semibold text-ink">Drop Bundle to Import</div>
<div className="text-xs text-ink-mid mt-1">.bundle.json files only</div> <div className="text-xs text-ink-soft mt-1">.bundle.json files only</div>
</div> </div>
</div> </div>
)} )}

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useMemo } from "react";
import { import {
ReactFlow, ReactFlow,
ReactFlowProvider, ReactFlowProvider,
@ -187,23 +187,6 @@ function CanvasInner() {
// Pan-to-node / zoom-to-team CustomEvent listeners + viewport save. // Pan-to-node / zoom-to-team CustomEvent listeners + viewport save.
const { onMoveEnd } = useCanvasViewport(); const { onMoveEnd } = useCanvasViewport();
// Screen-reader announcements — read liveAnnouncement from the store and
// immediately clear it so the same announcement doesn't re-fire on
// re-render. Using a ref avoids a setState loop while keeping the
// effect reactive to new announcement strings.
const liveAnnouncement = useCanvasStore((s) => s.liveAnnouncement);
const clearAnnouncement = useCanvasStore((s) => s.setLiveAnnouncement);
const prevAnnouncement = useRef("");
useEffect(() => {
if (liveAnnouncement && liveAnnouncement !== prevAnnouncement.current) {
prevAnnouncement.current = liveAnnouncement;
// Small delay so the DOM update lands before clearing, giving
// screen readers time to pick up the new text.
const timer = setTimeout(() => clearAnnouncement(""), 500);
return () => clearTimeout(timer);
}
}, [liveAnnouncement, clearAnnouncement]);
// Delete-confirmation lives in the store so the dialog survives ContextMenu // Delete-confirmation lives in the store so the dialog survives ContextMenu
// unmounting — the prior local-in-ContextMenu state raced with the menu's // unmounting — the prior local-in-ContextMenu state raced with the menu's
// outside-click handler. // outside-click handler.
@ -343,21 +326,11 @@ function CanvasInner() {
<DropTargetBadge /> <DropTargetBadge />
</ReactFlow> </ReactFlow>
{/* Screen-reader live region announces workspace count on initial load and {/* Screen-reader live region: announces workspace count on canvas load or change */}
live status updates from WebSocket events (online, offline, provisioning, etc.). <div role="status" aria-live="polite" className="sr-only">
The liveAnnouncement text is cleared after the screen reader has had time {nodes.filter((n) => !n.parentId).length === 0
to read it so the same message doesn't re-announce on re-render. */} ? "No workspaces on canvas"
<div : `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`}
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only"
>
{liveAnnouncement || (
nodes.filter((n) => !n.parentId).length === 0
? "No workspaces on canvas"
: `${nodes.filter((n) => !n.parentId).length} workspace${nodes.filter((n) => !n.parentId).length !== 1 ? "s" : ""} on canvas`
)}
</div> </div>
{nodes.length === 0 && <EmptyState />} {nodes.length === 0 && <EmptyState />}

View File

@ -226,7 +226,7 @@ export function CommunicationOverlay() {
type="button" type="button"
onClick={() => setVisible(false)} onClick={() => setVisible(false)}
aria-label="Close communications panel" aria-label="Close communications panel"
className="text-ink-mid hover:text-ink-mid text-xs" className="text-ink-soft hover:text-ink-mid text-xs"
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</button> </button>
@ -268,7 +268,7 @@ export function CommunicationOverlay() {
</div> </div>
</div> </div>
{c.summary && ( {c.summary && (
<div className="text-ink-mid truncate mt-0.5 pl-4">{c.summary}</div> <div className="text-ink-soft truncate mt-0.5 pl-4">{c.summary}</div>
)} )}
{c.durationMs && ( {c.durationMs && (
<div className="text-ink-mid pl-4">{c.durationMs}ms</div> <div className="text-ink-mid pl-4">{c.durationMs}ms</div>

View File

@ -103,7 +103,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
EC2 console output EC2 console output
</h3> </h3>
{workspaceName && ( {workspaceName && (
<div className="text-[11px] text-ink-mid mt-0.5 truncate max-w-[600px]"> <div className="text-[11px] text-ink-soft mt-0.5 truncate max-w-[600px]">
{workspaceName} {workspaceName}
</div> </div>
)} )}
@ -124,7 +124,7 @@ export function ConsoleModal({ workspaceId, workspaceName, open, onClose }: Prop
<div className="flex-1 overflow-auto bg-black/80 p-4"> <div className="flex-1 overflow-auto bg-black/80 p-4">
{loading && ( {loading && (
<div className="text-[12px] text-ink-mid" data-testid="console-loading"> <div className="text-[12px] text-ink-soft" data-testid="console-loading">
Loading console output Loading console output
</div> </div>
)} )}

View File

@ -311,7 +311,7 @@ export function ContextMenu() {
aria-hidden="true" aria-hidden="true"
className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`} className={`w-1.5 h-1.5 rounded-full ${statusDotClass(contextMenu.nodeData.status)}`}
/> />
<span className="text-[10px] text-ink-mid">{contextMenu.nodeData.status}</span> <span className="text-[10px] text-ink-soft">{contextMenu.nodeData.status}</span>
</div> </div>
</div> </div>

View File

@ -13,8 +13,7 @@ interface Props {
onClose: () => void; onClose: () => void;
} }
/** Exported for unit testing — see ConversationTraceModal.test.ts */ function extractMessageText(body: Record<string, unknown> | null): string {
export function extractMessageText(body: Record<string, unknown> | null): string {
if (!body) return ""; if (!body) return "";
try { try {
// Simple task format from MCP server: {task: "..."} // Simple task format from MCP server: {task: "..."}
@ -107,7 +106,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<Dialog.Title className="text-sm font-semibold text-ink"> <Dialog.Title className="text-sm font-semibold text-ink">
Conversation Trace Conversation Trace
</Dialog.Title> </Dialog.Title>
<p className="text-[10px] text-ink-mid mt-0.5"> <p className="text-[10px] text-ink-soft mt-0.5">
{entries.length} events across all workspaces {entries.length} events across all workspaces
</p> </p>
</div> </div>
@ -115,7 +114,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
<button <button
type="button" type="button"
aria-label="Close conversation trace" aria-label="Close conversation trace"
className="text-ink-mid hover:text-ink-mid text-lg px-2" className="text-ink-soft hover:text-ink-mid text-lg px-2"
> >
</button> </button>
@ -125,13 +124,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Timeline */} {/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4"> <div className="flex-1 overflow-y-auto px-5 py-4">
{loading && ( {loading && (
<div className="text-xs text-ink-mid text-center py-8"> <div className="text-xs text-ink-soft text-center py-8">
Loading trace from all workspaces... Loading trace from all workspaces...
</div> </div>
)} )}
{!loading && entries.length === 0 && ( {!loading && entries.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8"> <div className="text-xs text-ink-soft text-center py-8">
No activity found No activity found
</div> </div>
)} )}
@ -251,7 +250,7 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
{/* Message content — show request and/or response */} {/* Message content — show request and/or response */}
{requestText && ( {requestText && (
<div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto"> <div className="mt-1.5 bg-surface/60 border border-line/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-ink-mid uppercase mb-1"> <div className="text-[8px] text-ink-soft uppercase mb-1">
{isSend ? "Task" : "Request"} {isSend ? "Task" : "Request"}
</div> </div>
<div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed"> <div className="text-[10px] text-ink-mid whitespace-pre-wrap break-words leading-relaxed">

View File

@ -338,7 +338,7 @@ export function CreateWorkspaceButton() {
<Dialog.Title className="text-base font-semibold text-ink mb-1"> <Dialog.Title className="text-base font-semibold text-ink mb-1">
Create Workspace Create Workspace
</Dialog.Title> </Dialog.Title>
<p className="text-xs text-ink-mid mb-5"> <p className="text-xs text-ink-soft mb-5">
Add a new workspace node to the canvas Add a new workspace node to the canvas
</p> </p>
@ -376,7 +376,7 @@ export function CreateWorkspaceButton() {
/> />
<div className="text-xs"> <div className="text-xs">
<div className="text-ink font-medium">External agent (bring your own compute)</div> <div className="text-ink font-medium">External agent (bring your own compute)</div>
<div className="text-ink-mid mt-0.5"> <div className="text-ink-soft mt-0.5">
Skip the container. We&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A. Skip the container. We&apos;ll return a workspace_id + auth token + ready-to-paste snippet so an agent running on your laptop / server / CI can register via A2A.
</div> </div>
</div> </div>
@ -456,7 +456,7 @@ export function CreateWorkspaceButton() {
<p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide"> <p className="text-[11px] font-semibold text-violet-400 uppercase tracking-wide">
Hermes Provider Hermes Provider
</p> </p>
<p className="text-[11px] text-ink-mid -mt-1"> <p className="text-[11px] text-ink-soft -mt-1">
Choose the AI provider and paste your API key. The key is Choose the AI provider and paste your API key. The key is
stored as an encrypted workspace secret. stored as an encrypted workspace secret.
</p> </p>
@ -534,7 +534,7 @@ export function CreateWorkspaceButton() {
(m) => <option key={m} value={m} />, (m) => <option key={m} value={m} />,
)} )}
</datalist> </datalist>
<p className="text-[10px] text-ink-mid mt-1"> <p className="text-[10px] text-ink-soft mt-1">
Slug determines which provider hermes routes to at install time. Slug determines which provider hermes routes to at install time.
</p> </p>
</div> </div>
@ -626,7 +626,7 @@ function InputField({
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 ${mono ? "font-mono text-xs" : ""}`} 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 ${mono ? "font-mono text-xs" : ""}`}
/> />
{helper && ( {helper && (
<p className="mt-1 text-xs text-ink-mid">{helper}</p> <p className="mt-1 text-xs text-ink-soft">{helper}</p>
)} )}
</div> </div>
); );

View File

@ -129,11 +129,11 @@ export function EmptyState() {
T{t.tier} T{t.tier}
</span> </span>
</div> </div>
<p className="text-[11px] text-ink-mid line-clamp-2 leading-relaxed"> <p className="text-[11px] text-ink-soft line-clamp-2 leading-relaxed">
{t.description || "No description"} {t.description || "No description"}
</p> </p>
{t.skill_count > 0 && ( {t.skill_count > 0 && (
<p className="text-[9px] text-ink-mid mt-1.5"> <p className="text-[9px] text-ink-soft mt-1.5">
{t.skill_count} skill{t.skill_count !== 1 ? "s" : ""} {t.skill_count} skill{t.skill_count !== 1 ? "s" : ""}
{t.model ? ` · ${t.model}` : ""} {t.model ? ` · ${t.model}` : ""}
</p> </p>
@ -174,10 +174,10 @@ export function EmptyState() {
<div className="mt-5 pt-4 border-t border-line/50"> <div className="mt-5 pt-4 border-t border-line/50">
<div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid"> <div className="flex items-center justify-center gap-6 text-[10px] text-ink-mid">
<span>Drag to nest workspaces into teams</span> <span>Drag to nest workspaces into teams</span>
<span className="text-ink-mid">|</span> <span className="text-ink-soft">|</span>
<span>Right-click for actions</span> <span>Right-click for actions</span>
<span className="text-ink-mid">|</span> <span className="text-ink-soft">|</span>
<span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-mid font-mono">&#8984;K</kbd> to search</span> <span>Press <kbd className="px-1 py-0.5 bg-surface-card rounded text-ink-soft font-mono">&#8984;K</kbd> to search</span>
</div> </div>
</div> </div>
</div> </div>

View File

@ -201,7 +201,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${ className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors ${
tab === t tab === t
? "border-accent text-ink" ? "border-accent text-ink"
: "border-transparent text-ink-mid hover:text-ink-mid" : "border-transparent text-ink-soft hover:text-ink-mid"
}`} }`}
> >
{t === "claude" {t === "claude"
@ -335,7 +335,7 @@ function SnippetBlock({
return ( return (
<div> <div>
<div className="flex items-center justify-between pb-1"> <div className="flex items-center justify-between pb-1">
<span className="text-xs text-ink-mid">{label}</span> <span className="text-xs text-ink-soft">{label}</span>
<button <button
type="button" type="button"
onClick={onCopy} onClick={onCopy}
@ -366,7 +366,7 @@ function Field({
}) { }) {
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-ink-mid w-36 shrink-0">{label}</span> <span className="text-xs text-ink-soft w-36 shrink-0">{label}</span>
<code <code
className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`} className={`flex-1 text-xs bg-surface border border-line rounded px-2 py-1 text-ink break-all ${mono ? "font-mono" : ""}`}
> >

View File

@ -1,235 +0,0 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
interface ShortcutGroup {
title: string;
shortcuts: Array<{ keys: string[]; description: string }>;
}
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: "Canvas",
shortcuts: [
{
keys: ["Esc"],
description: "Close context menu, clear selection, or deselect",
},
{
keys: ["↑↓←→"],
description: "Nudge selected node 10px; hold Shift for 50px",
},
{
keys: ["Cmd", "↑↓←→"],
description: "Resize selected node (↑↓ height, ←→ width); hold Shift for fine control (2px)",
},
{
keys: ["Enter"],
description: "Descend into selected node's first child",
},
{
keys: ["Shift", "Enter"],
description: "Ascend to selected node's parent",
},
{
keys: ["Cmd", "]"],
description: "Bring selected node forward in z-order",
},
{
keys: ["Cmd", "["],
description: "Send selected node backward in z-order",
},
{
keys: ["Z"],
description: "Zoom to fit the selected team and its sub-workspaces",
},
],
},
{
title: "Navigation",
shortcuts: [
{
keys: ["⌘K"],
description: "Open workspace search",
},
{
keys: ["Palette"],
description: "Open the template palette to deploy a new workspace",
},
{
keys: ["Dbl-click"],
description: "Zoom canvas to fit a team node and all its sub-workspaces",
},
{
keys: ["Right-click"],
description: "Open the workspace context menu",
},
],
},
{
title: "Agent",
shortcuts: [
{
keys: ["Chat"],
description: "Send a message or resume a running task",
},
{
keys: ["Config"],
description: "Edit skills, model, secrets, and runtime settings",
},
{
keys: ["Audit"],
description: "View the activity ledger for the selected workspace",
},
],
},
];
interface Props {
open: boolean;
onClose: () => void;
}
export function KeyboardShortcutsDialog({ open, onClose }: Props) {
const dialogRef = useRef<HTMLDivElement>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
// Move focus into the dialog when it opens (WCAG 2.1 SC 2.4.3)
useEffect(() => {
if (!open || !mounted) return;
const raf = requestAnimationFrame(() => {
dialogRef.current?.querySelector<HTMLElement>("button")?.focus();
});
return () => cancelAnimationFrame(raf);
}, [open, mounted]);
// Keyboard: Escape closes, Tab is trapped
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusable = Array.from(
dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => !el.hasAttribute("disabled"));
if (focusable.length === 0) {
e.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey) {
if (document.activeElement === first) {
e.preventDefault();
last.focus();
}
} else {
if (document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [open, onClose]);
if (!open || !mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby="keyboard-shortcuts-title"
className="relative bg-surface border border-line rounded-xl shadow-2xl shadow-black/60 max-w-[480px] w-full mx-4 overflow-hidden max-h-[80vh] flex flex-col"
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-line shrink-0">
<h2
id="keyboard-shortcuts-title"
className="text-sm font-semibold text-ink"
>
Keyboard Shortcuts
</h2>
<button
type="button"
onClick={onClose}
aria-label="Close keyboard shortcuts"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-sunken transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
×
</button>
</div>
{/* Content */}
<div className="overflow-y-auto p-5 space-y-5">
{SHORTCUT_GROUPS.map((group) => (
<div key={group.title}>
<h3 className="text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-mid mb-2.5">
{group.title}
</h3>
<div className="space-y-2">
{group.shortcuts.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between gap-4"
>
<span className="text-[13px] text-ink-mid">
{shortcut.description}
</span>
<kbd className="flex items-center gap-0.5 shrink-0">
{shortcut.keys.map((k, j) => (
<span key={j} className="flex items-center gap-0.5">
{j > 0 && (
<span className="text-[9px] text-ink-mid mx-0.5">
+
</span>
)}
<span className="inline-flex items-center rounded-md border border-line/70 bg-surface-sunken/70 px-2 py-0.5 text-[11px] font-medium text-ink tabular-nums font-mono">
{k}
</span>
</span>
))}
</kbd>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-line bg-surface-sunken/30 shrink-0">
<p className="text-[10px] text-ink-mid text-center">
Press{" "}
<kbd className="inline-flex items-center rounded border border-line/70 bg-surface-sunken/70 px-1.5 py-0.5 text-[10px] font-medium text-ink font-mono">
Esc
</kbd>{" "}
to close
</p>
</div>
</div>
</div>,
document.body
);
}

View File

@ -97,7 +97,7 @@ export function Legend() {
// 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min). // 24×24 touch target (was ~10×16, well under WCAG 2.5.5 min).
// Negative margin keeps the visual position the same as before // Negative margin keeps the visual position the same as before
// — only the hit area + focus ring are larger. // — only the hit area + focus ring are larger.
className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-mid hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors" className="-mt-1.5 -mr-1.5 w-6 h-6 inline-flex items-center justify-center rounded text-[14px] leading-none text-ink-soft hover:text-ink hover:bg-surface-card/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 transition-colors"
> >
× ×
</button> </button>
@ -105,7 +105,7 @@ export function Legend() {
{/* Status */} {/* Status */}
<div className="mb-2"> <div className="mb-2">
<div className="text-[11px] text-ink-mid font-medium mb-1">Status</div> <div className="text-[11px] text-ink-soft font-medium mb-1">Status</div>
<div className="flex flex-wrap gap-x-3 gap-y-1"> <div className="flex flex-wrap gap-x-3 gap-y-1">
{LEGEND_STATUSES.map((s) => ( {LEGEND_STATUSES.map((s) => (
<StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} /> <StatusItem key={s} color={STATUS_CONFIG[s].dot} label={STATUS_CONFIG[s].label} />
@ -115,7 +115,7 @@ export function Legend() {
{/* Tiers */} {/* Tiers */}
<div className="mb-2"> <div className="mb-2">
<div className="text-[11px] text-ink-mid font-medium mb-1">Tier</div> <div className="text-[11px] text-ink-soft font-medium mb-1">Tier</div>
<div className="flex flex-wrap gap-x-3 gap-y-1"> <div className="flex flex-wrap gap-x-3 gap-y-1">
{LEGEND_TIERS.map(({ tier, label }) => ( {LEGEND_TIERS.map(({ tier, label }) => (
<TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} /> <TierItem key={tier} tier={tier} label={label} color={TIER_CONFIG[tier].border} />
@ -125,7 +125,7 @@ export function Legend() {
{/* Communication */} {/* Communication */}
<div> <div>
<div className="text-[11px] text-ink-mid font-medium mb-1">Communication</div> <div className="text-[11px] text-ink-soft font-medium mb-1">Communication</div>
<div className="flex flex-wrap gap-x-3 gap-y-1"> <div className="flex flex-wrap gap-x-3 gap-y-1">
<CommItem icon="↗" color="text-cyan-400" label="A2A Out" /> <CommItem icon="↗" color="text-cyan-400" label="A2A Out" />
<CommItem icon="↙" color="text-accent" label="A2A In" /> <CommItem icon="↙" color="text-accent" label="A2A In" />

View File

@ -288,7 +288,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
if (loading && entries.length === 0 && !error && !pluginUnavailable) { if (loading && entries.length === 0 && !error && !pluginUnavailable) {
return ( return (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-32">
<span className="text-xs text-ink-mid">Loading memories</span> <span className="text-xs text-ink-soft">Loading memories</span>
</div> </div>
); );
} }
@ -311,7 +311,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Namespace dropdown */} {/* Namespace dropdown */}
<div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2"> <div className="px-4 pt-3 pb-2 border-b border-line/40 shrink-0 space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label htmlFor="namespace-dropdown" className="text-[10px] text-ink-mid shrink-0"> <label htmlFor="namespace-dropdown" className="text-[10px] text-ink-soft shrink-0">
Namespace: Namespace:
</label> </label>
<select <select
@ -337,7 +337,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
height="12" height="12"
viewBox="0 0 16 16" viewBox="0 0 16 16"
fill="none" fill="none"
className="absolute left-2.5 text-ink-mid pointer-events-none shrink-0" className="absolute left-2.5 text-ink-soft pointer-events-none shrink-0"
aria-hidden="true" aria-hidden="true"
> >
<circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" /> <circle cx="7" cy="7" r="4.5" stroke="currentColor" strokeWidth="1.5" />
@ -360,7 +360,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
setDebouncedQuery(''); setDebouncedQuery('');
}} }}
aria-label="Clear search" aria-label="Clear search"
className="absolute right-2 text-ink-mid hover:text-ink transition-colors text-sm leading-none" className="absolute right-2 text-ink-soft hover:text-ink transition-colors text-sm leading-none"
> >
× ×
</button> </button>
@ -370,7 +370,7 @@ export function MemoryInspectorPanel({ workspaceId }: Props) {
{/* Toolbar */} {/* Toolbar */}
<div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0"> <div className="px-4 py-2.5 border-b border-line/40 flex items-center justify-between shrink-0">
<span className="text-[11px] text-ink-mid"> <span className="text-[11px] text-ink-soft">
{debouncedQuery {debouncedQuery
? `${entries.length} result${entries.length !== 1 ? 's' : ''}` ? `${entries.length} result${entries.length !== 1 ? 's' : ''}`
: entries.length === 1 : entries.length === 1
@ -446,11 +446,11 @@ function EmptyState({
// mirror it so the operator sees both signals. // mirror it so the operator sees both signals.
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"> <span className="text-4xl text-ink-soft" aria-hidden="true">
</span> </span>
<p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p> <p className="text-sm font-medium text-ink-mid">Memory plugin disabled</p>
<p className="text-[11px] text-ink-mid max-w-[220px] leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
See banner above for the operator-side fix. See banner above for the operator-side fix.
</p> </p>
</div> </div>
@ -459,11 +459,11 @@ function EmptyState({
if (query) { if (query) {
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"> <span className="text-4xl text-ink-soft" aria-hidden="true">
</span> </span>
<p className="text-sm font-medium text-ink-mid">No memories match your search</p> <p className="text-sm font-medium text-ink-mid">No memories match your search</p>
<p className="text-[11px] text-ink-mid max-w-[200px] leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-[200px] leading-relaxed">
Try a different query or clear the search. Try a different query or clear the search.
</p> </p>
</div> </div>
@ -471,11 +471,11 @@ function EmptyState({
} }
return ( return (
<div className="flex flex-col items-center justify-center py-16 gap-3 text-center"> <div className="flex flex-col items-center justify-center py-16 gap-3 text-center">
<span className="text-4xl text-ink-mid" aria-hidden="true"> <span className="text-4xl text-ink-soft" aria-hidden="true">
</span> </span>
<p className="text-sm font-medium text-ink-mid">No memories yet</p> <p className="text-sm font-medium text-ink-mid">No memories yet</p>
<p className="text-[11px] text-ink-mid max-w-[220px] leading-relaxed"> <p className="text-[11px] text-ink-soft max-w-[220px] leading-relaxed">
Agents commit memories via MCP tools (commit_memory, commit_summary). They Agents commit memories via MCP tools (commit_memory, commit_summary). They
appear here once written. appear here once written.
</p> </p>
@ -558,7 +558,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{/* Namespace tag */} {/* Namespace tag */}
<span <span
className="text-[9px] shrink-0 font-mono text-ink-mid truncate max-w-[100px]" className="text-[9px] shrink-0 font-mono text-ink-soft truncate max-w-[100px]"
title={entry.namespace} title={entry.namespace}
> >
{entry.namespace} {entry.namespace}
@ -598,10 +598,10 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
)} )}
<span className="text-[9px] text-ink-mid shrink-0"> <span className="text-[9px] text-ink-soft shrink-0">
{formatRelativeTime(entry.created_at)} {formatRelativeTime(entry.created_at)}
</span> </span>
<span className="text-[9px] text-ink-mid shrink-0" aria-hidden="true"> <span className="text-[9px] text-ink-soft shrink-0" aria-hidden="true">
{expanded ? '▼' : '▶'} {expanded ? '▼' : '▶'}
</span> </span>
</button> </button>
@ -618,7 +618,7 @@ function MemoryEntryRow({ entry, onDelete }: MemoryEntryRowProps) {
{entry.content} {entry.content}
</pre> </pre>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-[9px] text-ink-mid"> <span className="text-[9px] text-ink-soft">
Created: {new Date(entry.created_at).toLocaleString()} Created: {new Date(entry.created_at).toLocaleString()}
{entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`} {entry.expires_at && ` · Expires: ${new Date(entry.expires_at).toLocaleString()}`}
</span> </span>

View File

@ -421,7 +421,7 @@ function ProviderPickerModal({
<div className="text-[11px] text-ink-mid font-medium"> <div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)} {getKeyLabel(entry.key)}
</div> </div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div> <div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
</div> </div>
{entry.saved && ( {entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1"> <span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
@ -675,7 +675,7 @@ function AllKeysModal({
<div className="text-[11px] text-ink-mid font-medium"> <div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)} {getKeyLabel(entry.key)}
</div> </div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div> <div className="text-[9px] font-mono text-ink-soft">{entry.key}</div>
</div> </div>
{entry.saved && ( {entry.saved && (
<span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1"> <span className="text-[9px] text-good bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">

View File

@ -247,7 +247,7 @@ export function OrgImportPreflightModal({
<h2 id="org-preflight-title" className="text-sm font-semibold text-ink"> <h2 id="org-preflight-title" className="text-sm font-semibold text-ink">
Deploy {orgName} Deploy {orgName}
</h2> </h2>
<p className="mt-0.5 text-[11px] text-ink-mid"> <p className="mt-0.5 text-[11px] text-ink-soft">
{workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}. {workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}.
Review the credentials needed before import. Review the credentials needed before import.
</p> </p>
@ -400,7 +400,7 @@ function StrictEnvRow({
<li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5"> <li className="flex items-center gap-2 rounded bg-surface-sunken/70 border border-line px-2 py-1.5">
<code <code
className={`text-[11px] font-mono flex-1 ${ className={`text-[11px] font-mono flex-1 ${
configured ? "text-ink-mid line-through" : "text-ink" configured ? "text-ink-soft line-through" : "text-ink"
}`} }`}
> >
{envKey} {envKey}
@ -492,7 +492,7 @@ function AnyOfEnvGroup({
> >
<code <code
className={`text-[11px] font-mono flex-1 ${ className={`text-[11px] font-mono flex-1 ${
isConfigured ? "text-ink-mid line-through" : "text-ink" isConfigured ? "text-ink-soft line-through" : "text-ink"
}`} }`}
> >
{m} {m}

View File

@ -356,7 +356,7 @@ export function ProviderModelSelector({
<div> <div>
<label <label
htmlFor={providerSelectId} htmlFor={providerSelectId}
className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold mb-1.5 block" className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
> >
Provider <span aria-hidden="true" className="text-bad">*</span> Provider <span aria-hidden="true" className="text-bad">*</span>
<span className="sr-only"> (required)</span> <span className="sr-only"> (required)</span>
@ -382,13 +382,13 @@ export function ProviderModelSelector({
{selected?.tooltip && ( {selected?.tooltip && (
<p <p
id={`${providerSelectId}-help`} id={`${providerSelectId}-help`}
className="text-[9px] text-ink-mid mt-1 leading-relaxed" className="text-[9px] text-ink-soft mt-1 leading-relaxed"
> >
{selected.tooltip} {selected.tooltip}
</p> </p>
)} )}
{selected && selected.envVars.length > 0 && ( {selected && selected.envVars.length > 0 && (
<p className="text-[9px] text-ink-mid mt-0.5 font-mono"> <p className="text-[9px] text-ink-soft mt-0.5 font-mono">
requires: {selected.envVars.join(", ")} requires: {selected.envVars.join(", ")}
</p> </p>
)} )}
@ -397,7 +397,7 @@ export function ProviderModelSelector({
<div> <div>
<label <label
htmlFor={modelSelectId} htmlFor={modelSelectId}
className="text-[10px] uppercase tracking-wide text-ink-mid font-semibold mb-1.5 block" className="text-[10px] uppercase tracking-wide text-ink-soft font-semibold mb-1.5 block"
> >
Model <span aria-hidden="true" className="text-bad">*</span> Model <span aria-hidden="true" className="text-bad">*</span>
<span className="sr-only"> (required)</span> <span className="sr-only"> (required)</span>
@ -422,7 +422,7 @@ export function ProviderModelSelector({
data-testid="model-input" data-testid="model-input"
className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50" className="w-full bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors disabled:opacity-50"
/> />
<p className="text-[9px] text-ink-mid mt-1 leading-relaxed"> <p className="text-[9px] text-ink-soft mt-1 leading-relaxed">
{selected?.wildcard {selected?.wildcard
? wildcardHelpText(selected) ? wildcardHelpText(selected)
: "Free-text model id. Make sure the provider can resolve it."} : "Free-text model id. Make sure the provider can resolve it."}

View File

@ -157,7 +157,7 @@ export function PurchaseSuccessModal() {
</div> </div>
<div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50"> <div className="flex items-center justify-between gap-3 px-6 py-3 border-t border-line bg-surface/50">
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-mid"> <span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-ink-soft">
auto-dismiss · {AUTO_DISMISS_MS / 1000}s auto-dismiss · {AUTO_DISMISS_MS / 1000}s
</span> </span>
<button <button

View File

@ -104,7 +104,7 @@ export function SearchDialog() {
> >
{/* Search input */} {/* Search input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-line/40"> <div className="flex items-center gap-3 px-4 py-3 border-b border-line/40">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-mid" aria-hidden="true"> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" className="shrink-0 text-ink-soft" aria-hidden="true">
<circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" /> <circle cx="7" cy="7" r="5.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> <path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg> </svg>
@ -156,7 +156,7 @@ export function SearchDialog() {
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-sm text-ink truncate">{node.data.name}</div> <div className="text-sm text-ink truncate">{node.data.name}</div>
{node.data.role && ( {node.data.role && (
<div className="text-[10px] text-ink-mid truncate">{node.data.role}</div> <div className="text-[10px] text-ink-soft truncate">{node.data.role}</div>
)} )}
</div> </div>
<span <span

View File

@ -165,12 +165,12 @@ export function SidePanel() {
</h2> </h2>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
{node.data.role && ( {node.data.role && (
<span className="text-[10px] text-ink-mid truncate"> <span className="text-[10px] text-ink-soft truncate">
{node.data.role} {node.data.role}
</span> </span>
)} )}
<span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${ <span className={`text-[9px] px-1.5 py-0.5 rounded-md font-mono ${
isOnline ? "text-good bg-emerald-950/30" : "text-ink-mid bg-surface-card/50" isOnline ? "text-good bg-emerald-950/30" : "text-ink-soft bg-surface-card/50"
}`}> }`}>
T{node.data.tier} T{node.data.tier}
</span> </span>
@ -181,7 +181,7 @@ export function SidePanel() {
type="button" type="button"
onClick={() => selectNode(null)} onClick={() => selectNode(null)}
aria-label="Close workspace panel" aria-label="Close workspace panel"
className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-mid hover:text-ink hover:bg-surface-card/60 transition-colors" className="w-7 h-7 flex items-center justify-center rounded-lg text-ink-soft hover:text-ink hover:bg-surface-card/60 transition-colors"
> >
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" /> <path d="M1 1l10 10M11 1L1 11" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
@ -296,7 +296,7 @@ export function SidePanel() {
{/* Footer — workspace ID */} {/* Footer — workspace ID */}
<div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20"> <div className="px-5 py-2 border-t border-line/40 bg-surface-sunken/20">
<span className="text-[9px] font-mono text-ink-mid select-all"> <span className="text-[9px] font-mono text-ink-soft select-all">
{selectedNodeId} {selectedNodeId}
</span> </span>
</div> </div>

View File

@ -236,7 +236,7 @@ export function OrgTemplatesSection() {
onClick={() => setExpanded((v) => !v)} onClick={() => setExpanded((v) => !v)}
aria-expanded={expanded} aria-expanded={expanded}
aria-controls="org-templates-body" aria-controls="org-templates-body"
className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-mid hover:text-ink-mid font-semibold transition-colors" className="flex items-center gap-1.5 text-[10px] uppercase tracking-wide text-ink-soft hover:text-ink-mid font-semibold transition-colors"
> >
<span <span
aria-hidden="true" aria-hidden="true"
@ -246,7 +246,7 @@ export function OrgTemplatesSection() {
</span> </span>
Org Templates Org Templates
{orgs.length > 0 && ( {orgs.length > 0 && (
<span className="text-ink-mid normal-case tracking-normal"> <span className="text-ink-soft normal-case tracking-normal">
({orgs.length}) ({orgs.length})
</span> </span>
)} )}
@ -255,7 +255,7 @@ export function OrgTemplatesSection() {
type="button" type="button"
onClick={loadOrgs} onClick={loadOrgs}
aria-label="Refresh org templates" aria-label="Refresh org templates"
className="text-[10px] text-ink-mid hover:text-ink-mid" className="text-[10px] text-ink-soft hover:text-ink-mid"
> >
</button> </button>
@ -264,14 +264,14 @@ export function OrgTemplatesSection() {
{expanded && ( {expanded && (
<div id="org-templates-body" className="space-y-2"> <div id="org-templates-body" className="space-y-2">
{loading && ( {loading && (
<div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-mid"> <div role="status" aria-live="polite" className="flex items-center gap-1.5 text-[10px] text-ink-soft">
<Spinner size="sm" /> <Spinner size="sm" />
Loading Loading
</div> </div>
)} )}
{!loading && orgs.length === 0 && ( {!loading && orgs.length === 0 && (
<div className="text-[10px] text-ink-mid"> <div className="text-[10px] text-ink-soft">
No org templates in <code>org-templates/</code> No org templates in <code>org-templates/</code>
</div> </div>
)} )}
@ -298,7 +298,7 @@ export function OrgTemplatesSection() {
</span> </span>
</div> </div>
{o.description && ( {o.description && (
<p className="text-[10px] text-ink-mid mb-2.5 line-clamp-2 leading-relaxed"> <p className="text-[10px] text-ink-soft mb-2.5 line-clamp-2 leading-relaxed">
{o.description} {o.description}
</p> </p>
)} )}
@ -499,7 +499,7 @@ export function TemplatePalette() {
<div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40"> <div className="fixed top-0 left-0 h-full w-[280px] bg-surface-sunken/95 backdrop-blur-md border-r border-line/60 z-30 flex flex-col shadow-2xl shadow-black/40">
<div className="px-4 pt-14 pb-3 border-b border-line/60"> <div className="px-4 pt-14 pb-3 border-b border-line/60">
<h2 className="text-sm font-semibold text-ink">Templates</h2> <h2 className="text-sm font-semibold text-ink">Templates</h2>
<p className="text-[10px] text-ink-mid mt-0.5">Click to deploy a workspace</p> <p className="text-[10px] text-ink-soft mt-0.5">Click to deploy a workspace</p>
</div> </div>
<div className="flex-1 overflow-y-auto p-3 space-y-2"> <div className="flex-1 overflow-y-auto p-3 space-y-2">
@ -509,14 +509,14 @@ export function TemplatePalette() {
<OrgTemplatesSection /> <OrgTemplatesSection />
{loading && ( {loading && (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-mid text-center py-8"> <div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-soft text-center py-8">
<Spinner /> <Spinner />
Loading Loading
</div> </div>
)} )}
{!loading && templates.length === 0 && ( {!loading && templates.length === 0 && (
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8"> <div role="status" aria-live="polite" className="text-xs text-ink-soft text-center py-8">
No templates found in<br />workspace-configs-templates/ No templates found in<br />workspace-configs-templates/
</div> </div>
)} )}
@ -549,7 +549,7 @@ export function TemplatePalette() {
</div> </div>
{t.description && ( {t.description && (
<p className="text-[10px] text-ink-mid mb-2 line-clamp-2 leading-relaxed"> <p className="text-[10px] text-ink-soft mb-2 line-clamp-2 leading-relaxed">
{t.description} {t.description}
</p> </p>
)} )}
@ -562,7 +562,7 @@ export function TemplatePalette() {
</span> </span>
))} ))}
{t.skills.length > 3 && ( {t.skills.length > 3 && (
<span className="text-[8px] text-ink-mid">+{t.skills.length - 3}</span> <span className="text-[8px] text-ink-soft">+{t.skills.length - 3}</span>
)} )}
</div> </div>
)} )}
@ -580,7 +580,7 @@ export function TemplatePalette() {
<button <button
type="button" type="button"
onClick={loadTemplates} onClick={loadTemplates}
className="text-[10px] text-ink-mid hover:text-ink-mid transition-colors block" className="text-[10px] text-ink-soft hover:text-ink-mid transition-colors block"
> >
Refresh templates Refresh templates
</button> </button>

View File

@ -124,7 +124,7 @@ export function TermsGate({ children }: { children: React.ReactNode }) {
</a> </a>
. Click agree to continue. . Click agree to continue.
</p> </p>
<p className="mt-3 text-xs text-ink-mid"> <p className="mt-3 text-xs text-ink-soft">
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States). By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
</p> </p>
</div> </div>

View File

@ -57,7 +57,7 @@ export function ThemeToggle({ className = "" }: { className?: string }) {
"flex h-6 w-6 items-center justify-center rounded transition-colors " + "flex h-6 w-6 items-center justify-center rounded transition-colors " +
(active (active
? "bg-surface-elevated text-ink shadow-sm" ? "bg-surface-elevated text-ink shadow-sm"
: "text-ink-mid hover:text-ink-mid") : "text-ink-soft hover:text-ink-mid")
} }
> >
<svg <svg

View File

@ -9,7 +9,6 @@ import { ConfirmDialog } from "@/components/ConfirmDialog";
import { showToast } from "@/components/Toaster"; import { showToast } from "@/components/Toaster";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { statusDotClass } from "@/lib/design-tokens"; import { statusDotClass } from "@/lib/design-tokens";
import { KeyboardShortcutsDialog } from "@/components/KeyboardShortcutsDialog";
export function Toolbar() { export function Toolbar() {
const nodes = useCanvasStore((s) => s.nodes); const nodes = useCanvasStore((s) => s.nodes);
@ -34,7 +33,6 @@ export function Toolbar() {
const [restartingAll, setRestartingAll] = useState(false); const [restartingAll, setRestartingAll] = useState(false);
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false); const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
const helpRef = useRef<HTMLDivElement>(null); const helpRef = useRef<HTMLDivElement>(null);
// Suppress toast on the very first connect at page load; only fire on reconnects. // Suppress toast on the very first connect at page load; only fire on reconnects.
@ -129,29 +127,6 @@ export function Toolbar() {
}; };
}, []); }, []);
// Global ? shortcut opens the shortcuts dialog (mirrors the help button).
// Skip when the user is typing in an input so ? in a text field doesn't
// steal focus. Also skip when a modal/dialog is already open.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "?") return;
const tag = (e.target as HTMLElement).tagName;
const inInput =
tag === "INPUT" ||
tag === "TEXTAREA" ||
tag === "SELECT" ||
(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).
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
e.preventDefault();
setShortcutsOpen(true);
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return ( return (
<div <div
className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200" className="fixed top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-3 bg-surface-sunken/80 backdrop-blur-md border border-line/60 rounded-xl px-4 py-2 shadow-xl shadow-black/20 transition-[margin-left] duration-200"
@ -317,7 +292,7 @@ export function Toolbar() {
onClick={() => setHelpOpen((open) => !open)} onClick={() => setHelpOpen((open) => !open)}
className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40" className="flex items-center justify-center w-7 h-7 bg-surface-card hover:bg-surface-card/70 border border-line rounded-lg transition-colors text-ink-mid hover:text-ink focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
aria-expanded={helpOpen} aria-expanded={helpOpen}
aria-label="Open shortcuts and tips" aria-label="Open quick help"
title="Help — shortcuts & quick start" title="Help — shortcuts & quick start"
> >
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true"> <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
@ -327,44 +302,25 @@ export function Toolbar() {
</button> </button>
{helpOpen && ( {helpOpen && (
<div <div className="absolute right-0 top-full mt-2 w-72 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md">
role="dialog" <div className="mb-2 flex items-center justify-between">
aria-label="Shortcuts and tips" <span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Quick start</span>
aria-modal="false"
className="absolute right-0 top-full mt-2 w-80 rounded-xl border border-line/60 bg-surface/95 p-3 shadow-2xl shadow-black/50 backdrop-blur-md z-50"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-[10px] font-semibold uppercase tracking-[0.24em] text-ink-mid">Shortcuts & tips</span>
<button <button
type="button" type="button"
onClick={() => setHelpOpen(false)} onClick={() => setHelpOpen(false)}
aria-label="Close help dialog"
className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline" className="text-[10px] text-ink-mid hover:text-ink transition-colors focus:outline-none focus-visible:underline"
> >
Close Close
</button> </button>
</div> </div>
<div className="space-y-1.5"> <div className="space-y-2">
<HelpRow shortcut="⌘K" text="Search workspaces and jump straight into Details or Chat." /> <HelpRow shortcut="⌘K" text="Search workspaces and jump straight into Details or Chat." />
<HelpRow shortcut="Esc" text="Clear selection, close menus, dismiss dialogs." />
<HelpRow shortcut="Enter" text="Zoom into selected team and select its first child node." />
<HelpRow shortcut="Shift+Enter" text="Select the parent of the selected node." />
<HelpRow shortcut="⌘]" text="Bring selected node forward in the z-order." />
<HelpRow shortcut="⌘[" text="Send selected node backward in the z-order." />
<HelpRow shortcut="Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
<HelpRow shortcut="Palette" text="Open the template palette to deploy a new workspace." /> <HelpRow shortcut="Palette" text="Open the template palette to deploy a new workspace." />
<HelpRow shortcut="Right-click" text="Use node actions for duplicate, export, restart, or delete." /> <HelpRow shortcut="Right-click" text="Use node actions for duplicate, export, restart, or delete." />
<HelpRow shortcut="Dbl-click" text="On a team node: expand and zoom to show all sub-workspaces." /> <HelpRow shortcut="Chat" text="If a task is still running, the chat tab resumes that session automatically." />
<HelpRow shortcut="Shift+click" text="Multi-select: add or remove a node from the batch selection." /> <HelpRow shortcut="Config" text="Use the Config tab for skills, model, secrets, and runtime settings." />
<HelpRow shortcut="Dbl-click / Z" text="Zoom canvas to fit a team node and all its sub-workspaces." />
</div> </div>
{/* Link to the full keyboard shortcuts dialog */}
<button
type="button"
onClick={() => { setHelpOpen(false); setShortcutsOpen(true); }}
className="mt-3 w-full text-center text-[10px] text-ink-mid hover:text-accent transition-colors focus:outline-none focus-visible:underline"
>
See all shortcuts
</button>
</div> </div>
)} )}
</div> </div>
@ -384,11 +340,6 @@ export function Toolbar() {
onConfirm={restartAll} onConfirm={restartAll}
onCancel={() => setRestartConfirmOpen(false)} onCancel={() => setRestartConfirmOpen(false)}
/> />
<KeyboardShortcutsDialog
open={shortcutsOpen}
onClose={() => setShortcutsOpen(false)}
/>
</div> </div>
); );
} }

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useCallback, useMemo, type KeyboardEvent } from "react"; import { useCallback, useMemo } from "react";
import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react"; import { Handle, NodeResizer, Position, type NodeProps, type Node } from "@xyflow/react";
import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas";
import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology"; import { getConfigurationError, getConfigurationStatus } from "@/store/canvas-topology";
@ -191,23 +191,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
tabIndex={0} className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
role="button"
aria-label={`Extract ${data.name} from its parent (Enter or Space)`}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
// Keyboard accessibility for edge anchors: pressing Enter/Space on
// the top handle extracts this node from its current parent,
// moving it to the root level. Mirrors the Figma/Excalidraw
// pattern of using the connector dot as a keyboard affordance.
if (data.parentId) {
void nestNode(id, null);
}
}
}}
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-top-0.5 hover:!bg-blue-400 hover:!h-1.5 focus-visible:!bg-blue-400 focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 transition-all"
/> />
<div className="relative px-3.5 py-2.5"> <div className="relative px-3.5 py-2.5">
@ -374,23 +358,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
tabIndex={0} className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 transition-all"
role="button"
aria-label={`Nest selected workspace inside ${data.name} (Enter or Space)`}
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
e.stopPropagation();
// Keyboard accessibility for edge anchors: pressing Enter/Space on
// the bottom handle nests the currently-selected node as a child
// of this node. Requires another node to be selected first.
const selected = selectedNodeId;
if (selected && selected !== id) {
void nestNode(selected, id);
}
}
}}
className="!w-2.5 !h-1 !rounded-full !bg-surface-card/80 !border-0 !-bottom-0.5 hover:!bg-blue-400 hover:!h-1.5 focus-visible:!bg-blue-400 focus-visible:!h-1.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-400/60 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-950 transition-all"
/> />
</div> </div>
</> </>

View File

@ -55,7 +55,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
</h4> </h4>
{!loading && metrics && ( {!loading && metrics && (
<span <span
className="text-[10px] text-ink-mid font-mono" className="text-[10px] text-ink-soft font-mono"
data-testid="usage-period" data-testid="usage-period"
> >
{formatPeriod(metrics.period_start, metrics.period_end)} {formatPeriod(metrics.period_start, metrics.period_end)}
@ -131,7 +131,7 @@ function StatRow({
}) { }) {
return ( return (
<div className="flex justify-between items-center" data-testid={testId}> <div className="flex justify-between items-center" data-testid={testId}>
<span className="text-xs text-ink-mid">{label}</span> <span className="text-xs text-ink-soft">{label}</span>
<span className="text-xs text-ink-mid font-mono">{value}</span> <span className="text-xs text-ink-mid font-mono">{value}</span>
</div> </div>
); );

View File

@ -1,285 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ApprovalBanner component.
*
* Covers: renders nothing when no approvals, polls /approvals/pending,
* shows approval cards, approve/deny decisions, toast notifications.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { ApprovalBanner } from "../ApprovalBanner";
import { showToast } from "@/components/Toaster";
import { api } from "@/lib/api";
vi.mock("@/components/Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
const pendingApproval = (id = "a1", workspaceId = "ws-1"): {
id: string;
workspace_id: string;
workspace_name: string;
action: string;
reason: string | null;
status: string;
created_at: string;
} => ({
id,
workspace_id: workspaceId,
workspace_name: "Test Workspace",
action: "Run code execution",
reason: "Requires human approval due to workspace policy",
status: "pending",
created_at: "2026-05-10T10:00:00Z",
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ApprovalBanner — empty state", () => {
it("renders nothing when there are no pending approvals", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("alert")).toBeNull();
});
it("does not render any approve/deny buttons when list is empty", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("button", { name: /approve/i })).toBeNull();
expect(screen.queryByRole("button", { name: /deny/i })).toBeNull();
});
});
describe("ApprovalBanner — renders approval cards", () => {
it("renders an alert card for each pending approval", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([
pendingApproval("a1"),
pendingApproval("a2", "ws-2"),
]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const alerts = screen.getAllByRole("alert");
expect(alerts).toHaveLength(2);
});
it("displays the workspace name and action text", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Test Workspace needs approval")).toBeTruthy();
expect(screen.getByText("Run code execution")).toBeTruthy();
});
it("displays the reason when present", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText(/Requires human approval/i)).toBeTruthy();
});
it("omits the reason div when reason is null", async () => {
const approval = pendingApproval("a1");
approval.reason = null;
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByText(/Requires human approval/i)).toBeNull();
});
it("renders both Approve and Deny buttons per card", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("button", { name: /approve/i })).toBeTruthy();
expect(screen.getByRole("button", { name: /deny/i })).toBeTruthy();
});
it("has aria-live=assertive on the alert container", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const alert = screen.getByRole("alert");
expect(alert.getAttribute("aria-live")).toBe("assertive");
});
});
describe("ApprovalBanner — polling", () => {
let clearIntervalSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
clearIntervalSpy = vi.spyOn(global, "clearInterval").mockImplementation(() => {});
});
afterEach(() => {
clearIntervalSpy.mockRestore();
});
it("clears the polling interval on unmount", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
const { unmount } = render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
unmount();
expect(clearIntervalSpy).toHaveBeenCalled();
});
});
describe("ApprovalBanner — decisions", () => {
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(postSpy).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "approved", decided_by: "human" }
);
});
});
it("calls POST with decision=denied on Deny click", async () => {
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
const postSpy = vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(postSpy).toHaveBeenCalledWith(
"/workspaces/ws-1/approvals/a1/decide",
{ decision: "denied", decided_by: "human" }
);
});
});
it("removes the card from state after a successful decision", async () => {
const approval = pendingApproval("a1", "ws-1");
vi.spyOn(api, "get").mockResolvedValueOnce([approval]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// One alert initially
expect(screen.getAllByRole("alert")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(screen.queryByRole("alert")).toBeNull();
});
});
it("shows a success toast on approve", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(showToast).toHaveBeenCalledWith("Approved", "success");
});
});
it("shows an info toast on deny", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockResolvedValueOnce(undefined);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /deny/i }));
await waitFor(() => {
expect(showToast).toHaveBeenCalledWith("Denied", "info");
});
});
it("shows an error toast when POST fails", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
expect(showToast).toHaveBeenCalledWith("Failed to submit decision", "error");
});
});
it("keeps the card visible when the POST fails", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([pendingApproval("a1")]);
vi.spyOn(api, "post").mockRejectedValueOnce(new Error("Network error"));
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
fireEvent.click(screen.getByRole("button", { name: /approve/i }));
await waitFor(() => {
// Card still shown because the request failed
expect(screen.getByRole("alert")).toBeTruthy();
});
});
});
describe("ApprovalBanner — handles empty list from server", () => {
it("shows nothing when the API returns an empty array on first poll", async () => {
vi.spyOn(api, "get").mockResolvedValueOnce([]);
render(<ApprovalBanner />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("alert")).toBeNull();
});
});

View File

@ -1,317 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for BundleDropZone component.
*
* Covers: drag-over/drag-leave state, drop of valid/invalid files,
* keyboard file input, import success, import error, auto-clear timeout.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { BundleDropZone } from "../BundleDropZone";
import { api } from "@/lib/api";
vi.mock("@/lib/api", () => ({
api: {
post: vi.fn(),
},
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
vi.useRealTimers();
});
// ─── Test helper ──────────────────────────────────────────────────────────────
function makeBundle(name = "test-workspace"): File {
const content = JSON.stringify({
name,
tier: 2,
skills: [],
config: {},
});
return new File([content], "test.bundle.json", {
type: "application/json",
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("BundleDropZone — render", () => {
it("renders a hidden file input with correct accept and aria-label", () => {
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
expect(input.getAttribute("type")).toBe("file");
expect(input.getAttribute("accept")).toBe(".bundle.json");
});
it("renders the keyboard-accessible import button with aria-label", () => {
render(<BundleDropZone />);
const btn = screen.getByRole("button", { name: /import bundle/i });
expect(btn).toBeTruthy();
expect(btn.getAttribute("aria-controls")).toBe("bundle-file-input");
});
});
describe("BundleDropZone — drag state", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows the drop overlay when a file is dragged over", () => {
render(<BundleDropZone />);
const overlay = screen.getByText("Drop Bundle to Import").closest("div");
expect(overlay?.className).toContain("fixed");
// Simulate drag-over on the invisible drop zone
const zone = document.body.querySelector('[class*="fixed inset-0 z-10"]') as HTMLElement;
if (zone) {
fireEvent.dragOver(zone);
} else {
// Fallback: dispatch on the component's outer div
const container = document.body.querySelector('[class*="pointer-events-none"]') as HTMLElement;
if (container) {
fireEvent.dragOver(container);
}
}
});
it("hides the drop overlay when not dragging", () => {
render(<BundleDropZone />);
// By default (no drag), the overlay should not be visible
expect(screen.queryByText("Drop Bundle to Import")).toBeNull();
});
});
describe("BundleDropZone — keyboard file input (WCAG 2.1.1)", () => {
it("triggers the hidden file input when the import button is clicked", () => {
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const clickSpy = vi.spyOn(input, "click");
fireEvent.click(screen.getByRole("button", { name: /import bundle/i }));
expect(clickSpy).toHaveBeenCalled();
});
it("processes a selected file when the file input changes", async () => {
vi.useFakeTimers();
const postMock = vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Imported Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("My Bundle");
Object.defineProperty(input, "files", {
value: [file],
writable: false,
});
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(postMock).toHaveBeenCalledWith(
"/bundles/import",
expect.objectContaining({ name: "My Bundle" })
);
vi.useRealTimers();
});
});
describe("BundleDropZone — import success", () => {
it("shows success toast after successful import", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "My Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Success Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
// Success toast should be visible
expect(screen.getByText(/imported "my workspace" successfully/i)).toBeTruthy();
// Toast auto-clears after 4000ms
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByRole("status")).toBeNull();
vi.useRealTimers();
});
it("clears the result toast after 4000ms", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Timed Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Timed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText(/timed workspace/i)).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(4500);
});
expect(screen.queryByText(/timed workspace/i)).toBeNull();
vi.useRealTimers();
});
});
describe("BundleDropZone — import error", () => {
it("shows error toast when the API call fails", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Import failed: 500 Internal Server Error"));
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Failed Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText(/import failed: 500 internal server error/i)).toBeTruthy();
vi.useRealTimers();
});
it("shows error when file is not a .bundle.json", async () => {
vi.useFakeTimers();
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = new File(["{}"], "readme.txt", { type: "text/plain" });
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.getByText(/only .bundle.json files are accepted/i)).toBeTruthy();
// Error clears after 3000ms
await act(async () => {
vi.advanceTimersByTime(3500);
});
expect(screen.queryByText(/only .bundle.json/i)).toBeNull();
vi.useRealTimers();
});
it("clears error after 4000ms", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockRejectedValueOnce(new Error("Network error"));
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Error Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByText(/network error/i)).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(5000);
});
expect(screen.queryByText(/network error/i)).toBeNull();
vi.useRealTimers();
});
});
describe("BundleDropZone — importing state", () => {
it("shows 'Importing bundle...' status while API call is in flight", async () => {
vi.useFakeTimers();
let resolve: (v: unknown) => void;
const pending = new Promise((r) => { resolve = r; });
vi.mocked(api.post).mockReturnValueOnce(pending as unknown as ReturnType<typeof api.post>);
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file");
const file = makeBundle("Pending Workspace");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
// Advance timer to allow the state update to flush
await act(async () => {
vi.advanceTimersByTime(100);
});
expect(screen.getByText("Importing bundle...")).toBeTruthy();
expect(screen.getByRole("status")).toBeTruthy();
await act(async () => {
vi.advanceTimersByTime(500);
});
vi.useRealTimers();
});
});
describe("BundleDropZone — file input reset", () => {
it("resets the file input value after processing so the same file can be re-selected", async () => {
vi.useFakeTimers();
vi.mocked(api.post).mockResolvedValueOnce({
workspace_id: "ws-new",
name: "Reset Workspace",
status: "online",
});
render(<BundleDropZone />);
const input = screen.getByLabelText("Import bundle file") as HTMLInputElement;
const file = makeBundle("Reset Test");
Object.defineProperty(input, "files", { value: [file], writable: false });
fireEvent.change(input);
await act(async () => {
vi.advanceTimersByTime(500);
});
// The component calls e.target.value = "" after processing
expect(input.value).toBe("");
vi.useRealTimers();
});
});

View File

@ -1,376 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ContextMenu component.
*
* Covers: null guard, node header (name + status), outside-click close,
* Escape close, arrow-key navigation, conditional menu items by status,
* danger items, dividers, rAF position clamping.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ContextMenu } from "../ContextMenu";
import { useCanvasStore } from "@/store/canvas";
import { showToast } from "../Toaster";
// ─── Mock Toaster ─────────────────────────────────────────────────────────────
vi.mock("../Toaster", () => ({
showToast: vi.fn(),
}));
// ─── Mock API ────────────────────────────────────────────────────────────────
const apiPost = vi.fn().mockResolvedValue(undefined as void);
const apiPatch = vi.fn().mockResolvedValue(undefined as void);
vi.mock("@/lib/api", () => ({
api: {
post: apiPost,
patch: apiPatch,
get: vi.fn(),
},
}));
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockStoreState = {
contextMenu: null as {
x: number;
y: number;
nodeId: string;
nodeData: {
name: string;
status: string;
tier: number;
role: string;
parentId?: string | null;
collapsed?: boolean;
};
} | null,
closeContextMenu: vi.fn(),
updateNodeData: vi.fn(),
selectNode: vi.fn(),
setPanelTab: vi.fn(),
nestNode: vi.fn().mockResolvedValue(undefined as void),
setPendingDelete: vi.fn(),
setCollapsed: vi.fn(),
arrangeChildren: vi.fn(),
nodes: [] as Array<{
id: string;
data: { parentId?: string | null };
}>,
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
function openMenu(overrides?: Partial<NonNullable<typeof mockStoreState.contextMenu>>) {
mockStoreState.contextMenu = {
x: 100,
y: 200,
nodeId: "n1",
nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" },
...overrides,
};
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("ContextMenu — visibility", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("renders nothing when contextMenu is null", () => {
mockStoreState.contextMenu = null;
render(<ContextMenu />);
expect(screen.queryByRole("menu")).toBeNull();
});
it("renders the menu when contextMenu is set", () => {
openMenu();
render(<ContextMenu />);
expect(screen.getByRole("menu")).toBeTruthy();
});
it("has aria-label describing the node name", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menu").getAttribute("aria-label")).toBe("Actions for Alice");
});
it("shows the node name in the header", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
expect(screen.getByText("Bob")).toBeTruthy();
});
it("shows the node status in the header", () => {
openMenu({ nodeData: { name: "Alice", status: "failed", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByText("failed")).toBeTruthy();
});
});
describe("ContextMenu — close", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("closes when clicking outside the menu", () => {
openMenu();
render(<ContextMenu />);
fireEvent.mouseDown(document.body);
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("closes when Escape is pressed", () => {
openMenu();
render(<ContextMenu />);
fireEvent.keyDown(document.body, { key: "Escape" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("closes when Tab is pressed", () => {
openMenu();
render(<ContextMenu />);
fireEvent.keyDown(document.body, { key: "Tab" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
});
describe("ContextMenu — menu items", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("shows Chat and Terminal only for online nodes", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /chat/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /terminal/i })).toBeTruthy();
});
it("hides Chat and Terminal for offline nodes", () => {
openMenu({ nodeData: { name: "Bob", status: "offline", tier: 2, role: "analyst" } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /chat/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /terminal/i })).toBeNull();
});
it("shows Pause for online nodes (not paused)", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /pause/i })).toBeTruthy();
});
it("shows Resume for paused nodes (not Pause)", () => {
openMenu({ nodeData: { name: "Carol", status: "paused", tier: 3, role: "writer" } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /pause/i })).toBeNull();
expect(screen.getByRole("menuitem", { name: /resume/i })).toBeTruthy();
});
it("shows Extract from Team only for child nodes", () => {
openMenu({ nodeData: { name: "Child", status: "online", tier: 4, role: "", parentId: "parent1" } });
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /extract/i })).toBeTruthy();
});
it("hides Extract from Team for root nodes", () => {
openMenu({ nodeData: { name: "Root", status: "online", tier: 4, role: "", parentId: null } });
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /extract/i })).toBeNull();
});
it("shows team items only when node has children", () => {
openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "" } });
mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }];
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /arrange/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /collapse/i })).toBeTruthy();
expect(screen.getByRole("menuitem", { name: /zoom/i })).toBeTruthy();
});
it("hides team items when node has no children", () => {
openMenu({ nodeData: { name: "Leaf", status: "online", tier: 4, role: "" } });
mockStoreState.nodes = [];
render(<ContextMenu />);
expect(screen.queryByRole("menuitem", { name: /arrange/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /collapse/i })).toBeNull();
expect(screen.queryByRole("menuitem", { name: /zoom/i })).toBeNull();
});
it("shows Collapse Team when collapsed, Expand Team when expanded", () => {
openMenu({ nodeData: { name: "Parent", status: "online", tier: 4, role: "", collapsed: true } });
mockStoreState.nodes = [{ id: "child1", data: { parentId: "n1" } }];
render(<ContextMenu />);
expect(screen.getByRole("menuitem", { name: /expand/i })).toBeTruthy();
});
it("Delete item has danger styling class", () => {
openMenu();
render(<ContextMenu />);
const deleteItem = screen.getByRole("menuitem", { name: /delete/i });
expect(deleteItem.getAttribute("class")).toMatch(/text-bad|bad/);
});
it("renders role=separator for dividers", () => {
openMenu();
render(<ContextMenu />);
expect(document.body.querySelectorAll('[role="separator"]').length).toBeGreaterThan(0);
});
});
describe("ContextMenu — keyboard navigation", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("ArrowDown moves focus to next enabled menuitem", () => {
openMenu();
render(<ContextMenu />);
const menu = screen.getByRole("menu");
// First tab goes to Details (first non-disabled item)
fireEvent.keyDown(menu, { key: "ArrowDown" });
const buttons = screen.getAllByRole("menuitem");
const focusedIdx = buttons.findIndex((b) => document.activeElement === b);
expect(focusedIdx).toBeGreaterThanOrEqual(0);
});
it("ArrowUp moves focus to previous enabled menuitem", () => {
openMenu();
render(<ContextMenu />);
const menu = screen.getByRole("menu");
fireEvent.keyDown(menu, { key: "ArrowDown" });
const beforeFocused = document.activeElement;
fireEvent.keyDown(menu, { key: "ArrowUp" });
// Focus should have moved
expect(document.activeElement).toBeTruthy();
});
});
describe("ContextMenu — item actions", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.updateNodeData.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
mockStoreState.nestNode.mockClear();
mockStoreState.setPendingDelete.mockClear();
mockStoreState.setCollapsed.mockClear();
mockStoreState.arrangeChildren.mockClear();
mockStoreState.nodes = [];
apiPost.mockReset();
apiPatch.mockReset();
vi.mocked(showToast).mockClear();
});
it("Details selects node and opens details tab", () => {
openMenu();
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /details/i }));
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
});
it("Chat selects node and opens chat tab", () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /chat/i }));
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("chat");
});
it("Delete calls setPendingDelete without closing immediately", () => {
openMenu();
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /delete/i }));
expect(mockStoreState.setPendingDelete).toHaveBeenCalled();
expect(mockStoreState.closeContextMenu).toHaveBeenCalled();
});
it("Pause calls the pause API and updates node status optimistically", async () => {
openMenu({ nodeData: { name: "Alice", status: "online", tier: 4, role: "assistant" } });
apiPost.mockResolvedValue(undefined);
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /pause/i }));
await act(async () => { /* flush */ });
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/pause", {});
expect(mockStoreState.updateNodeData).toHaveBeenCalledWith("n1", { status: "paused" });
});
it("Resume calls the resume API", async () => {
openMenu({ nodeData: { name: "Alice", status: "paused", tier: 4, role: "assistant" } });
apiPost.mockResolvedValue(undefined);
render(<ContextMenu />);
fireEvent.click(screen.getByRole("menuitem", { name: /resume/i }));
await act(async () => { /* flush */ });
expect(apiPost).toHaveBeenCalledWith("/workspaces/n1/resume", {});
});
});

View File

@ -1,156 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ConversationTraceModal's extractMessageText helper.
*
* Covers: MCP simple task format, request params.message.parts extraction,
* response result.parts extraction, result.root.text extraction, plain string
* result, null input, malformed input, empty strings.
*/
import { describe, expect, it } from "vitest";
import { extractMessageText } from "../ConversationTraceModal";
describe("extractMessageText — MCP simple task format", () => {
it("extracts text from body.task field", () => {
const body = { task: "Deploy the agent to production" };
expect(extractMessageText(body)).toBe("Deploy the agent to production");
});
it("returns empty string when body is null", () => {
expect(extractMessageText(null)).toBe("");
});
it("returns empty string when body is undefined", () => {
expect(extractMessageText(undefined as unknown as null)).toBe("");
});
});
describe("extractMessageText — request params.message format", () => {
it("extracts text from params.message.parts[].text", () => {
const body = {
params: {
message: {
parts: [{ text: "Hello world" }],
},
},
};
expect(extractMessageText(body)).toBe("Hello world");
});
it("joins multiple parts with newlines", () => {
const body = {
params: {
message: {
parts: [
{ text: "First part" },
{ text: "Second part" },
{ text: "Third part" },
],
},
},
};
expect(extractMessageText(body)).toBe("First part\nSecond part\nThird part");
});
it("ignores parts without text field", () => {
const body = {
params: {
message: {
parts: [{ text: "Hello" }, { other: "field" }, { text: "World" }],
},
},
};
expect(extractMessageText(body)).toBe("Hello\nWorld");
});
it("returns empty string when params.message is absent", () => {
const body = { params: {} };
expect(extractMessageText(body)).toBe("");
});
});
describe("extractMessageText — response result format", () => {
it("extracts text from result.parts[].text", () => {
const body = {
result: {
parts: [{ text: "Agent response" }],
},
};
expect(extractMessageText(body)).toBe("Agent response");
});
it("extracts text from result.parts[].root.text", () => {
const body = {
result: {
parts: [{ root: { text: "Root response text" } }],
},
};
expect(extractMessageText(body)).toBe("Root response text");
});
it("prefers parts[].text over parts[].root.text", () => {
const body = {
result: {
parts: [
{ text: "Direct text" },
{ root: { text: "Root text" } },
],
},
};
// Both are non-empty strings, so the first one wins (filter picks the first)
// The implementation: rText from rParts[0].text = "Direct text"
expect(extractMessageText(body)).toBe("Direct text");
});
});
describe("extractMessageText — plain string result", () => {
it("returns body.result when it is a plain string", () => {
const body = { result: "Simple string response" };
expect(extractMessageText(body)).toBe("Simple string response");
});
});
describe("extractMessageText — priority order", () => {
it("prefers task format over params format", () => {
const body = {
task: "Task text",
params: { message: { parts: [{ text: "Params text" }] } },
};
// Implementation: checks task first, returns if non-empty
expect(extractMessageText(body)).toBe("Task text");
});
it("prefers params format over result format", () => {
const body = {
params: { message: { parts: [{ text: "Params text" }] } },
result: { parts: [{ text: "Result text" }] },
};
// Implementation: checks params.message.parts first (after task)
expect(extractMessageText(body)).toBe("Params text");
});
});
describe("extractMessageText — error resilience", () => {
it("returns empty string on malformed input", () => {
expect(extractMessageText({})).toBe("");
expect(extractMessageText({ params: null })).toBe("");
expect(extractMessageText({ result: null })).toBe("");
});
it("returns empty string when all fields are absent", () => {
expect(extractMessageText({ random: "field" })).toBe("");
});
it("handles missing parts array gracefully", () => {
const body = { params: { message: {} } };
expect(extractMessageText(body)).toBe("");
});
it("handles parts with undefined text gracefully", () => {
const body = {
result: {
parts: [{ text: undefined }, { text: "valid" }],
},
};
expect(extractMessageText(body)).toBe("valid");
});
});

View File

@ -1,170 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for KeyValueField component.
*
* Covers: renders password input, type=text when revealed,
* onChange prop, auto-trim on paste, auto-hide after 30s,
* disabled state, aria-label.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { KeyValueField } from "../ui/KeyValueField";
const AUTO_HIDE_MS = 30_000;
describe("KeyValueField — render", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("renders a password input by default", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("type")).toBe("password");
});
it("renders a text input when revealed=true", () => {
const { container } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
// Cannot use getByRole because type=text inputs may not be queryable as textbox in jsdom
const input = container.querySelector("input");
expect(input).toBeTruthy();
expect(input!.getAttribute("type")).toBe("password");
});
it("uses the provided aria-label", () => {
render(<KeyValueField value="" onChange={vi.fn()} aria-label="My secret field" />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("My secret field");
});
it("uses default aria-label when omitted", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("aria-label")).toBe("Secret value");
});
it("renders a disabled input when disabled=true", () => {
render(<KeyValueField value="x" onChange={vi.fn()} disabled={true} />);
expect(screen.getByRole("textbox").getAttribute("disabled")).toBe("");
});
it("renders with the provided placeholder", () => {
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("Enter API key");
});
it("disables spell-check on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("spellcheck")).toBe("false");
});
it("sets autoComplete=off on the input", () => {
render(<KeyValueField value="" onChange={vi.fn()} />);
expect(screen.getByRole("textbox").getAttribute("autocomplete")).toBe("off");
});
});
describe("KeyValueField — onChange", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("calls onChange when input changes", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims trailing whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "abc " } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("trims leading whitespace on change", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: " abc" } });
expect(onChange).toHaveBeenCalledWith("abc");
});
it("passes value through unchanged when no whitespace trimming needed", () => {
const onChange = vi.fn();
render(<KeyValueField value="" onChange={onChange} />);
fireEvent.change(screen.getByRole("textbox"), { target: { value: "no-change" } });
expect(onChange).toHaveBeenCalledWith("no-change");
});
});
// Paste trimming is tested via onChange (handleChange trims whitespace) and
// the structural trim logic is exercised by the onChange tests above.
// Full paste testing requires @testing-library/user-event which is not installed.
describe("KeyValueField — auto-hide timer", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("auto-hides after 30 seconds when revealed", async () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
// Reveal the value
const input = document.body.querySelector("input");
fireEvent.click(document.body.querySelector("button")!);
// After reveal, input type should be text (not password)
expect(input?.getAttribute("type")).not.toBe("password");
// Advance 30 seconds
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
// Value should be hidden again — the input value is managed externally
// via `value` prop, so we check the input type flipped back to password
// by verifying the button was clicked twice (setRevealed toggled)
// The component's internal revealed state should be false after timer fires.
// Since we can't read internal state, we verify the behavior by checking
// the input type (it flips back to password after auto-hide).
// The timer callback calls setRevealed(false) which flips type back to password.
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
expect(typeAfter).toBe("password");
});
it("does not fire auto-hide before 30 seconds", async () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
fireEvent.click(document.body.querySelector("button")!);
// Advance 29 seconds — should NOT have hidden yet
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS - 1000); });
const typeAfter = document.body.querySelector("input")?.getAttribute("type");
// Still revealed (type=text) after 29s
expect(typeAfter).toBe("text");
});
it("clears the timer when revealed flips back to false before timeout", () => {
const onChange = vi.fn();
render(<KeyValueField value="secret" onChange={onChange} />);
fireEvent.click(document.body.querySelector("button")!);
// Hide manually before the 30s auto-hide
fireEvent.click(document.body.querySelector("button")!);
// Advance full 30s — should not crash (timer already cleared)
act(() => { vi.advanceTimersByTime(AUTO_HIDE_MS); });
// Still hidden (we hid it manually)
expect(document.body.querySelector("input")?.getAttribute("type")).toBe("password");
});
});

View File

@ -1,90 +0,0 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
// ── Component under test — imported AFTER mocks ───────────────────────────────
import { KeyboardShortcutsDialog } from "../KeyboardShortcutsDialog";
afterEach(cleanup);
const onCloseMock = vi.fn();
beforeEach(() => {
onCloseMock.mockReset();
});
describe("KeyboardShortcutsDialog — a11y render", () => {
it("renders with role=dialog and aria-modal=true when open", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeTruthy();
});
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("has aria-labelledby pointing to the dialog title", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
const dialog = await waitFor(() => screen.getByRole("dialog"));
const labelledby = dialog.getAttribute("aria-labelledby");
expect(labelledby).toBeTruthy();
// The labelledby should reference the h2 with id="keyboard-shortcuts-title"
const title = document.getElementById(labelledby!);
expect(title?.textContent).toMatch(/keyboard shortcuts/i);
});
it("does not render when open=false", () => {
render(<KeyboardShortcutsDialog open={false} onClose={onCloseMock} />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("calls onClose when Escape is pressed", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy());
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(onCloseMock).toHaveBeenCalledTimes(1);
});
it("focuses the first focusable element (close button) when dialog opens", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
// The component uses requestAnimationFrame to move focus; wait for it to settle.
await waitFor(() => expect(screen.getByRole("dialog")).toBeTruthy());
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
const closeBtn = screen.getByRole("button", { name: /close/i });
expect(document.activeElement).toBe(closeBtn);
});
it("traps Tab focus within the dialog", async () => {
render(<KeyboardShortcutsDialog open={true} onClose={onCloseMock} />);
const dialog = await waitFor(() => screen.getByRole("dialog"));
// Collect all focusable elements inside the dialog
const focusableSelectors =
'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const focusableEls = Array.from(
dialog.querySelectorAll<HTMLElement>(focusableSelectors)
);
expect(focusableEls.length).toBeGreaterThan(0);
const onlyFocusable = focusableEls[0];
act(() => { onlyFocusable.focus(); });
// Simulate Tab keydown. The dialog's handler should call preventDefault()
// to stop focus leaving the dialog. Verify by checking the event was
// handled (focus remains on the only focusable element).
let tabWasIntercepted = false;
const tabHandler = (e: KeyboardEvent) => {
if (e.key === "Tab") tabWasIntercepted = e.defaultPrevented;
};
window.addEventListener("keydown", tabHandler);
act(() => {
fireEvent.keyDown(onlyFocusable, { key: "Tab", shiftKey: false });
});
expect(tabWasIntercepted).toBe(true);
window.removeEventListener("keydown", tabHandler);
});
});

View File

@ -1,185 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for Legend component.
*
* Covers: open/closed state, localStorage persistence, palette-offset
* positioning, status/tier/comm items rendering.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { Legend } from "../Legend";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock localStorage ────────────────────────────────────────────────────────
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: () => { store = {}; },
getStore: () => store,
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
// ─── Mock canvas store ────────────────────────────────────────────────────────
vi.mock("@/store/canvas", () => ({
useCanvasStore: vi.fn(),
}));
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("Legend — initial render (localStorage open)", () => {
it("renders the legend panel when localStorage has no saved preference", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
expect(screen.getByText("Legend")).toBeTruthy();
});
it("renders the legend panel when localStorage has open=1", () => {
localStorageMock.getItem.mockReturnValueOnce("1");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
expect(screen.getByText("Legend")).toBeTruthy();
});
it("renders the collapsed pill when localStorage has open=0", () => {
localStorageMock.getItem.mockReturnValueOnce("0");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
// Collapsed pill shows "ⓘ Legend"
expect(screen.getByText("Legend")).toBeTruthy();
// Hide button should not be in the open panel
expect(screen.queryByTitle("Hide legend")).toBeNull();
});
});
describe("Legend — open panel content", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue("1");
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
});
it("renders the Status section with status items", () => {
render(<Legend />);
expect(screen.getByText("Status")).toBeTruthy();
// All statuses from LEGEND_STATUSES
expect(screen.getByText("Online")).toBeTruthy();
expect(screen.getByText("Offline")).toBeTruthy();
expect(screen.getByText("Failed")).toBeTruthy();
});
it("renders the Tier section", () => {
render(<Legend />);
expect(screen.getByText("Tier")).toBeTruthy();
expect(screen.getByText("Sandboxed")).toBeTruthy();
expect(screen.getByText("Standard")).toBeTruthy();
expect(screen.getByText("Privileged")).toBeTruthy();
expect(screen.getByText("Full Access")).toBeTruthy();
});
it("renders the Communication section", () => {
render(<Legend />);
expect(screen.getByText("Communication")).toBeTruthy();
expect(screen.getByText("A2A Out")).toBeTruthy();
expect(screen.getByText("A2A In")).toBeTruthy();
expect(screen.getByText("Task")).toBeTruthy();
expect(screen.getByText("Error")).toBeTruthy();
});
it("renders the hide button", () => {
render(<Legend />);
expect(screen.getByTitle("Hide legend")).toBeTruthy();
});
});
describe("Legend — close and reopen", () => {
it("closes when the hide button is clicked and persists to localStorage", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
fireEvent.click(screen.getByTitle("Hide legend"));
// localStorage should be updated to "0"
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"molecule.legend.open",
"0"
);
});
it("reopens when the collapsed pill is clicked and persists to localStorage", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
// Initially open — close it
fireEvent.click(screen.getByTitle("Hide legend"));
// Collapsed pill appears
expect(screen.getByTitle("Show legend")).toBeTruthy();
// Reopen
fireEvent.click(screen.getByTitle("Show legend"));
expect(localStorageMock.setItem).toHaveBeenLastCalledWith(
"molecule.legend.open",
"1"
);
});
});
describe("Legend — palette offset positioning", () => {
it("uses left-4 when template palette is NOT open", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-4");
});
it("uses left-[296px] when template palette IS open", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: true } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const panel = screen.getByText("Legend").closest("div");
expect(panel?.className).toContain("left-[296px]");
});
});
describe("Legend — aria attributes", () => {
it("the hide button has aria-label", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
const hideBtn = screen.getByTitle("Hide legend");
expect(hideBtn.getAttribute("aria-label")).toBe("Hide legend");
});
it("the show legend pill has aria-label", () => {
vi.mocked(useCanvasStore).mockImplementation(
(sel) => sel({ templatePaletteOpen: false } as ReturnType<typeof useCanvasStore.getState>)
);
render(<Legend />);
fireEvent.click(screen.getByTitle("Hide legend"));
const pill = screen.getByTitle("Show legend");
expect(pill.getAttribute("aria-label")).toBe("Show legend");
});
});

View File

@ -1,69 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for MissingKeysModal's providerIdForModel helper.
*
* Covers: model match, no match, empty modelId, whitespace-only modelId,
* model with no required_env, models undefined, single vs multiple env vars,
* stable sort order for env var ordering.
*/
import { describe, expect, it } from "vitest";
import { providerIdForModel } from "../MissingKeysModal";
describe("providerIdForModel — match behavior", () => {
it("returns sorted-joined env vars when model is found", () => {
const models = [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
];
expect(providerIdForModel("claude-3-5-sonnet", models)).toBe("ANTHROPIC_API_KEY");
});
it("returns null when model is not found", () => {
const models = [
{ id: "claude-3-5-sonnet", name: "Claude 3.5 Sonnet", required_env: ["ANTHROPIC_API_KEY"] },
];
expect(providerIdForModel("unknown-model", models)).toBeNull();
});
it("returns null when models is undefined", () => {
expect(providerIdForModel("claude-3-5-sonnet", undefined)).toBeNull();
});
it("returns null when modelId is empty string", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel("", models)).toBeNull();
});
it("returns null when modelId is whitespace-only", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel(" ", models)).toBeNull();
});
it("trims whitespace from modelId before matching", () => {
const models = [{ id: "claude", name: "Claude", required_env: ["KEY"] }];
expect(providerIdForModel(" claude ", models)).toBe("KEY");
});
});
describe("providerIdForModel — required_env variations", () => {
it("returns null when model has no required_env", () => {
const models = [{ id: "local-model", name: "Local Model", required_env: [] }];
expect(providerIdForModel("local-model", models)).toBeNull();
});
it("returns null when model.required_env is undefined", () => {
const models = [{ id: "local-model", name: "Local Model" }] as Array<{
id: string;
name: string;
required_env?: string[];
}>;
expect(providerIdForModel("local-model", models)).toBeNull();
});
it("sorts and joins multiple required_env alphabetically", () => {
const models = [
{ id: "openrouter", name: "OpenRouter", required_env: ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"] },
];
// Expected: alphabetically sorted = ANTHROPIC_API_KEY|OPENAI_API_KEY
expect(providerIdForModel("openrouter", models)).toBe("ANTHROPIC_API_KEY|OPENAI_API_KEY");
});
});

View File

@ -1,174 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for OnboardingWizard component.
*
* Covers: renders only when not dismissed, renders 4 steps, dismiss
* button, localStorage persistence, progress bar width, step navigation,
* auto-advance from welcomeapi-key on nodes change, aria-live region.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { OnboardingWizard } from "../OnboardingWizard";
import { useCanvasStore } from "@/store/canvas";
const mockStoreState = {
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
selectedNodeId: null as string | null,
panelTab: "chat" as string,
agentMessages: {} as Record<string, unknown[]>,
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
const STORAGE_KEY = "molecule-onboarding-complete";
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string): string | null => store[key] ?? null),
setItem: vi.fn((key: string, value: string) => { store[key] = value; }),
removeItem: vi.fn((key: string) => { delete store[key]; }),
clear: () => { store = {}; },
getStore: () => store,
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
// Reset mutable store properties (mockStoreState is const, so mutate fields)
mockStoreState.nodes = [];
mockStoreState.selectedNodeId = null;
mockStoreState.panelTab = "chat";
mockStoreState.agentMessages = {};
mockStoreState.setPanelTab = vi.fn();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("OnboardingWizard — visibility", () => {
it("renders nothing when localStorage has the complete flag", () => {
localStorageMock.getItem.mockReturnValueOnce("true");
render(<OnboardingWizard />);
expect(screen.queryByRole("complementary")).toBeNull();
});
it("renders the wizard for first-time users (no localStorage flag)", () => {
localStorageMock.getItem.mockReturnValueOnce(null);
render(<OnboardingWizard />);
expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy();
});
});
describe("OnboardingWizard — steps", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("renders step 1 'Welcome to Molecule AI' on first paint", () => {
render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
expect(screen.getByText("Step 1 of 4")).toBeTruthy();
});
it("renders the 'Skip guide' button", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("button", { name: "Skip onboarding guide" })).toBeTruthy();
});
it("renders the progress bar", () => {
render(<OnboardingWizard />);
// Progress bar is inside a div
const bar = document.body.querySelector(".h-full.bg-gradient-to-r");
expect(bar).toBeTruthy();
// Step 1 should be 25% wide
expect(bar?.getAttribute("style")).toContain("25%");
});
it("advances to step 2 'Set your API key' when Next is clicked", () => {
render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Set your API key")).toBeTruthy();
expect(screen.getByText("Step 2 of 4")).toBeTruthy();
});
it("advances to step 3 'Send your first message' when Next is clicked twice", () => {
render(<OnboardingWizard />);
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("Send your first message")).toBeTruthy();
expect(screen.getByText("Step 3 of 4")).toBeTruthy();
});
it("shows 'Get Started' button on the last step", () => {
render(<OnboardingWizard />);
// Navigate to done step
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByText("You're all set!")).toBeTruthy();
expect(screen.getByRole("button", { name: "Get Started" })).toBeTruthy();
});
it("dismisses the wizard when 'Skip guide' is clicked", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("complementary")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" }));
expect(screen.queryByRole("complementary")).toBeNull();
});
it("persists the dismissed state to localStorage when dismissed", () => {
render(<OnboardingWizard />);
fireEvent.click(screen.getByRole("button", { name: "Skip onboarding guide" }));
expect(localStorageMock.setItem).toHaveBeenCalledWith(STORAGE_KEY, "true");
});
});
describe("OnboardingWizard — auto-advance", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("auto-advances from welcome to api-key when nodes appear", async () => {
const { unmount } = render(<OnboardingWizard />);
expect(screen.getByText("Welcome to Molecule AI")).toBeTruthy();
// Simulate a node being added to the store and re-render
mockStoreState.nodes = [{ id: "ws-1", data: {} }];
render(<OnboardingWizard />);
await waitFor(() => {
expect(screen.queryByText("Welcome to Molecule AI")).toBeNull();
});
expect(screen.getByText("Set your API key")).toBeTruthy();
unmount();
});
});
describe("OnboardingWizard — accessibility", () => {
beforeEach(() => {
localStorageMock.getItem.mockReturnValue(null);
});
it("has aria-live='polite' region for step announcements", () => {
render(<OnboardingWizard />);
const liveRegion = document.body.querySelector('[aria-live="polite"]');
expect(liveRegion).toBeTruthy();
expect(liveRegion?.textContent).toMatch(/onboarding step 1/i);
});
it("has role=complementary with aria-label", () => {
render(<OnboardingWizard />);
expect(screen.getByRole("complementary", { name: "Onboarding guide" })).toBeTruthy();
});
});

View File

@ -1,255 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for PurchaseSuccessModal component.
*
* Covers: no render when no URL params, renders with ?purchase_success=1,
* portal rendering, item name from &item=, auto-dismiss after 5s,
* manual dismiss, backdrop click close, Escape key close, URL stripping,
* focus management.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function pushUrl(url: string) {
window.history.pushState({}, "", url);
}
function replaceUrl(url: string) {
window.history.replaceState({}, "", url);
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("PurchaseSuccessModal — render conditions", () => {
beforeEach(() => {
replaceUrl("http://localhost/");
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("renders nothing when URL has no purchase_success param", () => {
replaceUrl("http://localhost/");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders nothing on a plain URL", () => {
replaceUrl("http://localhost/dashboard?foo=bar");
render(<PurchaseSuccessModal />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when ?purchase_success=1 is present", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
// useEffect fires after mount
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders the dialog when ?purchase_success=true is present", async () => {
replaceUrl("http://localhost/?purchase_success=true");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.queryByRole("dialog")).toBeTruthy();
});
it("renders a portal attached to document.body", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = document.body.querySelector('[role="dialog"]');
expect(dialog).toBeTruthy();
});
it("shows the item name when &item= is present", async () => {
replaceUrl("http://localhost/?purchase_success=1&item=MyAgent");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("MyAgent")).toBeTruthy();
expect(screen.getByText("Purchase successful")).toBeTruthy();
});
it("shows 'Your new agent' when no item param is present", async () => {
replaceUrl("http://localhost/?purchase_success=1");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Your new agent")).toBeTruthy();
});
it("decodes URI-encoded item names", async () => {
replaceUrl("http://localhost/?purchase_success=1&item=Claude%20Code%20Agent");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByText("Claude Code Agent")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — dismiss", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("closes the dialog when the close button is clicked", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes the dialog when the backdrop is clicked", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Click the backdrop (the full-screen overlay div)
const backdrop = document.body.querySelector('[aria-hidden="true"]');
if (backdrop) fireEvent.click(backdrop);
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes on Escape key", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.keyDown(window, { key: "Escape" });
await act(async () => {
vi.advanceTimersByTime(10);
});
expect(screen.queryByRole("dialog")).toBeNull();
});
it("auto-dismisses after 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
// Advance 5 seconds
act(() => { vi.advanceTimersByTime(5000); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("does not auto-dismiss before 5 seconds", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(screen.getByRole("dialog")).toBeTruthy();
act(() => { vi.advanceTimersByTime(4900); });
await act(async () => { /* flush */ });
expect(screen.queryByRole("dialog")).toBeTruthy();
});
});
describe("PurchaseSuccessModal — URL stripping", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("strips purchase_success and item params from the URL on mount", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const url = new URL(window.location.href);
expect(url.searchParams.get("purchase_success")).toBeNull();
expect(url.searchParams.get("item")).toBeNull();
});
it("uses replaceState (not pushState) so back-button does not re-trigger", async () => {
const replaceSpy = vi.spyOn(window.history, "replaceState");
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
expect(replaceSpy).toHaveBeenCalled();
});
});
describe("PurchaseSuccessModal — accessibility", () => {
beforeEach(() => {
replaceUrl("http://localhost/?purchase_success=1&item=TestItem");
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});
it("has aria-modal=true on the dialog", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
});
it("has aria-labelledby pointing to the title", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
const dialog = screen.getByRole("dialog");
const labelledby = dialog.getAttribute("aria-labelledby");
expect(labelledby).toBeTruthy();
expect(document.getElementById(labelledby!)).toBeTruthy();
expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
});
it("moves focus to the close button on open", async () => {
render(<PurchaseSuccessModal />);
await act(async () => {
// Two rAFs for focus: one from the effect, one from the RAF wrapper
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.textContent).toMatch(/close/i);
});
});

View File

@ -1,64 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for RevealToggle component.
*
* Covers: renders eye icon when hidden, eye-off when revealed,
* aria-label, title text, onToggle callback.
*/
import React from "react";
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { RevealToggle } from "../ui/RevealToggle";
describe("RevealToggle — render", () => {
it("renders a button element", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button")).toBeTruthy();
});
it("uses the provided aria-label", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
});
it("uses default aria-label when label prop is omitted", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle visibility");
});
it("has title 'Show value' when revealed=false", () => {
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
});
it("has title 'Hide value' when revealed=true", () => {
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
});
});
describe("RevealToggle — interaction", () => {
it("calls onToggle when clicked", () => {
const onToggle = vi.fn();
render(<RevealToggle revealed={false} onToggle={onToggle} />);
fireEvent.click(screen.getByRole("button"));
expect(onToggle).toHaveBeenCalledTimes(1);
});
it("renders EyeIcon (eye SVG) when revealed=false", () => {
const { container } = render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
// Eye icon has a circle path for the eye
expect(container.innerHTML).toContain("M1 12s4-8 11-8");
});
it("renders EyeOffIcon (eye-off SVG) when revealed=true", () => {
const { container } = render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
// Eye-off has a diagonal line
expect(container.innerHTML).toContain("x1");
expect(container.innerHTML).toContain("y2");
});
});

View File

@ -1,351 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for SearchDialog component.
*
* Covers: renders only when open, Cmd+K/Ctrl+K shortcut, Escape close,
* focus management, text filtering (name/role/status), arrow-key
* navigation, Enter to select, footer count, aria attributes.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SearchDialog } from "../SearchDialog";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockStoreState = {
searchOpen: false,
setSearchOpen: vi.fn((open: boolean) => {
mockStoreState.searchOpen = open;
}),
nodes: [] as Array<{
id: string;
data: {
name: string;
status: string;
tier: number;
role: string;
parentId?: string | null;
};
}>,
selectNode: vi.fn(),
setPanelTab: vi.fn(),
};
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
(sel: (s: typeof mockStoreState) => unknown) => sel(mockStoreState),
{ getState: () => mockStoreState },
),
}));
const STORAGE_KEY = "molecule-onboarding-complete";
// ─── Helpers ─────────────────────────────────────────────────────────────────
function dispatchKeydown(key: string, meta = false, ctrl = false) {
fireEvent.keyDown(window, {
key,
metaKey: meta,
ctrlKey: ctrl,
});
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("SearchDialog — visibility", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("does not render when searchOpen is false", () => {
mockStoreState.searchOpen = false;
render(<SearchDialog />);
expect(screen.queryByRole("dialog")).toBeNull();
});
it("renders the dialog when searchOpen is true", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByRole("dialog", { name: "Search workspaces" })).toBeTruthy();
});
});
describe("SearchDialog — keyboard shortcuts", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("opens the dialog when Cmd+K is pressed", () => {
render(<SearchDialog />);
dispatchKeydown("k", true, false);
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
});
it("opens the dialog when Ctrl+K is pressed", () => {
render(<SearchDialog />);
dispatchKeydown("k", false, true);
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(true);
});
it("clears the query when Cmd+K opens the dialog", () => {
render(<SearchDialog />);
dispatchKeydown("k", true, false);
const input = screen.getByRole("combobox");
expect(input.getAttribute("value") ?? "").toBe("");
});
it("closes the dialog when Escape is pressed while open", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
dispatchKeydown("Escape");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
});
describe("SearchDialog — focus", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("focuses the input when the dialog opens", async () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
expect(document.activeElement?.getAttribute("role")).toBe("combobox");
});
it("input has the combobox role", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByRole("combobox")).toBeTruthy();
});
});
describe("SearchDialog — filtering", () => {
beforeEach(() => {
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
{ id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } },
];
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("shows all workspaces when query is empty", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getByText("Bob")).toBeTruthy();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("filters workspaces by name (case-insensitive)", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "alice" } });
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.queryByText("Carol")).toBeNull();
});
it("filters workspaces by role (case-insensitive)", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "writer" } });
expect(screen.queryByText("Alice")).toBeNull();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("filters workspaces by status", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "online" } });
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.queryByText("Bob")).toBeNull();
expect(screen.getByText("Carol")).toBeTruthy();
});
it("shows 'No workspaces match' when filtering returns nothing", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "xyz123" } });
expect(screen.getByText("No workspaces match")).toBeTruthy();
});
it("shows 'No workspaces yet' when canvas is empty", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [];
render(<SearchDialog />);
expect(screen.getByText("No workspaces yet")).toBeTruthy();
});
});
describe("SearchDialog — listbox navigation", () => {
beforeEach(() => {
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
{ id: "n3", data: { name: "Carol", status: "online", tier: 3, role: "writer" } },
];
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("highlights the first result when query is typed", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } });
// First result (Alice) should be highlighted
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
});
it("ArrowDown moves highlight to the next item", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("false");
expect(options[1].getAttribute("aria-selected")).toBe("true");
});
it("ArrowUp moves highlight to the previous item", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "ArrowUp" });
const options = screen.getAllByRole("option");
expect(options[0].getAttribute("aria-selected")).toBe("true");
expect(options[1].getAttribute("aria-selected")).toBe("false");
});
it("Enter selects the highlighted workspace", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const input = screen.getByRole("combobox");
fireEvent.change(input, { target: { value: "a" } }); // All 3 match
fireEvent.keyDown(input, { key: "ArrowDown" }); // Highlight Bob
fireEvent.keyDown(input, { key: "Enter" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1"); // Alice
expect(mockStoreState.setPanelTab).toHaveBeenCalledWith("details");
expect(mockStoreState.setSearchOpen).toHaveBeenCalledWith(false);
});
});
describe("SearchDialog — aria attributes", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("dialog has role=dialog and aria-modal=true", () => {
mockStoreState.searchOpen = true;
render(<SearchDialog />);
const dialog = screen.getByRole("dialog");
expect(dialog.getAttribute("aria-modal")).toBe("true");
expect(dialog.getAttribute("aria-label")).toBe("Search workspaces");
});
it("results container has role=listbox", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getByRole("listbox")).toBeTruthy();
});
it("each result has role=option", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getAllByRole("option").length).toBeGreaterThan(0);
});
});
describe("SearchDialog — footer", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
mockStoreState.searchOpen = false;
mockStoreState.nodes = [];
mockStoreState.setSearchOpen.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.setPanelTab.mockClear();
});
it("footer shows singular 'workspace' when count is 1", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
];
render(<SearchDialog />);
expect(screen.getByText("1 workspace")).toBeTruthy();
});
it("footer shows plural 'workspaces' when count > 1", () => {
mockStoreState.searchOpen = true;
mockStoreState.nodes = [
{ id: "n1", data: { name: "Alice", status: "online", tier: 4, role: "assistant" } },
{ id: "n2", data: { name: "Bob", status: "offline", tier: 2, role: "analyst" } },
];
render(<SearchDialog />);
expect(screen.getByText("2 workspaces")).toBeTruthy();
});
});

View File

@ -1,173 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for SettingsButton component.
*
* Covers: renders gear button, aria attributes, toggle opens/closes panel,
* active class when panel open, tooltip content (Mac vs non-Mac),
* forwardRef button element.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SettingsButton } from "../settings/SettingsButton";
import { useSecretsStore } from "@/stores/secrets-store";
// ─── Mock Radix Tooltip ────────────────────────────────────────────────────────
vi.mock("@radix-ui/react-tooltip", () => ({
Provider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Root: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Trigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Portal: ({ children }: { children: React.ReactNode }) => <>{children}</>,
Content: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Arrow: () => null,
}));
// ─── Mock secrets store ────────────────────────────────────────────────────────
const mockSecretsState = {
isPanelOpen: false,
openPanel: vi.fn(),
closePanel: vi.fn(),
};
vi.mock("@/stores/secrets-store", () => ({
useSecretsStore: Object.assign(
(sel: (s: typeof mockSecretsState) => unknown) => sel(mockSecretsState),
{ getState: () => mockSecretsState },
),
}));
// ─── Helpers ──────────────────────────────────────────────────────────────────
function getMacUserAgent() {
return vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("SettingsButton — render", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
mockSecretsState.isPanelOpen = false;
mockSecretsState.openPanel.mockClear();
mockSecretsState.closePanel.mockClear();
});
it("renders a button with aria-label=Settings", () => {
render(<SettingsButton />);
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("has aria-expanded=false when panel is closed", () => {
render(<SettingsButton />);
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("false");
});
it("has aria-expanded=true when panel is open", () => {
mockSecretsState.isPanelOpen = true;
render(<SettingsButton />);
expect(screen.getByRole("button").getAttribute("aria-expanded")).toBe("true");
});
it("renders with active class when panel is open", () => {
mockSecretsState.isPanelOpen = true;
render(<SettingsButton />);
const btn = screen.getByRole("button");
expect(btn.className).toContain("settings-button--active");
});
it("does not render active class when panel is closed", () => {
render(<SettingsButton />);
const btn = screen.getByRole("button");
expect(btn.className).not.toContain("settings-button--active");
});
});
describe("SettingsButton — toggle", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
mockSecretsState.isPanelOpen = false;
mockSecretsState.openPanel.mockClear();
mockSecretsState.closePanel.mockClear();
});
it("calls openPanel when panel is closed and button is clicked", () => {
render(<SettingsButton />);
fireEvent.click(screen.getByRole("button"));
expect(mockSecretsState.openPanel).toHaveBeenCalledTimes(1);
expect(mockSecretsState.closePanel).not.toHaveBeenCalled();
});
it("calls closePanel when panel is open and button is clicked", () => {
mockSecretsState.isPanelOpen = true;
render(<SettingsButton />);
fireEvent.click(screen.getByRole("button"));
expect(mockSecretsState.closePanel).toHaveBeenCalledTimes(1);
expect(mockSecretsState.openPanel).not.toHaveBeenCalled();
});
});
describe("SettingsButton — tooltip", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
vi.clearAllMocks();
mockSecretsState.isPanelOpen = false;
mockSecretsState.openPanel.mockClear();
mockSecretsState.closePanel.mockClear();
});
it("shows tooltip with ⌘, on Mac", () => {
getMacUserAgent();
render(<SettingsButton />);
// Advance timers to trigger Tooltip.Provider's delay (300ms)
act(() => { vi.advanceTimersByTime(300); });
// The Tooltip.Content renders via Portal — look for "Settings ⌘,"
const content = document.body.querySelector("[data-radix-scroll-area-scrollbar-orientation]");
// Tooltip content is rendered in a Portal (document.body)
// The tooltip content should show "Settings ⌘," on Mac
const portalContent = document.body.querySelector("div:last-child");
// Check if the gear icon button was rendered
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("shows tooltip with Ctrl+, on non-Mac", () => {
vi.spyOn(navigator, "userAgent", "get").mockReturnValue(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
);
render(<SettingsButton />);
act(() => { vi.advanceTimersByTime(300); });
// Tooltip should say "Settings Ctrl+,"
// The gear button is rendered correctly
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
});
describe("SettingsButton — forwardRef", () => {
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.clearAllMocks();
mockSecretsState.isPanelOpen = false;
mockSecretsState.openPanel.mockClear();
mockSecretsState.closePanel.mockClear();
});
it("forwards the ref to the button element", () => {
const ref = React.createRef<HTMLButtonElement>();
render(<SettingsButton ref={ref} />);
expect(ref.current).toBe(screen.getByRole("button"));
});
});

View File

@ -1,58 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for Spinner component.
*
* Covers: sm/md/lg size classes, aria-hidden, motion-safe animate-spin class.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Spinner } from "../Spinner";
describe("Spinner — size variants", () => {
it("renders with sm size class", () => {
const { container } = render(<Spinner size="sm" />);
const svg = container.querySelector("svg");
expect(svg).toBeTruthy();
expect(svg?.className).toContain("w-3");
expect(svg?.className).toContain("h-3");
});
it("renders with md size class (default)", () => {
const { container } = render(<Spinner size="md" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("renders with lg size class", () => {
const { container } = render(<Spinner size="lg" />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-5");
expect(svg?.className).toContain("h-5");
});
it("defaults to md size when no size prop given", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("w-4");
expect(svg?.className).toContain("h-4");
});
it("has aria-hidden=true so screen readers skip it", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.getAttribute("aria-hidden")).toBe("true");
});
it("includes the motion-safe:animate-spin class for CSS animation", () => {
const { container } = render(<Spinner />);
const svg = container.querySelector("svg");
expect(svg?.className).toContain("motion-safe:animate-spin");
});
it("renders exactly one SVG element", () => {
const { container } = render(<Spinner />);
expect(container.querySelectorAll("svg").length).toBe(1);
});
});

View File

@ -1,57 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for StatusBadge component.
*
* Covers: renders all three status variants, aria-label, role=status,
* icon presence, className variants, no render when passed invalid status.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { StatusBadge } from "../ui/StatusBadge";
describe("StatusBadge — render", () => {
it("renders verified status with ✓ icon", () => {
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("✓");
expect(badge.getAttribute("aria-label")).toBe("Connection status: verified");
});
it("renders invalid status with ✗ icon", () => {
render(<StatusBadge status="invalid" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("✗");
expect(badge.getAttribute("aria-label")).toBe("Connection status: invalid");
});
it("renders unverified status with ○ icon", () => {
render(<StatusBadge status="unverified" />);
const badge = screen.getByRole("status");
expect(badge.textContent).toBe("○");
expect(badge.getAttribute("aria-label")).toBe("Connection status: unverified");
});
it("has role=status on the badge element", () => {
render(<StatusBadge status="verified" />);
expect(screen.getByRole("status")).toBeTruthy();
});
it("includes the config className on the rendered element", () => {
render(<StatusBadge status="verified" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--valid");
});
it("includes status-badge--invalid class for invalid status", () => {
render(<StatusBadge status="invalid" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--invalid");
});
it("includes status-badge--unverified class for unverified status", () => {
render(<StatusBadge status="unverified" />);
const badge = screen.getByRole("status");
expect(badge.className).toContain("status-badge--unverified");
});
});

View File

@ -1,100 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for StatusDot the small coloured indicator rendered inside
* workspace cards to convey runtime status (online/offline/degraded/etc.).
*
* Coverage:
* - Renders for every known status in STATUS_CONFIG
* - Unknown status falls back to bg-zinc-500
* - size prop (sm/md) applies the correct Tailwind dimension class
* - aria-hidden="true" and role="img" for accessibility
* - provisioning status carries motion-safe:animate-pulse for the pulsing effect
* - glow class applied when STATUS_CONFIG declares one
*/
import { describe, expect, it } from "vitest";
import { render, screen } from "@testing-library/react";
import React from "react";
import { StatusDot } from "../StatusDot";
describe("StatusDot — snapshot", () => {
it("renders with online status", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-emerald-400");
expect(dot.className).toContain("shadow-emerald-400/50");
expect(dot.getAttribute("aria-hidden")).toBe("true");
});
it("renders with offline status", () => {
render(<StatusDot status="offline" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-zinc-500");
// offline has no glow
expect(dot.className).not.toContain("shadow-");
});
it("renders with degraded status", () => {
render(<StatusDot status="degraded" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-amber-400");
expect(dot.className).toContain("shadow-amber-400/50");
});
it("renders with failed status", () => {
render(<StatusDot status="failed" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-red-400");
expect(dot.className).toContain("shadow-red-400/50");
});
it("renders with paused status", () => {
render(<StatusDot status="paused" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-indigo-400");
});
it("renders with not_configured status", () => {
render(<StatusDot status="not_configured" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-amber-300");
expect(dot.className).toContain("shadow-amber-300/50");
});
it("renders with provisioning status and pulsing animation", () => {
render(<StatusDot status="provisioning" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-sky-400");
expect(dot.className).toContain("motion-safe:animate-pulse");
expect(dot.className).toContain("shadow-sky-400/50");
});
it("falls back to bg-zinc-500 for unknown status", () => {
render(<StatusDot status="alien_artifact" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("bg-zinc-500");
});
});
describe("StatusDot — size prop", () => {
it("applies w-2 h-2 (sm, default)", () => {
render(<StatusDot status="online" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("w-2");
expect(dot.className).toContain("h-2");
});
it("applies w-2.5 h-2.5 (md)", () => {
render(<StatusDot status="online" size="md" />);
const dot = screen.getByRole("img");
expect(dot.className).toContain("w-2.5");
expect(dot.className).toContain("h-2.5");
});
});
describe("StatusDot — accessibility", () => {
it("is aria-hidden so it doesn't pollute the accessibility tree", () => {
render(<StatusDot status="online" />);
expect(screen.getByRole("img").getAttribute("aria-hidden")).toBe("true");
});
});

View File

@ -1,222 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TermsGate component.
*
* Covers: loading accepted (already agreed), loading pending (show
* modal), 401 accepted (not signed in), error state, accept flow,
* focus management (WCAG 2.4.3), and modal accessibility.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { TermsGate } from "../TermsGate";
// PLATFORM_URL is imported from @/lib/api; we mock it via module mock
vi.mock("@/lib/api", () => ({
PLATFORM_URL: "https://app.example.com",
}));
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
// ─── Helpers ──────────────────────────────────────────────────────────────────
function mockFetch(res: Response) {
vi.spyOn(global, "fetch").mockResolvedValueOnce(res);
}
async function resolveFetch(res: Response) {
await act(async () => {
vi.spyOn(global, "fetch").mockResolvedValueOnce(res);
});
}
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("TermsGate — loading → accepted", () => {
it("renders children immediately (loading state)", () => {
mockFetch(new Response(JSON.stringify({ accepted: true }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
// Children are always rendered (TermsGate does not hide them)
expect(screen.getByTestId("children")).toBeTruthy();
});
it("shows no dialog when server returns accepted=true", async () => {
mockFetch(new Response(JSON.stringify({ accepted: true }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
it("shows no dialog when server returns 401 (not signed in)", async () => {
mockFetch(new Response(null, { status: 401 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
});
});
describe("TermsGate — pending state → modal", () => {
it("shows the terms dialog when server returns accepted=false", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children">App content</div>
</TermsGate>
);
await waitFor(() => {
expect(screen.getByRole("dialog")).toBeTruthy();
});
});
it("dialog has aria-modal=true and correct labelling", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div>App content</div>
</TermsGate>
);
const dialog = await waitFor(() => screen.getByRole("dialog"));
expect(dialog.getAttribute("aria-modal")).toBe("true");
expect(dialog.getAttribute("aria-labelledby")).toBeTruthy();
const title = document.getElementById(dialog.getAttribute("aria-labelledby")!);
expect(title?.textContent).toMatch(/terms/i);
});
it("dialog body contains the terms text", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByText(/Terms of Service/i)).toBeTruthy();
expect(screen.getByText(/Privacy Policy/i)).toBeTruthy();
expect(screen.getByText(/AWS us-east-2/i)).toBeTruthy();
});
it("the I agree button is present", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByRole("button", { name: /i agree/i })).toBeTruthy();
});
it("links to terms and privacy policy have correct hrefs", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
const links = screen.getAllByRole("link");
const hrefs = links.map((l) => l.getAttribute("href"));
expect(hrefs).toContain("/legal/terms");
expect(hrefs).toContain("/legal/privacy");
});
});
describe("TermsGate — focus management (WCAG 2.4.3)", () => {
it("moves focus to the I agree button when modal opens", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
const dialog = await waitFor(() => screen.getByRole("dialog"));
// Focus is moved via requestAnimationFrame — wait a tick
await act(async () => {
await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
});
const agreeBtn = screen.getByRole("button", { name: /i agree/i });
expect(document.activeElement).toBe(agreeBtn);
});
});
describe("TermsGate — accept flow", () => {
it("calls POST /cp/auth/accept-terms and closes dialog on success", async () => {
// First: terms-status → pending
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
// Second: accept-terms → 200
const postMock = mockFetch(new Response(null, { status: 200 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
await waitFor(() => {
expect(screen.queryByRole("dialog")).toBeNull();
});
// Check POST was called
const calls = vi.mocked(global.fetch).mock.calls;
expect(calls.some(
([url, opts]) =>
(url as string).includes("/accept-terms") &&
(opts as RequestInit).method === "POST"
)).toBe(true);
});
it("shows error message and keeps modal open when accept fails", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
mockFetch(new Response("Internal Server Error", { status: 500 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => screen.getByRole("dialog"));
fireEvent.click(screen.getByRole("button", { name: /i agree/i }));
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
// Dialog is still open
expect(screen.getByRole("dialog")).toBeTruthy();
});
it.skip("disables the button while submitting (requires fake-timers around fireEvent.click)", async () => {
// This test requires vi.useFakeTimers() + act(() => { fireEvent.click(btn); vi.runAllTimers(); })
// to synchronously advance through the async boundary between click and fetch initiation.
// The current test structure fires the fetch before click, so this is skipped pending
// a refactor of the component to not initiate fetch synchronously on user gesture.
});
});
describe("TermsGate — error state", () => {
it("shows an error alert when terms-status fetch fails with non-401", async () => {
mockFetch(new Response("Gateway Timeout", { status: 504 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
});
it("error alert contains the status code", async () => {
mockFetch(new Response(null, { status: 503 }));
render(<TermsGate><div>App content</div></TermsGate>);
await waitFor(() => {
expect(screen.getByRole("alert")).toBeTruthy();
});
expect(screen.getByRole("alert").textContent).toMatch(/503/);
});
});
describe("TermsGate — children always rendered", () => {
it("renders children even when modal is shown (does not gate them)", async () => {
mockFetch(new Response(JSON.stringify({ accepted: false }), { status: 200 }));
render(
<TermsGate>
<div data-testid="children-visible">Behind the modal</div>
</TermsGate>
);
await waitFor(() => screen.getByRole("dialog"));
expect(screen.getByTestId("children-visible")).toBeTruthy();
});
});

View File

@ -1,216 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TestConnectionButton component.
*
* Covers: all 4 states (idle/testing/success/failure), button disabled
* during testing, disabled when secretValue empty, error detail display,
* auto-reset to idle after 3s (success) and 5s (failure), onResult callback.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TestConnectionButton } from "../ui/TestConnectionButton";
import type { SecretGroup } from "@/types/secrets";
// ─── Mock validateSecret ──────────────────────────────────────────────────────
const mockValidateSecret = vi.fn();
vi.mock("@/lib/api/secrets", () => ({
validateSecret: mockValidateSecret,
}));
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
const toGroup = (id: string): SecretGroup => id as SecretGroup;
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("TestConnectionButton — render", () => {
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("renders 'Test connection' button in idle state", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
});
it("disables button when secretValue is empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="" />);
expect(screen.getByRole("button").getAttribute("disabled")).toBeTruthy();
});
it("enables button when secretValue is non-empty", () => {
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-test" />);
expect(screen.getByRole("button").getAttribute("disabled")).toBeFalsy();
});
});
describe("TestConnectionButton — state machine", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("shows 'Testing…' while validateSecret is pending", async () => {
mockValidateSecret.mockImplementation(() => new Promise(() => {})); // never resolves
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
// Button should show testing label and be disabled
expect(screen.getByRole("button", { name: "Testing…" }).getAttribute("disabled")).toBeTruthy();
});
it("shows 'Connected ✓' on success", async () => {
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
});
it("shows 'Test failed' on validation failure", async () => {
mockValidateSecret.mockResolvedValue({ valid: false, error: "Invalid key format" });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad-key" />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Test failed" })).toBeTruthy();
});
it("shows error detail when validation returns invalid with message", async () => {
mockValidateSecret.mockResolvedValue({ valid: false, error: "Permission denied" });
render(<TestConnectionButton provider={toGroup("github")} secretValue="ghp_xxx" />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Permission denied")).toBeTruthy();
});
it("shows generic error message on unexpected exception", async () => {
mockValidateSecret.mockRejectedValue(new Error("timeout"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush */ });
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText(/timeout/i)).toBeTruthy();
});
});
describe("TestConnectionButton — auto-reset", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("resets to idle after 3 seconds on success", async () => {
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
act(() => { vi.advanceTimersByTime(3000); });
await act(async () => { /* flush */ });
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
});
it("resets to idle after 5 seconds on failure", async () => {
mockValidateSecret.mockResolvedValue({ valid: false, error: "Bad key" });
render(<TestConnectionButton provider={toGroup("github")} secretValue="bad" />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Test failed" })).toBeTruthy();
act(() => { vi.advanceTimersByTime(5000); });
await act(async () => { /* flush */ });
expect(screen.getByRole("button", { name: "Test connection" })).toBeTruthy();
});
it("does not reset before 3 seconds on success", async () => {
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
act(() => { vi.advanceTimersByTime(2900); });
await act(async () => { /* flush */ });
// Still showing success
expect(screen.getByRole("button", { name: "Connected ✓" })).toBeTruthy();
});
});
describe("TestConnectionButton — onResult callback", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
mockValidateSecret.mockReset();
});
it("calls onResult(true) on success", async () => {
const onResult = vi.fn();
mockValidateSecret.mockResolvedValue({ valid: true });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(onResult).toHaveBeenCalledWith(true);
});
it("calls onResult(false) on failure", async () => {
const onResult = vi.fn();
mockValidateSecret.mockResolvedValue({ valid: false });
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="bad" onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush microtasks */ });
expect(onResult).toHaveBeenCalledWith(false);
});
it("calls onResult(false) when exception is thrown", async () => {
const onResult = vi.fn();
mockValidateSecret.mockRejectedValue(new Error("network error"));
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." onResult={onResult} />);
fireEvent.click(screen.getByRole("button"));
await act(async () => { /* flush */ });
expect(onResult).toHaveBeenCalledWith(false);
});
});

View File

@ -1,146 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ThemeToggle component.
*
* Covers: renders all three options, aria radiogroup semantics,
* aria-checked per option, setTheme calls on click, custom className prop.
*/
import React from "react";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ThemeToggle } from "../ThemeToggle";
import * as themeProvider from "@/lib/theme-provider";
// ─── Mock theme provider ───────────────────────────────────────────────────────
const mockSetTheme = vi.fn();
vi.mock("@/lib/theme-provider", () => ({
useTheme: vi.fn(() => ({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
})),
}));
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
// ─── Tests ────────────────────────────────────────────────────────────────────
describe("ThemeToggle — render", () => {
beforeEach(() => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
});
it("renders a radiogroup with aria-label", () => {
render(<ThemeToggle />);
expect(screen.getByRole("radiogroup", { name: "Theme preference" })).toBeTruthy();
});
it("renders three radio buttons", () => {
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios).toHaveLength(3);
});
it("has aria-checked=true on the active option", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[2].getAttribute("aria-checked")).toBe("true"); // dark is third
expect(radios[0].getAttribute("aria-checked")).toBe("false"); // light is first
expect(radios[1].getAttribute("aria-checked")).toBe("false"); // system is second
});
it("marks 'light' as active when theme=light", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "light",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[0].getAttribute("aria-checked")).toBe("true"); // light
expect(radios[1].getAttribute("aria-checked")).toBe("false"); // system
expect(radios[2].getAttribute("aria-checked")).toBe("false"); // dark
});
it("marks 'system' as active when theme=system", () => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "system",
resolvedTheme: "light",
setTheme: mockSetTheme,
});
render(<ThemeToggle />);
const radios = screen.getAllByRole("radio");
expect(radios[0].getAttribute("aria-checked")).toBe("false"); // light
expect(radios[1].getAttribute("aria-checked")).toBe("true"); // system
expect(radios[2].getAttribute("aria-checked")).toBe("false"); // dark
});
it("has aria-label on each button matching the option label", () => {
render(<ThemeToggle />);
expect(screen.getByRole("radio", { name: "Light" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "System" })).toBeTruthy();
expect(screen.getByRole("radio", { name: "Dark" })).toBeTruthy();
});
});
describe("ThemeToggle — interaction", () => {
beforeEach(() => {
vi.mocked(themeProvider.useTheme).mockReturnValue({
theme: "dark",
resolvedTheme: "dark",
setTheme: mockSetTheme,
});
});
it("calls setTheme with 'light' when light button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Light" }));
expect(mockSetTheme).toHaveBeenCalledWith("light");
});
it("calls setTheme with 'system' when system button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "System" }));
expect(mockSetTheme).toHaveBeenCalledWith("system");
});
it("calls setTheme with 'dark' when dark button is clicked", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Dark" }));
expect(mockSetTheme).toHaveBeenCalledWith("dark");
});
it("calls setTheme only once per click", () => {
render(<ThemeToggle />);
fireEvent.click(screen.getByRole("radio", { name: "Light" }));
expect(mockSetTheme).toHaveBeenCalledTimes(1);
});
});
describe("ThemeToggle — className prop", () => {
it("passes custom className to the radiogroup", () => {
render(<ThemeToggle className="my-custom-class" />);
const group = screen.getByRole("radiogroup", { name: "Theme preference" });
expect(group.className).toContain("my-custom-class");
});
it("applies default className when none provided", () => {
render(<ThemeToggle />);
const group = screen.getByRole("radiogroup", { name: "Theme preference" });
expect(group.className).toContain("inline-flex");
});
});

View File

@ -1,235 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for Tooltip component.
*
* Covers: portal rendering, 400ms hover delay, keyboard focus reveal,
* Esc dismiss, no render when text is empty.
*/
import React from "react";
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
import { Tooltip } from "../Tooltip";
afterEach(cleanup);
describe("Tooltip — render", () => {
it("renders children without showing tooltip on mount", () => {
render(
<Tooltip text="Hello world">
<button type="button">Hover me</button>
</Tooltip>
);
expect(screen.getByRole("button", { name: "Hover me" })).toBeTruthy();
// Tooltip portal is not yet in the DOM (no timer fires on mount)
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("does not render the tooltip portal when text is empty string", () => {
render(
<Tooltip text="">
<button type="button">Hover me</button>
</Tooltip>
);
// Move mouse over trigger
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("mounts the tooltip into a portal attached to document.body", () => {
render(
<Tooltip text="Portal tip">
<button type="button">Hover me</button>
</Tooltip>
);
// Simulate mouse enter → 400ms delay → tooltip renders
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(document.body.querySelector('[role="tooltip"]')).toBeTruthy();
});
});
describe("Tooltip — hover delay", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it("does NOT show tooltip before the 400ms delay expires", () => {
render(
<Tooltip text="Delayed tip">
<button type="button">Hover me</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(300);
});
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("shows tooltip after 400ms hover delay", () => {
render(
<Tooltip text="Delayed tip">
<button type="button">Hover me</button>
</Tooltip>
);
fireEvent.mouseEnter(screen.getByRole("button"));
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
});
it("hides tooltip immediately on mouse leave (clears pending timer)", () => {
render(
<Tooltip text="Cleared tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(200);
});
expect(screen.queryByRole("tooltip")).toBeNull();
fireEvent.mouseLeave(btn);
act(() => {
vi.advanceTimersByTime(500);
});
// Still not shown because mouseLeave cancelled the timer
expect(screen.queryByRole("tooltip")).toBeNull();
});
it("does not show on a second mouseEnter after mouseLeave", () => {
render(
<Tooltip text="Re-show tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
fireEvent.mouseLeave(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeNull();
// Re-enter
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
});
});
describe("Tooltip — keyboard focus reveal", () => {
it("shows tooltip on focus without needing the hover timer", () => {
vi.useFakeTimers();
render(
<Tooltip text="Keyboard tip">
<button type="button">Focus me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
// No timer needed — onFocus shows immediately
act(() => {
btn.focus();
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
vi.useRealTimers();
});
it("hides tooltip on blur", () => {
vi.useFakeTimers();
render(
<Tooltip text="Blur tip">
<button type="button">Focus me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
act(() => {
btn.focus();
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
act(() => {
btn.blur();
});
expect(screen.queryByRole("tooltip")).toBeNull();
vi.useRealTimers();
});
});
describe("Tooltip — Esc dismiss (WCAG 1.4.13)", () => {
it("dismisses tooltip on Escape without blurring the trigger", () => {
vi.useFakeTimers();
render(
<Tooltip text="Esc dismiss tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
expect(document.activeElement).toBe(btn);
act(() => {
fireEvent.keyDown(window, { key: "Escape" });
});
expect(screen.queryByRole("tooltip")).toBeNull();
// Trigger is still focused (Esc dismisses tooltip but does not blur)
expect(document.activeElement).toBe(btn);
vi.useRealTimers();
});
it("does nothing on non-Escape keys while tooltip is open", () => {
vi.useFakeTimers();
render(
<Tooltip text="Non-Escape key">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
fireEvent.mouseEnter(btn);
act(() => {
vi.advanceTimersByTime(500);
});
expect(screen.queryByRole("tooltip")).toBeTruthy();
act(() => {
fireEvent.keyDown(window, { key: "Enter" });
});
// Tooltip still visible
expect(screen.queryByRole("tooltip")).toBeTruthy();
vi.useRealTimers();
});
});
describe("Tooltip — aria-describedby", () => {
it("associates tooltip with the trigger via aria-describedby", () => {
render(
<Tooltip text="Associated tip">
<button type="button">Hover me</button>
</Tooltip>
);
const btn = screen.getByRole("button");
const describedBy = btn.getAttribute("aria-describedby");
expect(describedBy).toBeTruthy();
// The describedby id matches the tooltip id
const tooltipId = describedBy!.replace(/.*?:\s*/, "");
expect(document.getElementById(tooltipId)).toBeTruthy();
});
});

View File

@ -1,50 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for TopBar component.
*
* Covers: renders header, logo, canvas name, "+ New Agent" button,
* SettingsButton integration, custom canvasName prop.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TopBar } from "../canvas/TopBar";
// ─── Mock SettingsButton ───────────────────────────────────────────────────────
vi.mock("../settings/SettingsButton", () => ({
SettingsButton: vi.fn(() => <button aria-label="Settings"></button>),
}));
describe("TopBar — render", () => {
it("renders a header element", () => {
render(<TopBar />);
expect(document.body.querySelector("header")).toBeTruthy();
});
it("renders the canvas name (default)", () => {
render(<TopBar />);
expect(screen.getByText("Canvas")).toBeTruthy();
});
it("renders a custom canvas name", () => {
render(<TopBar canvasName="My Org Canvas" />);
expect(screen.getByText("My Org Canvas")).toBeTruthy();
});
it("renders the '+ New Agent' button", () => {
render(<TopBar />);
expect(screen.getByRole("button", { name: /new agent/i })).toBeTruthy();
});
it("renders the SettingsButton", () => {
render(<TopBar />);
expect(screen.getByRole("button", { name: "Settings" })).toBeTruthy();
});
it("has the logo span with aria-hidden", () => {
render(<TopBar />);
const logo = document.body.querySelector('[aria-hidden="true"]');
expect(logo?.textContent).toBe("☁");
});
});

View File

@ -1,77 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for ValidationHint component.
*
* Covers: error state, valid state, neutral/hidden state,
* aria-live for error, icon rendering.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { ValidationHint } from "../ui/ValidationHint";
describe("ValidationHint — error state", () => {
it("renders error message when error is a non-null string", () => {
render(<ValidationHint error="Invalid email address" />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.getByText("Invalid email address")).toBeTruthy();
});
it("includes the warning icon in error state", () => {
render(<ValidationHint error="Too short" />);
expect(screen.getByText(/⚠/)).toBeTruthy();
});
it("uses the error class on the paragraph element", () => {
render(<ValidationHint error="Bad input" />);
const el = screen.getByRole("alert");
expect(el.className).toContain("validation-hint--error");
});
it("renders error even when showValid is true", () => {
render(<ValidationHint error="Oops" showValid={true} />);
expect(screen.getByRole("alert")).toBeTruthy();
expect(screen.queryByText(/✓/)).toBeNull();
});
});
describe("ValidationHint — valid state", () => {
it("renders valid message when error is null and showValid is true", () => {
render(<ValidationHint error={null} showValid={true} />);
expect(screen.getByText("Valid format")).toBeTruthy();
});
it("includes the checkmark icon in valid state", () => {
render(<ValidationHint error={null} showValid={true} />);
expect(screen.getByText(/✓ Valid format/)).toBeTruthy();
});
it("uses the valid class on the paragraph element", () => {
render(<ValidationHint error={null} showValid={true} />);
const el = document.body.querySelector(".validation-hint--valid");
expect(el).toBeTruthy();
});
it("renders nothing when error is null and showValid is false (default)", () => {
const { container } = render(<ValidationHint error={null} />);
expect(container.textContent).toBe("");
});
it("renders nothing when error is empty string", () => {
const { container } = render(<ValidationHint error="" />);
expect(container.textContent).toBe("");
});
});
describe("ValidationHint — neutral / not-yet-validated", () => {
it("renders nothing when error is null and showValid defaults to false", () => {
const { container } = render(<ValidationHint error={null} />);
expect(container.textContent).toBe("");
});
it("renders nothing when error is undefined", () => {
// @ts-expect-error — testing runtime behavior with undefined
const { container } = render(<ValidationHint error={undefined} />);
expect(container.textContent).toBe("");
});
});

View File

@ -1,75 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for createMessage the ChatMessage factory from types.ts.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createMessage } from "../tabs/chat/types";
describe("createMessage", () => {
beforeEach(() => {
// Freeze time so timestamp is deterministic.
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-10T12:00:00.000Z"));
// Stub crypto.randomUUID so message IDs are deterministic.
vi.stubGlobal("crypto", { randomUUID: vi.fn(() => "fixed-uuid-1234") });
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
it("creates a message with the correct role", () => {
const userMsg = createMessage("user", "hello");
expect(userMsg.role).toBe("user");
const agentMsg = createMessage("agent", "hi there");
expect(agentMsg.role).toBe("agent");
const systemMsg = createMessage("system", "prompt loaded");
expect(systemMsg.role).toBe("system");
});
it("creates a message with the correct content", () => {
const msg = createMessage("user", "Deploy the agent now");
expect(msg.content).toBe("Deploy the agent now");
});
it("sets a deterministic id via crypto.randomUUID", () => {
const msg = createMessage("agent", "response");
expect(msg.id).toBe("fixed-uuid-1234");
});
it("sets a deterministic ISO timestamp", () => {
const msg = createMessage("user", "hello");
expect(msg.timestamp).toBe("2026-05-10T12:00:00.000Z");
});
it("omits attachments field when none provided", () => {
const msg = createMessage("user", "hello");
expect(msg.attachments).toBeUndefined();
});
it("omits attachments field when empty array is provided", () => {
const msg = createMessage("agent", "result", []);
expect(msg.attachments).toBeUndefined();
});
it("includes attachments field when non-empty array is provided", () => {
const atts = [{ name: "report.pdf", uri: "workspace:/docs/report.pdf" }];
const msg = createMessage("agent", "see attached", atts);
expect(msg.attachments).toEqual(atts);
});
it("returns a frozen object (prevents accidental mutation)", () => {
const msg = createMessage("user", "hello");
expect(Object.isFrozen(msg)).toBe(true);
});
it("returns a plain object with expected keys", () => {
const msg = createMessage("user", "hello");
expect(Object.keys(msg).sort()).toEqual(
["id", "role", "content", "timestamp"].sort()
);
});
});

View File

@ -1,104 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for getIcon the pure icon-selector from FilesTab/tree.ts.
*/
import { describe, it, expect } from "vitest";
import { getIcon } from "../tabs/FilesTab/tree";
describe("getIcon", () => {
// ─── Directories ──────────────────────────────────────────────────────────
it("returns 📁 for directories regardless of extension", () => {
expect(getIcon("src", true)).toBe("📁");
expect(getIcon("node_modules", true)).toBe("📁");
expect(getIcon(".claude", true)).toBe("📁");
expect(getIcon("foo/bar/baz", true)).toBe("📁");
});
it("returns 📁 even for paths that look like files", () => {
expect(getIcon("foo.txt", true)).toBe("📁");
expect(getIcon("script.sh", true)).toBe("📁");
});
// ─── Files by extension ────────────────────────────────────────────────────
it("returns 📄 for .md files", () => {
expect(getIcon("README.md", false)).toBe("📄");
expect(getIcon("CHANGELOG.md", false)).toBe("📄");
expect(getIcon("docs/guide.md", false)).toBe("📄");
});
it("returns ⚙ for .yaml and .yml files", () => {
expect(getIcon("config.yaml", false)).toBe("⚙");
expect(getIcon("values.yml", false)).toBe("⚙");
expect(getIcon("deploy.yaml", false)).toBe("⚙");
});
it("returns 🐍 for .py files", () => {
expect(getIcon("main.py", false)).toBe("🐍");
expect(getIcon("utils/helpers.py", false)).toBe("🐍");
});
it("returns 💠 for .ts and .tsx files", () => {
expect(getIcon("index.ts", false)).toBe("💠");
expect(getIcon("Component.tsx", false)).toBe("💠");
expect(getIcon("types.d.ts", false)).toBe("💠");
});
it("returns 📜 for .js files", () => {
expect(getIcon("bundle.js", false)).toBe("📜");
expect(getIcon("src/index.js", false)).toBe("📜");
});
it("returns {} for .json files", () => {
expect(getIcon("package.json", false)).toBe("{}");
expect(getIcon("config.json", false)).toBe("{}");
});
it("returns 🌐 for .html files", () => {
expect(getIcon("index.html", false)).toBe("🌐");
expect(getIcon("templates/page.html", false)).toBe("🌐");
});
it("returns 🎨 for .css files", () => {
expect(getIcon("style.css", false)).toBe("🎨");
expect(getIcon("src/app.css", false)).toBe("🎨");
});
it("returns ▸ for .sh files", () => {
expect(getIcon("deploy.sh", false)).toBe("▸");
expect(getIcon("scripts/setup.sh", false)).toBe("▸");
});
// ─── Fallback ─────────────────────────────────────────────────────────────
it("returns 📄 for unknown extensions", () => {
expect(getIcon("README", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("notes.txt", false)).toBe("📄");
expect(getIcon("archive.tar.gz", false)).toBe("📄");
});
it("returns 📄 for paths with no extension", () => {
expect(getIcon("Makefile", false)).toBe("📄");
expect(getIcon("README", false)).toBe("📄");
expect(getIcon("Dockerfile", false)).toBe("📄");
});
// ─── Case sensitivity ──────────────────────────────────────────────────────
it("is case-insensitive for extension lookup", () => {
expect(getIcon("image.PNG", false)).toBe("📄");
expect(getIcon("data.JSON", false)).toBe("{}");
expect(getIcon("script.SH", false)).toBe("▸");
});
// ─── Nested paths ─────────────────────────────────────────────────────────
it("uses the leaf extension for nested paths", () => {
expect(getIcon("src/utils/helpers.ts", false)).toBe("💠");
expect(getIcon("docs/api.yaml", false)).toBe("⚙");
expect(getIcon(".github/workflows/ci.yml", false)).toBe("⚙");
});
});

View File

@ -1,436 +0,0 @@
// @vitest-environment jsdom
/**
* Tests for canvas keyboard shortcuts (useKeyboardShortcuts hook).
*
* Covers: Esc, Enter/Shift+Enter, Cmd+]/[, Z, and Arrow keys.
*
* The hook is tested by dispatching KeyboardEvents at the window and
* asserting the resulting store mutations / dispatched events.
*/
import React from "react";
import { render, cleanup, fireEvent } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useKeyboardShortcuts } from "../useKeyboardShortcuts";
import { useCanvasStore } from "@/store/canvas";
// ─── Mock store ──────────────────────────────────────────────────────────────
const mockSavePosition = vi.fn().mockResolvedValue(undefined);
vi.mock("@/store/canvas", () => ({
useCanvasStore: Object.assign(
vi.fn((sel) => sel(mockStoreState)),
{
getState: () => mockStoreState,
}
),
}));
// Module-level mutable state so tests can mutate between cases
const mockStoreState = {
selectedNodeId: null as string | null,
selectedNodeIds: new Set<string>(),
nodes: [] as Array<{
id: string;
position: { x: number; y: number };
data: { parentId?: string | null };
width?: number;
height?: number;
}>,
contextMenu: null as { x: number; y: number; nodeId: string } | null,
closeContextMenu: vi.fn(),
selectNode: vi.fn(),
clearSelection: vi.fn(),
bumpZOrder: vi.fn(),
savePosition: mockSavePosition,
moveNode: vi.fn(),
onNodesChange: vi.fn(),
};
afterEach(() => {
cleanup();
vi.clearAllMocks();
// Reset to default empty state between tests
mockStoreState.selectedNodeId = null;
mockStoreState.selectedNodeIds = new Set();
mockStoreState.nodes = [];
mockStoreState.contextMenu = null;
mockStoreState.closeContextMenu.mockClear();
mockStoreState.selectNode.mockClear();
mockStoreState.clearSelection.mockClear();
mockStoreState.bumpZOrder.mockClear();
mockStoreState.moveNode.mockClear();
mockStoreState.savePosition.mockClear();
mockStoreState.onNodesChange.mockClear();
});
// ─── Test wrapper ────────────────────────────────────────────────────────────
function ShortcutTestComponent() {
useKeyboardShortcuts();
return <div data-testid="canvas-root" />;
}
function renderWithProvider() {
return render(<ShortcutTestComponent />);
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe("Esc — deselect / close context menu", () => {
it("closes the context menu when one is open", () => {
mockStoreState.contextMenu = { x: 100, y: 100, nodeId: "n1" };
renderWithProvider();
fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.closeContextMenu).toHaveBeenCalledTimes(1);
});
it("clears the batch selection when no context menu is open", () => {
mockStoreState.contextMenu = null;
mockStoreState.selectedNodeIds = new Set(["n1", "n2"]);
renderWithProvider();
fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.clearSelection).toHaveBeenCalledTimes(1);
});
it("deselects the focused node when no batch selection exists", () => {
mockStoreState.contextMenu = null;
mockStoreState.selectedNodeIds = new Set();
mockStoreState.selectedNodeId = "n1";
renderWithProvider();
fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith(null);
});
});
describe("Enter — hierarchy navigation", () => {
beforeEach(() => {
mockStoreState.selectedNodeId = "n1";
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
{ id: "n2", position: { x: 100, y: 0 }, data: { parentId: "n1" } },
{ id: "n3", position: { x: 200, y: 0 }, data: { parentId: null } },
];
});
it("navigates to the first child on Enter", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "Enter" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n2");
});
it("navigates to the parent on Shift+Enter", () => {
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
{ id: "n2", position: { x: 100, y: 0 }, data: { parentId: "n1" } },
];
mockStoreState.selectedNodeId = "n2";
renderWithProvider();
fireEvent.keyDown(window, { key: "Enter", shiftKey: true });
expect(mockStoreState.selectNode).toHaveBeenCalledWith("n1");
});
it("does NOT navigate when no node is selected", () => {
mockStoreState.selectedNodeId = null;
renderWithProvider();
fireEvent.keyDown(window, { key: "Enter" });
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
});
});
describe("Cmd+]/[ — z-order bump", () => {
beforeEach(() => {
mockStoreState.selectedNodeId = "n1";
});
it("bumps z-order forward on Cmd+]", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "]", metaKey: true });
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
});
it("bumps z-order backward on Cmd+[", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "[", metaKey: true });
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", -1);
});
it("uses Ctrl as the modifier key", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "]", ctrlKey: true });
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
});
});
describe("Z — zoom-to-team", () => {
let dispatchedEvents: CustomEvent[] = [];
beforeEach(() => {
dispatchedEvents = [];
mockStoreState.selectedNodeId = "n1";
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
{ id: "n2", position: { x: 100, y: 0 }, data: { parentId: "n1" } },
];
window.addEventListener("molecule:zoom-to-team", (e) => {
dispatchedEvents.push(e as CustomEvent);
});
});
afterEach(() => {
window.removeEventListener("molecule:zoom-to-team", () => {});
});
it("dispatches zoom-to-team when the selected node has children", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "z" });
expect(dispatchedEvents).toHaveLength(1);
expect(dispatchedEvents[0].detail.nodeId).toBe("n1");
});
it("does NOT fire when no node is selected", () => {
mockStoreState.selectedNodeId = null;
renderWithProvider();
fireEvent.keyDown(window, { key: "z" });
expect(dispatchedEvents).toHaveLength(0);
});
it("does NOT fire when the node has no children", () => {
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
];
renderWithProvider();
fireEvent.keyDown(window, { key: "z" });
expect(dispatchedEvents).toHaveLength(0);
});
it("skips when the target element is an input", () => {
renderWithProvider();
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "z" });
expect(dispatchedEvents).toHaveLength(0);
document.body.removeChild(input);
});
});
describe("Arrow keys — keyboard node movement", () => {
beforeEach(() => {
mockStoreState.selectedNodeId = "n1";
mockStoreState.nodes = [
{ id: "n1", position: { x: 100, y: 200 }, data: { parentId: null } },
];
});
it("moves the selected node down on ArrowDown", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowDown" });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", 0, 10);
});
it("moves the selected node up on ArrowUp", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowUp" });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", 0, -10);
});
it("moves the selected node right on ArrowRight", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowRight" });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", 10, 0);
});
it("moves the selected node left on ArrowLeft", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowLeft" });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", -10, 0);
});
it("moves 50 px when Shift is held", () => {
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowDown", shiftKey: true });
expect(mockStoreState.moveNode).toHaveBeenCalledWith("n1", 0, 50);
});
it("does NOT fire when no node is selected", () => {
mockStoreState.selectedNodeId = null;
renderWithProvider();
fireEvent.keyDown(window, { key: "ArrowDown" });
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
});
it("skips when the target element is an input", () => {
renderWithProvider();
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
document.body.removeChild(input);
});
it("skips when a modal dialog is already open", () => {
renderWithProvider();
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "ArrowDown" });
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
document.body.removeChild(dialog);
});
// NOTE: "prevents default browser scroll on arrow keys" was removed.
// jsdom's KeyboardEvent.initKeyboardEvent does not copy the preventDefault
// function from eventProperties into the real KeyboardEvent, so a
// preventDefault mock passed via fireEvent.keyDown(eventProperties) is
// never called. The guard (selected node required) is covered by
// "does NOT fire when no node is selected". The e.preventDefault() call
// itself is verified by code inspection.
});
describe("all shortcuts respect inInput guard", () => {
it("ArrowDown is skipped in an input element", () => {
mockStoreState.selectedNodeId = "n1";
renderWithProvider();
const textarea = document.createElement("textarea");
document.body.appendChild(textarea);
fireEvent.keyDown(textarea, { key: "ArrowDown" });
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
document.body.removeChild(textarea);
});
it("Enter navigation is skipped in an input element", () => {
mockStoreState.selectedNodeId = "n1";
mockStoreState.nodes = [
{ id: "n1", position: { x: 0, y: 0 }, data: { parentId: null } },
{ id: "n2", position: { x: 100, y: 0 }, data: { parentId: "n1" } },
];
renderWithProvider();
const input = document.createElement("input");
document.body.appendChild(input);
fireEvent.keyDown(input, { key: "Enter" });
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
document.body.removeChild(input);
});
});
describe("Cmd/Ctrl+Arrow — keyboard node resize", () => {
beforeEach(() => {
mockStoreState.nodes = [
{
id: "n1",
position: { x: 0, y: 0 },
data: { parentId: null },
width: 210,
height: 110,
},
];
mockStoreState.selectedNodeId = "n1";
renderWithProvider();
});
it("resizes height down (smaller) on Cmd/Ctrl+ArrowUp", () => {
// Node starts at minHeight=110 (no children). Shrinking clamps to min —
// height stays 110. Width is unchanged.
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 110 },
}),
]);
});
it("resizes height up (larger) on Cmd/Ctrl+ArrowDown", () => {
fireEvent.keyDown(window, { key: "ArrowDown", ctrlKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 120 },
}),
]);
});
it("resizes width down (smaller) on Cmd/Ctrl+ArrowLeft", () => {
// Node starts at minWidth=210 (no children). Shrinking clamps to min —
// width stays 210. Height is unchanged.
fireEvent.keyDown(window, { key: "ArrowLeft", metaKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 210, height: 110 },
}),
]);
});
it("resizes width up (larger) on Cmd/Ctrl+ArrowRight", () => {
fireEvent.keyDown(window, { key: "ArrowRight", ctrlKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
type: "dimensions",
id: "n1",
dimensions: { width: 220, height: 110 },
}),
]);
});
it("uses 2px step with Shift held", () => {
// Step is 2px with Shift, but minHeight=110 clamps the result.
// 110 - 2 = 108, Math.max(110, 108) = 110. Width is unchanged.
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true, shiftKey: true });
expect(mockStoreState.onNodesChange).toHaveBeenCalledWith([
expect.objectContaining({
dimensions: { width: 210, height: 110 },
}),
]);
});
it("respects min-height constraint (no children)", () => {
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
fireEvent.keyDown(window, { key: "ArrowUp", metaKey: true });
// After shrinking from 110 to 100, another ArrowUp hits min-height of 110
// (110 - 10 = 100, but 100 < 110 so it should stay at 110)
// Actually: 110 -> 100 -> 110 (resets to min)
// Let me check: the hook does Math.max(minHeight, currentHeight - step)
// minHeight=110, step=10, so 110 - 10 = 100, but Math.max(110, 100) = 110
// So two ArrowUp calls should both result in height=100 then height=110?
// Wait: 110 - 10 = 100, Math.max(110, 100) = 110 (not 100)
// So the height never goes below 110. After first: 110 -> 100, but clamped to 110.
// Actually Math.max(110, 100) = 110, so the height never changes.
// The min constraint is respected — height stays at 110.
expect(mockStoreState.onNodesChange).toHaveBeenLastCalledWith([
expect.objectContaining({ dimensions: { width: 210, height: 110 } }),
]);
});
it("does NOT fire when no node is selected", () => {
mockStoreState.selectedNodeId = null;
fireEvent.keyDown(window, { key: "ArrowDown", metaKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
});
it("skips when a modal dialog is open", () => {
const dialog = document.createElement("div");
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
document.body.appendChild(dialog);
fireEvent.keyDown(window, { key: "ArrowDown", metaKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
document.body.removeChild(dialog);
});
it("skips plain arrow keys (no modifier) — moveNode is called instead", () => {
fireEvent.keyDown(window, { key: "ArrowUp" });
expect(mockStoreState.moveNode).toHaveBeenCalled();
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
});
it("skips Alt+Arrow (not a resize combo)", () => {
fireEvent.keyDown(window, { key: "ArrowUp", altKey: true });
expect(mockStoreState.onNodesChange).not.toHaveBeenCalled();
expect(mockStoreState.moveNode).not.toHaveBeenCalled();
});
});

View File

@ -2,13 +2,6 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useCanvasStore } from "@/store/canvas"; import { useCanvasStore } from "@/store/canvas";
import { type NodeChange, type Node } from "@xyflow/react";
import type { WorkspaceNodeData } from "@/store/canvas";
/** Returns true if the node has any direct child in the node list. */
function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean {
return nodes.some((n) => n.data.parentId === nodeId);
}
/** /**
* Canvas-wide keyboard shortcuts. All bound to the document window so * Canvas-wide keyboard shortcuts. All bound to the document window so
@ -21,9 +14,6 @@ function hasChildren(nodeId: string, nodes: Node<WorkspaceNodeData>[]): boolean
* Cmd/Ctrl+] bump selected node forward in z-order * Cmd/Ctrl+] bump selected node forward in z-order
* Cmd/Ctrl+[ bump selected node backward in z-order * Cmd/Ctrl+[ bump selected node backward in z-order
* Z zoom-to-team if the selected node has children * Z zoom-to-team if the selected node has children
* Arrow keys move selected node 10px (50px with Shift)
* Cmd/Ctrl+Arrow resize selected node ( height, width)
* Cmd/Ctrl+Shift+Arrow resize by 2px per press (fine control)
*/ */
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
useEffect(() => { useEffect(() => {
@ -90,76 +80,6 @@ export function useKeyboardShortcuts() {
); );
} }
} }
// Arrow-key node movement — Figma-style keyboard drag for keyboard users.
// 10 px per press, 50 px with Shift held. Only fires when a node
// is selected and the target isn't a form control. Skipped when a
// modifier key (Cmd/Ctrl/Alt) is held so those combos can be used
// for other shortcuts (e.g. Cmd+Arrow = resize).
if (
!inInput &&
!e.metaKey &&
!e.ctrlKey &&
!e.altKey &&
(e.key === "ArrowUp" ||
e.key === "ArrowDown" ||
e.key === "ArrowLeft" ||
e.key === "ArrowRight")
) {
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
// Skip when a modal/dialog is already open — dialogs own their own
// arrow-key semantics and shouldn't trigger canvas moves.
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
e.preventDefault();
const step = e.shiftKey ? 50 : 10;
let dx = 0;
let dy = 0;
if (e.key === "ArrowUp") dy = -step;
else if (e.key === "ArrowDown") dy = step;
else if (e.key === "ArrowLeft") dx = -step;
else dx = step;
state.moveNode(selectedId, dx, dy);
}
// Cmd/Ctrl+Arrow — keyboard-accessible node resize.
// ↑/↓ resizes height, ←/→ resizes width.
// 10 px per press (2 px with Shift for fine control).
// Uses the same onNodesChange('dimensions') path that NodeResizer uses.
if (
!inInput &&
(e.metaKey || e.ctrlKey) &&
(e.key === "ArrowUp" ||
e.key === "ArrowDown" ||
e.key === "ArrowLeft" ||
e.key === "ArrowRight")
) {
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
e.preventDefault();
const step = e.shiftKey ? 2 : 10;
const node = state.nodes.find((n) => n.id === selectedId);
if (!node) return;
const currentWidth = (node.width ?? 210) as number;
const currentHeight = (node.height ?? 110) as number;
const minWidth = hasChildren(node.id, state.nodes) ? 360 : 210;
const minHeight = hasChildren(node.id, state.nodes) ? 200 : 110;
let newWidth = currentWidth;
let newHeight = currentHeight;
if (e.key === "ArrowUp") newHeight = Math.max(minHeight, currentHeight - step);
else if (e.key === "ArrowDown") newHeight = currentHeight + step;
else if (e.key === "ArrowLeft") newWidth = Math.max(minWidth, currentWidth - step);
else newWidth = currentWidth + step;
const change: NodeChange = {
type: "dimensions",
id: selectedId,
dimensions: { width: newWidth, height: newHeight },
};
state.onNodesChange([change]);
}
}; };
window.addEventListener("keydown", handler); window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler);

View File

@ -109,7 +109,7 @@ export function OrgTokensTab() {
Organization API Keys Organization API Keys
</h3> </h3>
</div> </div>
<p className="text-[10px] text-ink-mid leading-relaxed"> <p className="text-[10px] text-ink-soft leading-relaxed">
Full-admin bearer tokens for this organization. Use with external Full-admin bearer tokens for this organization. Use with external
integrations, CLI tools, or AI agents that need to manage integrations, CLI tools, or AI agents that need to manage
workspaces, settings, and secrets. Each key has the same workspaces, settings, and secrets. Each key has the same
@ -182,13 +182,13 @@ export function OrgTokensTab() {
{/* Token list */} {/* Token list */}
{loading ? ( {loading ? (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs"> <div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
<Spinner /> Loading keys... <Spinner /> Loading keys...
</div> </div>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<div className="text-center py-6"> <div className="text-center py-6">
<p className="text-xs text-ink-mid">No active keys</p> <p className="text-xs text-ink-soft">No active keys</p>
<p className="text-[10px] text-ink-mid mt-1"> <p className="text-[10px] text-ink-soft mt-1">
Create a key above to authenticate API calls to this organization. Create a key above to authenticate API calls to this organization.
</p> </p>
</div> </div>
@ -209,7 +209,7 @@ export function OrgTokensTab() {
{t.name} {t.name}
</span> </span>
)} )}
<div className="text-[9px] text-ink-mid space-x-3"> <div className="text-[9px] text-ink-soft space-x-3">
<span>Created {formatAge(t.created_at)}</span> <span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && ( {t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span> <span>Last used {formatAge(t.last_used_at)}</span>

View File

@ -81,7 +81,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-semibold text-ink">API Tokens</h3> <h3 className="text-sm font-semibold text-ink">API Tokens</h3>
<p className="text-[10px] text-ink-mid mt-0.5"> <p className="text-[10px] text-ink-soft mt-0.5">
Bearer tokens for authenticating API calls to this workspace. Bearer tokens for authenticating API calls to this workspace.
</p> </p>
</div> </div>
@ -129,13 +129,13 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
{/* Token list */} {/* Token list */}
{loading ? ( {loading ? (
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-mid text-xs"> <div role="status" aria-live="polite" className="flex items-center justify-center gap-2 py-6 text-ink-soft text-xs">
<Spinner /> Loading tokens... <Spinner /> Loading tokens...
</div> </div>
) : tokens.length === 0 ? ( ) : tokens.length === 0 ? (
<div className="text-center py-6"> <div className="text-center py-6">
<p className="text-xs text-ink-mid">No active tokens</p> <p className="text-xs text-ink-soft">No active tokens</p>
<p className="text-[10px] text-ink-mid mt-1"> <p className="text-[10px] text-ink-soft mt-1">
Create a token to authenticate API calls. Create a token to authenticate API calls.
</p> </p>
</div> </div>
@ -150,7 +150,7 @@ export function TokensTab({ workspaceId }: TokensTabProps) {
<code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded"> <code className="text-[11px] font-mono text-ink-mid bg-surface-sunken/60 px-1.5 py-0.5 rounded">
{t.prefix}... {t.prefix}...
</code> </code>
<div className="text-[9px] text-ink-mid space-x-3"> <div className="text-[9px] text-ink-soft space-x-3">
<span>Created {formatAge(t.created_at)}</span> <span>Created {formatAge(t.created_at)}</span>
{t.last_used_at && ( {t.last_used_at && (
<span>Last used {formatAge(t.last_used_at)}</span> <span>Last used {formatAge(t.last_used_at)}</span>

View File

@ -142,7 +142,7 @@ export function ActivityTab({ workspaceId }: Props) {
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${ className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
filter === f.id filter === f.id
? "bg-surface-card text-ink ring-1 ring-zinc-600" ? "bg-surface-card text-ink ring-1 ring-zinc-600"
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60" : "text-ink-soft hover:text-ink-mid hover:bg-surface-card/60"
}`} }`}
> >
<span className="mr-0.5 opacity-60">{f.icon}</span> {f.label} <span className="mr-0.5 opacity-60">{f.icon}</span> {f.label}
@ -153,7 +153,7 @@ export function ActivityTab({ workspaceId }: Props) {
onClick={() => setAutoRefresh(!autoRefresh)} onClick={() => setAutoRefresh(!autoRefresh)}
aria-pressed={autoRefresh} aria-pressed={autoRefresh}
className={`text-[11px] px-1.5 py-0.5 rounded ${ className={`text-[11px] px-1.5 py-0.5 rounded ${
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-mid" autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-soft"
}`} }`}
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"} title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
> >
@ -177,7 +177,7 @@ export function ActivityTab({ workspaceId }: Props) {
</button> </button>
</div> </div>
</div> </div>
<div className="mt-1.5 text-[10px] text-ink-mid"> <div className="mt-1.5 text-[10px] text-ink-soft">
{activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"} {activities.length} {filter === "all" ? "activities" : filter.replace("_", " ") + " entries"}
</div> </div>
</div> </div>
@ -185,7 +185,7 @@ export function ActivityTab({ workspaceId }: Props) {
{/* Activity list */} {/* Activity list */}
<div className="flex-1 overflow-y-auto p-3 space-y-1.5"> <div className="flex-1 overflow-y-auto p-3 space-y-1.5">
{loading && activities.length === 0 && ( {loading && activities.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8">Loading activity...</div> <div className="text-xs text-ink-soft text-center py-8">Loading activity...</div>
)} )}
{error && ( {error && (
@ -196,8 +196,8 @@ export function ActivityTab({ workspaceId }: Props) {
{!loading && !error && activities.length === 0 && ( {!loading && !error && activities.length === 0 && (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-ink-mid text-xs">No activity recorded yet</div> <div className="text-ink-soft text-xs">No activity recorded yet</div>
<div className="text-ink-mid text-[9px] mt-1"> <div className="text-ink-soft text-[9px] mt-1">
Activity logs appear when agents communicate or perform tasks Activity logs appear when agents communicate or perform tasks
</div> </div>
</div> </div>
@ -265,16 +265,16 @@ function ActivityRow({
</span> </span>
{entry.duration_ms != null && ( {entry.duration_ms != null && (
<span className="text-[8px] text-ink-mid font-mono tabular-nums shrink-0"> <span className="text-[8px] text-ink-soft font-mono tabular-nums shrink-0">
{entry.duration_ms}ms {entry.duration_ms}ms
</span> </span>
)} )}
<span className="text-[8px] text-ink-mid shrink-0"> <span className="text-[8px] text-ink-soft shrink-0">
{formatTime(entry.created_at)} {formatTime(entry.created_at)}
</span> </span>
<span className="text-[9px] text-ink-mid"> <span className="text-[9px] text-ink-soft">
{expanded ? "▼" : "▶"} {expanded ? "▼" : "▶"}
</span> </span>
</div> </div>
@ -296,7 +296,7 @@ function ActivityRow({
{resolveName(entry.source_id)} {resolveName(entry.source_id)}
</span> </span>
)} )}
<span className="text-[9px] text-ink-mid"></span> <span className="text-[9px] text-ink-soft"></span>
{entry.target_id && ( {entry.target_id && (
<span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}> <span className="text-[9px] text-accent/80 truncate max-w-[140px]" title={entry.target_id}>
{resolveName(entry.target_id)} {resolveName(entry.target_id)}
@ -338,7 +338,7 @@ function ActivityRow({
{entry.response_body && ( {entry.response_body && (
<JsonBlock label="Response" data={entry.response_body} /> <JsonBlock label="Response" data={entry.response_body} />
)} )}
<div className="text-[8px] text-ink-mid font-mono select-all"> <div className="text-[8px] text-ink-soft font-mono select-all">
ID: {entry.id} ID: {entry.id}
</div> </div>
</div> </div>
@ -386,7 +386,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
} }
return ( return (
<div> <div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div> <div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words"> <div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
{text.slice(0, 2000)} {text.slice(0, 2000)}
</div> </div>
@ -429,7 +429,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
return ( return (
<div> <div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div> <div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words"> <div className="text-[10px] text-ink-mid bg-surface-sunken/60 rounded p-2 max-h-32 overflow-y-auto whitespace-pre-wrap break-words">
{text.slice(0, 2000)} {text.slice(0, 2000)}
</div> </div>
@ -440,7 +440,7 @@ function MessagePreview({ label, body }: { label: string; body: Record<string, u
function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) { function Detail({ label, value, mono, error: isError }: { label: string; value: string; mono?: boolean; error?: boolean }) {
return ( return (
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<span className="text-[8px] text-ink-mid uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span> <span className="text-[8px] text-ink-soft uppercase tracking-wider w-14 shrink-0 pt-0.5">{label}</span>
<span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}> <span className={`text-[9px] break-all ${isError ? "text-bad" : "text-ink-mid"} ${mono ? "font-mono" : ""}`}>
{value} {value}
</span> </span>
@ -451,7 +451,7 @@ function Detail({ label, value, mono, error: isError }: { label: string; value:
function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) { function JsonBlock({ label, data }: { label: string; data: Record<string, unknown> }) {
return ( return (
<div> <div>
<div className="text-[8px] text-ink-mid uppercase tracking-wider mb-1">{label}</div> <div className="text-[8px] text-ink-soft uppercase tracking-wider mb-1">{label}</div>
<pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono"> <pre className="text-[9px] text-ink-mid bg-surface-sunken/80 rounded p-2 overflow-x-auto max-h-48 font-mono">
{JSON.stringify(data, null, 2)} {JSON.stringify(data, null, 2)}
</pre> </pre>

View File

@ -158,7 +158,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Usage stats */} {/* Usage stats */}
{loading ? ( {loading ? (
<p className="text-xs text-ink-mid" data-testid="budget-loading"> <p className="text-xs text-ink-soft" data-testid="budget-loading">
Loading Loading
</p> </p>
) : fetchError ? ( ) : fetchError ? (
@ -172,7 +172,7 @@ export function BudgetSection({ workspaceId }: Props) {
<span className="text-xs text-ink-mid">Credits used</span> <span className="text-xs text-ink-mid">Credits used</span>
<span className="text-xs font-mono text-ink-mid"> <span className="text-xs font-mono text-ink-mid">
<span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span> <span data-testid="budget-used-value">{(budget.budget_used ?? 0).toLocaleString()}</span>
<span className="text-ink-mid mx-1">/</span> <span className="text-ink-soft mx-1">/</span>
<span data-testid="budget-limit-value"> <span data-testid="budget-limit-value">
{budget.budget_limit != null {budget.budget_limit != null
? budget.budget_limit.toLocaleString() ? budget.budget_limit.toLocaleString()
@ -201,7 +201,7 @@ export function BudgetSection({ workspaceId }: Props) {
{/* Remaining credits */} {/* Remaining credits */}
{budget.budget_remaining != null && ( {budget.budget_remaining != null && (
<p className="text-[11px] text-ink-mid" data-testid="budget-remaining"> <p className="text-[11px] text-ink-soft" data-testid="budget-remaining">
{budget.budget_remaining.toLocaleString()} credits remaining {budget.budget_remaining.toLocaleString()} credits remaining
</p> </p>
)} )}
@ -227,7 +227,7 @@ export function BudgetSection({ workspaceId }: Props) {
data-testid="budget-limit-input" data-testid="budget-limit-input"
className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors" className="w-full bg-surface-card border border-line rounded-lg px-3 py-2 text-sm text-ink-mid placeholder-zinc-500 focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/30 transition-colors"
/> />
<p className="text-xs text-ink-mid">Leave blank for unlimited</p> <p className="text-xs text-ink-soft">Leave blank for unlimited</p>
{saveError && ( {saveError && (
<div <div

View File

@ -242,7 +242,7 @@ export function ChannelsTab({ workspaceId }: Props) {
if (loading) { if (loading) {
return ( return (
<div className="p-4 text-ink-mid text-xs">Loading channels...</div> <div className="p-4 text-ink-soft text-xs">Loading channels...</div>
); );
} }
@ -271,7 +271,7 @@ export function ChannelsTab({ workspaceId }: Props) {
{showForm && ( {showForm && (
<div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50"> <div className="space-y-2 p-3 bg-surface-card/40 rounded border border-line/50">
<div> <div>
<label htmlFor={platformId} className="text-[10px] text-ink-mid block mb-1">Platform</label> <label htmlFor={platformId} className="text-[10px] text-ink-soft block mb-1">Platform</label>
<select <select
id={platformId} id={platformId}
value={formType} value={formType}
@ -327,7 +327,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className="rounded border-line" className="rounded border-line"
/> />
<span className="text-xs text-ink-mid">{chat.name || "Unknown"}</span> <span className="text-xs text-ink-mid">{chat.name || "Unknown"}</span>
<span className="text-[10px] text-ink-mid ml-auto">{chat.type} {chat.chat_id}</span> <span className="text-[10px] text-ink-soft ml-auto">{chat.type} {chat.chat_id}</span>
</label> </label>
))} ))}
<button <button
@ -347,8 +347,8 @@ export function ChannelsTab({ workspaceId }: Props) {
)} )}
<div> <div>
<label htmlFor={allowedUsersId} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={allowedUsersId} className="text-[10px] text-ink-soft block mb-1">
Allowed Users <span className="text-ink-mid">(optional, comma-separated)</span> Allowed Users <span className="text-ink-soft">(optional, comma-separated)</span>
</label> </label>
<input <input
id={allowedUsersId} id={allowedUsersId}
@ -357,7 +357,7 @@ export function ChannelsTab({ workspaceId }: Props) {
placeholder="123456789, 987654321" placeholder="123456789, 987654321"
className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600" className="w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600"
/> />
<p className="text-[11px] text-ink-mid mt-0.5"> <p className="text-[11px] text-ink-soft mt-0.5">
Platform-specific user IDs. Leave empty to allow everyone. Platform-specific user IDs. Leave empty to allow everyone.
</p> </p>
</div> </div>
@ -380,8 +380,8 @@ export function ChannelsTab({ workspaceId }: Props) {
{/* Channel list */} {/* Channel list */}
{channels.length === 0 && !showForm && ( {channels.length === 0 && !showForm && (
<div className="text-center py-8"> <div className="text-center py-8">
<p className="text-ink-mid text-xs">No channels connected</p> <p className="text-ink-soft text-xs">No channels connected</p>
<p className="text-ink-mid text-[10px] mt-1"> <p className="text-ink-soft text-[10px] mt-1">
Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms. Connect Telegram, Slack, Discord, or Lark / Feishu to chat with this agent from social platforms.
</p> </p>
</div> </div>
@ -402,7 +402,7 @@ export function ChannelsTab({ workspaceId }: Props) {
<span className="text-xs font-medium text-ink"> <span className="text-xs font-medium text-ink">
{ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)} {ch.channel_type.charAt(0).toUpperCase() + ch.channel_type.slice(1)}
</span> </span>
<span className="text-[10px] text-ink-mid"> <span className="text-[10px] text-ink-soft">
{ch.config.chat_id || ch.config.channel_id || ""} {ch.config.chat_id || ch.config.channel_id || ""}
</span> </span>
</div> </div>
@ -419,7 +419,7 @@ export function ChannelsTab({ workspaceId }: Props) {
className={`text-[10px] px-2 py-0.5 rounded transition ${ className={`text-[10px] px-2 py-0.5 rounded transition ${
ch.enabled ch.enabled
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50" ? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
: "bg-surface-card/50 text-ink-mid hover:text-ink-mid" : "bg-surface-card/50 text-ink-soft hover:text-ink-mid"
}`} }`}
> >
{ch.enabled ? "On" : "Off"} {ch.enabled ? "On" : "Off"}
@ -432,7 +432,7 @@ export function ChannelsTab({ workspaceId }: Props) {
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 text-[10px] text-ink-mid"> <div className="flex items-center gap-4 text-[10px] text-ink-soft">
<span>{ch.message_count} messages</span> <span>{ch.message_count} messages</span>
<span>Last: {relativeTime(ch.last_message_at)}</span> <span>Last: {relativeTime(ch.last_message_at)}</span>
{ch.allowed_users.length > 0 && ( {ch.allowed_users.length > 0 && (
@ -474,9 +474,9 @@ function SchemaField({
"w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600"; "w-full text-xs bg-surface-sunken border border-line rounded px-2 py-1.5 text-ink-mid placeholder-zinc-600";
return ( return (
<div> <div>
<label htmlFor={inputId} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={inputId} className="text-[10px] text-ink-soft block mb-1">
{field.label} {field.label}
{!field.required && <span className="text-ink-mid"> (optional)</span>} {!field.required && <span className="text-ink-soft"> (optional)</span>}
</label> </label>
{field.type === "textarea" ? ( {field.type === "textarea" ? (
<textarea <textarea
@ -499,7 +499,7 @@ function SchemaField({
)} )}
{renderExtras?.()} {renderExtras?.()}
{field.help && ( {field.help && (
<p className="text-[11px] text-ink-mid mt-0.5">{field.help}</p> <p className="text-[11px] text-ink-soft mt-0.5">{field.help}</p>
)} )}
</div> </div>
); );

View File

@ -13,6 +13,7 @@ import { AttachmentPreview } from "./chat/AttachmentPreview";
import { extractFilesFromTask } from "./chat/message-parser"; import { extractFilesFromTask } from "./chat/message-parser";
import { AgentCommsPanel } from "./chat/AgentCommsPanel"; import { AgentCommsPanel } from "./chat/AgentCommsPanel";
import { appendActivityLine } from "./chat/activityLog"; import { appendActivityLine } from "./chat/activityLog";
import { activityRowToMessages, type ActivityRowForHydration } from "./chat/historyHydration";
import { runtimeDisplayName } from "@/lib/runtime-names"; import { runtimeDisplayName } from "@/lib/runtime-names";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
@ -49,12 +50,38 @@ interface A2AResponse {
}; };
} }
// Internal-self-message filtering moved server-side in RFC #2945 /** Detect activity-log rows that the workspace's own runtime fired
// PR-C/D — the platform's /chat-history endpoint applies the * against itself but were misclassified as canvas-source. The proper
// IsInternalSelfMessage predicate before returning rows, so the * fix is the X-Workspace-ID header from `self_source_headers()` in
// client no longer needs the local backstop on the history path. * workspace/platform_auth.py, which makes the platform record
// The proper fix is still X-Workspace-ID header (source_id=workspace_id); * source_id = workspace_id. But three failure modes still leak a
// the platform-side prefix filter handles the residual cases. * self-message into "My Chat":
*
* 1. Historical rows already in the DB with source_id=NULL.
* 2. Workspace containers running pre-fix heartbeat.py / main.py
* (the fix only takes effect after an image rebuild + redeploy).
* 3. Future internal triggers added without the helper.
*
* This client-side filter recognises the heartbeat trigger by its
* exact prefix the heartbeat assembles
*
* "Delegation results are ready. Review them and take appropriate
* action:\n" + summary_lines + report_instruction
*
* in workspace/heartbeat.py. The prefix is template-fixed so a
* string match is reliable. If the heartbeat copy ever changes,
* update this constant in the same commit.
*
* This is a backstop, not the primary defence the X-Workspace-ID
* header is. Filtering content is fragile to copy edits, so keep
* the list narrow. */
const INTERNAL_SELF_MESSAGE_PREFIXES = [
"Delegation results are ready. Review them and take appropriate action",
];
function isInternalSelfMessage(text: string): boolean {
return INTERNAL_SELF_MESSAGE_PREFIXES.some((p) => text.startsWith(p));
}
// extractReplyText pulls the agent's text reply out of an A2A response. // extractReplyText pulls the agent's text reply out of an A2A response.
// Concatenates ALL text parts (joined with "\n") rather than returning // Concatenates ALL text parts (joined with "\n") rather than returning
@ -107,19 +134,8 @@ const INITIAL_HISTORY_LIMIT = 10;
const OLDER_HISTORY_BATCH = 20; const OLDER_HISTORY_BATCH = 20;
/** /**
* Load chat history from the platform's typed /chat-history endpoint. * Load chat history from the activity_logs database via the platform API.
* * Uses source=canvas to only get user-initiated messages (not agent-to-agent).
* Server-side rendering of activity_logs rows into ChatMessage shape
* lives in workspace-server/internal/messagestore/postgres_store.go
* (RFC #2945 PR-C/D). The server already applies the canvas-source
* filter, the internal-self-message predicate, the role decision
* (status=error vs agent-error prefix system), and the v0/v1
* file-shape extraction. Canvas just renders what it receives.
*
* Wire shape (mirrors ChatMessage exactly, no per-row mapping needed):
*
* GET /workspaces/:id/chat-history?limit=N&before_ts=T
* 200 {"messages": ChatMessage[], "reached_end": boolean}
* *
* Pagination: * Pagination:
* - Pass `limit` to bound the page size (newest-first from server). * - Pass `limit` to bound the page size (newest-first from server).
@ -127,10 +143,10 @@ const OLDER_HISTORY_BATCH = 20;
* timestamp. Combined with limit, this yields the next-older page * timestamp. Combined with limit, this yields the next-older page
* when scrolling backward through history. * when scrolling backward through history.
* *
* `reachedEnd` is propagated from the server. The server computes it * `reachedEnd` is true when the server returned fewer rows than asked
* by comparing rowCount vs limit so a partial last page is correctly * for caller uses this to disable further older-batch fetches.
* detected even when the rowbubble fan-out is non-1:1 (each row * (Counts row-level returns, not chat-bubble count: each row may
* produces 1-2 bubbles). * produce 1-2 bubbles.)
*/ */
async function loadMessagesFromDB( async function loadMessagesFromDB(
workspaceId: string, workspaceId: string,
@ -138,23 +154,25 @@ async function loadMessagesFromDB(
beforeTs?: string, beforeTs?: string,
): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> { ): Promise<{ messages: ChatMessage[]; error: string | null; reachedEnd: boolean }> {
try { try {
const params = new URLSearchParams({ limit: String(limit) }); const params = new URLSearchParams({
type: "a2a_receive",
source: "canvas",
limit: String(limit),
});
if (beforeTs) params.set("before_ts", beforeTs); if (beforeTs) params.set("before_ts", beforeTs);
const resp = await api.get<{ messages: ChatMessage[]; reached_end: boolean }>( const activities = await api.get<ActivityRowForHydration[]>(
`/workspaces/${workspaceId}/chat-history?${params.toString()}`, `/workspaces/${workspaceId}/activity?${params.toString()}`,
); );
// Server emits oldest-first within the page (RFC #2945 PR-C-2 const messages: ChatMessage[] = [];
// post-fix: server reverses row-aware before returning so the // Activities are newest-first, reverse for chronological order.
// wire is display-ready). Canvas appends/prepends without // Per-row mapping lives in chat/historyHydration.ts so it can be
// reordering — this avoids the pair-flip bug a naive flat // unit-tested without spinning up the full ChatTab component
// reverse causes when each row produces a (user, agent) pair // (regression cover for the timestamp-collapse bug).
// with the same timestamp. for (const a of [...activities].reverse()) {
return { messages.push(...activityRowToMessages(a, isInternalSelfMessage));
messages: resp.messages ?? [], }
error: null, return { messages, error: null, reachedEnd: activities.length < limit };
reachedEnd: resp.reached_end,
};
} catch (err) { } catch (err) {
return { return {
messages: [], messages: [],
@ -965,7 +983,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{/* Messages */} {/* Messages */}
<div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3"> <div ref={containerRef} className="flex-1 overflow-y-auto p-3 space-y-3">
{loading && ( {loading && (
<div className="text-xs text-ink-mid text-center py-4">Loading chat history...</div> <div className="text-xs text-ink-soft text-center py-4">Loading chat history...</div>
)} )}
{!loading && loadError !== null && messages.length === 0 && ( {!loading && loadError !== null && messages.length === 0 && (
<div <div
@ -984,7 +1002,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
</div> </div>
)} )}
{!loading && loadError === null && messages.length === 0 && ( {!loading && loadError === null && messages.length === 0 && (
<div className="text-xs text-ink-mid text-center py-8"> <div className="text-xs text-ink-soft text-center py-8">
No messages yet. Send a message to start chatting with this agent. No messages yet. Send a message to start chatting with this agent.
</div> </div>
)} )}
@ -1002,7 +1020,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
scroll resting against the top of the conversation IS the scroll resting against the top of the conversation IS the
signal. */} signal. */}
{hasMore && messages.length > 0 && ( {hasMore && messages.length > 0 && (
<div ref={topRef} className="text-xs text-ink-mid text-center py-1"> <div ref={topRef} className="text-xs text-ink-soft text-center py-1">
{loadingOlder ? "Loading older messages…" : " "} {loadingOlder ? "Loading older messages…" : " "}
</div> </div>
)} )}
@ -1153,7 +1171,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
{thinkingElapsed}s {thinkingElapsed}s
</div> </div>
{activityLog.length > 0 && ( {activityLog.length > 0 && (
<div className="mt-1.5 text-[9px] text-ink-mid space-y-0.5"> <div className="mt-1.5 text-[9px] text-ink-soft space-y-0.5">
<div className="text-ink-mid">Processing with {runtimeDisplayName(data.runtime)}...</div> <div className="text-ink-mid">Processing with {runtimeDisplayName(data.runtime)}...</div>
{activityLog.map((line, i) => ( {activityLog.map((line, i) => (
<div key={line + i} className="pl-2 border-l border-line"> {line}</div> <div key={line + i} className="pl-2 border-l border-line"> {line}</div>

View File

@ -21,39 +21,20 @@ interface Props {
// --- Agent Card Section --- // --- Agent Card Section ---
function AgentCardSection({ workspaceId }: { workspaceId: string }) { function AgentCardSection({ workspaceId }: { workspaceId: string }) {
// Initial card value comes from the canvas store — node.data.agentCard const [card, setCard] = useState<Record<string, unknown> | null>(null);
// is hydrated by the platform stream when the workspace appears in the const [loading, setLoading] = useState(true);
// graph, so reading it here avoids a duplicate `GET /workspaces/${id}`
// (the parent ConfigTab.loadConfig already fetches workspace metadata,
// and refetching here adds a serialised RTT to the panel-open path —
// contributed to the ~20s detail-panel load reported in core#11).
// Local state still tracks the edited/saved value so the editor flow
// is unchanged.
const storeCard = useCanvasStore((s) => {
// Defensive against test mocks that omit `nodes` (some test files
// stub the store with a minimal shape). In production `nodes` is
// always an array — empty or not — so the optional chaining only
// matters for the test path.
const node = s.nodes?.find?.((n) => n.id === workspaceId);
return (node?.data.agentCard as
| Record<string, unknown>
| null
| undefined) ?? null;
});
const [card, setCard] = useState<Record<string, unknown> | null>(storeCard);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
// If the store updates while this section is mounted (another tab
// pushed an update via the platform event stream), reflect that —
// unless the user is mid-edit, in which case we don't clobber their
// unsaved draft.
useEffect(() => { useEffect(() => {
if (!editing) setCard(storeCard); api.get<Record<string, unknown>>(`/workspaces/${workspaceId}`)
}, [storeCard, editing]); .then((ws) => setCard((ws.agent_card as Record<string, unknown>) || null))
.catch(() => {})
.finally(() => setLoading(false));
}, [workspaceId]);
const handleSave = async () => { const handleSave = async () => {
setError(null); setError(null);
@ -72,7 +53,9 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
return ( return (
<Section title="Agent Card" defaultOpen={false}> <Section title="Agent Card" defaultOpen={false}>
{editing ? ( {loading ? (
<div className="text-[10px] text-ink-soft">Loading...</div>
) : editing ? (
<div className="space-y-2"> <div className="space-y-2">
<textarea <textarea
aria-label="Agent card JSON editor" aria-label="Agent card JSON editor"
@ -97,7 +80,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
{JSON.stringify(card, null, 2)} {JSON.stringify(card, null, 2)}
</pre> </pre>
) : ( ) : (
<div className="text-[10px] text-ink-mid">No agent card</div> <div className="text-[10px] text-ink-soft">No agent card</div>
)} )}
{success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>} {success && <div className="mt-2 px-2 py-1 bg-green-900/30 border border-green-800 rounded text-[10px] text-good">Updated</div>}
<button type="button" onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }} <button type="button" onClick={() => { setDraft(JSON.stringify(card || {}, null, 2)); setEditing(true); setError(null); setSuccess(false); }}
@ -238,51 +221,47 @@ export function ConfigTab({ workspaceId }: Props) {
setLoading(true); setLoading(true);
setError(null); setError(null);
// Load workspace metadata (runtime + model + provider) in parallel. // ALWAYS load workspace metadata first (runtime + model). These are the
// These are independent GETs against three workspace-server endpoints // source of truth regardless of whether the runtime uses our config.yaml
// and used to be awaited serially — for SaaS workspaces each call // template. Without this the form falls back to empty/default values on
// round-trips through an EIC SSH tunnel, so the previous serial // a hermes workspace (which doesn't use our template), creating the
// pattern stacked 3-5s of tunnel-setup latency per call (core#11). // appearance that the saved runtime is unset — and worse, clicking Save
// Promise.all overlaps them; the per-call cost stays the same but // would silently flip `runtime` from `hermes` back to the dropdown
// wall time drops to max() instead of sum(). // default `LangGraph`. See GH #1894.
// let wsMetadataRuntime = "";
// Each leg has its own .catch handler that yields a sentinel value, let wsMetadataModel = "";
// matching the previous semantics: let wsMetadataTier: number | null = null;
// - /workspaces/${id}: required source-of-truth for runtime+tier; try {
// fall back to YAML if the GET fails (rare, network-class only). const ws = await api.get<{ runtime?: string; tier?: number }>(`/workspaces/${workspaceId}`);
// - /workspaces/${id}/model: non-fatal; empty model lets the form wsMetadataRuntime = (ws.runtime || "").trim();
// fall through to YAML runtime_config.model. if (typeof ws.tier === "number") wsMetadataTier = ws.tier;
// - /workspaces/${id}/provider: non-fatal; old workspace-servers } catch { /* fall back to config.yaml */ }
// return 404, in which case provider="" and Save skips the PUT. try {
// const m = await api.get<{ model?: string }>(`/workspaces/${workspaceId}/model`);
// See GH #1894 for the workspace-row-as-source-of-truth rationale wsMetadataModel = (m.model || "").trim();
// that motivated splitting from a single config.yaml read. } catch { /* non-fatal */ }
const [wsRes, modelRes, providerRes] = await Promise.all([
api.get<{ runtime?: string; tier?: number }>(`/workspaces/${workspaceId}`)
.catch(() => ({} as { runtime?: string; tier?: number })),
api.get<{ model?: string }>(`/workspaces/${workspaceId}/model`)
.catch(() => ({} as { model?: string })),
api.get<{ provider?: string }>(`/workspaces/${workspaceId}/provider`)
.catch(() => null),
]);
const wsMetadataRuntime = (wsRes.runtime || "").trim();
const wsMetadataModel = (modelRes.model || "").trim();
const wsMetadataTier: number | null =
typeof wsRes.tier === "number" ? wsRes.tier : null;
if (providerRes !== null) {
const loadedProvider = (providerRes.provider || "").trim();
setProvider(loadedProvider);
setOriginalProvider(loadedProvider);
} else {
setProvider("");
setOriginalProvider("");
}
// originalModel is set further down once the YAML has been parsed — // originalModel is set further down once the YAML has been parsed —
// we want it to reflect what the form ACTUALLY rendered, which may // we want it to reflect what the form ACTUALLY rendered, which may
// be the YAML's runtime_config.model fallback when MODEL_PROVIDER // be the YAML's runtime_config.model fallback when MODEL_PROVIDER
// is empty. Setting it here from wsMetadataModel alone would be // is empty. Setting it here from wsMetadataModel alone would be
// wrong for hermes/pre-#240 workspaces. // wrong for hermes/pre-#240 workspaces.
// Load explicit provider override (Option B PR-5). Endpoint returns
// {provider: "", source: "default"} when no override is set, so the
// empty string is the legitimate "auto-derive" signal — don't treat
// it as a load error. Non-fatal: an older workspace-server that
// predates PR-2 returns 404 here; the form falls back to "" and
// Save just won't PUT the provider field.
try {
const p = await api.get<{ provider?: string }>(`/workspaces/${workspaceId}/provider`);
const loadedProvider = (p.provider || "").trim();
setProvider(loadedProvider);
setOriginalProvider(loadedProvider);
} catch {
setProvider("");
setOriginalProvider("");
}
// Skip the config.yaml fetch entirely for runtimes that manage // Skip the config.yaml fetch entirely for runtimes that manage
// their own config (external, hermes, etc.) — they don't have a // their own config (external, hermes, etc.) — they don't have a
// platform-side template, so the GET would 404. The catch block // platform-side template, so the GET would 404. The catch block
@ -635,16 +614,16 @@ export function ConfigTab({ workspaceId }: Props) {
const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty; const isDirty = (rawMode ? rawDraft !== originalYaml : toYaml(config) !== originalYaml) || providerDirty;
if (loading) { if (loading) {
return <div className="p-4 text-xs text-ink-mid">Loading config...</div>; return <div className="p-4 text-xs text-ink-soft">Loading config...</div>;
} }
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Mode toggle */} {/* Mode toggle */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/30"> <div className="flex items-center justify-between px-3 py-1.5 border-b border-line/40 bg-surface-sunken/30">
<span className="text-[10px] text-ink-mid">config.yaml</span> <span className="text-[10px] text-ink-soft">config.yaml</span>
<label className="flex items-center gap-1.5 cursor-pointer"> <label className="flex items-center gap-1.5 cursor-pointer">
<span className="text-[9px] text-ink-mid">Raw YAML</span> <span className="text-[9px] text-ink-soft">Raw YAML</span>
<input <input
type="checkbox" type="checkbox"
checked={rawMode} checked={rawMode}
@ -677,7 +656,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="General"> <Section title="General">
<TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} /> <TextInput label="Name" value={config.name} onChange={(v) => update("name", v)} />
<div> <div>
<label htmlFor={descriptionId} className="text-[10px] text-ink-mid block mb-1">Description</label> <label htmlFor={descriptionId} className="text-[10px] text-ink-soft block mb-1">Description</label>
<textarea <textarea
id={descriptionId} id={descriptionId}
value={config.description} value={config.description}
@ -689,7 +668,7 @@ export function ConfigTab({ workspaceId }: Props) {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono /> <TextInput label="Version" value={config.version} onChange={(v) => update("version", v)} mono />
<div> <div>
<label htmlFor={tierId} className="text-[10px] text-ink-mid block mb-1">Tier</label> <label htmlFor={tierId} className="text-[10px] text-ink-soft block mb-1">Tier</label>
<select <select
id={tierId} id={tierId}
value={config.tier} value={config.tier}
@ -707,7 +686,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Runtime"> <Section title="Runtime">
<div> <div>
<label htmlFor={runtimeId} className="text-[10px] text-ink-mid block mb-1">Runtime</label> <label htmlFor={runtimeId} className="text-[10px] text-ink-soft block mb-1">Runtime</label>
<select <select
id={runtimeId} id={runtimeId}
value={config.runtime || ""} value={config.runtime || ""}
@ -791,7 +770,7 @@ export function ConfigTab({ workspaceId }: Props) {
// workspace_secrets MODEL_PROVIDER override. // workspace_secrets MODEL_PROVIDER override.
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
<label className="text-[10px] text-ink-mid block mb-1">Model</label> <label className="text-[10px] text-ink-soft block mb-1">Model</label>
<input <input
type="text" type="text"
value={currentModelId} value={currentModelId}
@ -808,9 +787,9 @@ export function ConfigTab({ workspaceId }: Props) {
/> />
</div> </div>
<div> <div>
<label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={`${runtimeId}-provider`} className="text-[10px] text-ink-soft block mb-1">
Provider Provider
<span className="ml-1 text-ink-mid"> <span className="ml-1 text-ink-soft">
(override leave empty to auto-derive from model slug) (override leave empty to auto-derive from model slug)
</span> </span>
</label> </label>
@ -859,7 +838,7 @@ export function ConfigTab({ workspaceId }: Props) {
onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)} onChange={(v) => updateNested("runtime_config" as keyof ConfigData, "required_env", v)}
placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value" placeholder="variable NAME (e.g. ANTHROPIC_API_KEY) — not the value"
/> />
<p className="text-[10px] text-ink-mid mt-1"> <p className="text-[10px] text-ink-soft mt-1">
This declares which env var <em>names</em> the workspace needs. This declares which env var <em>names</em> the workspace needs.
Set the actual values in the <strong>Secrets</strong> section Set the actual values in the <strong>Secrets</strong> section
below those are encrypted and mounted into the container at below those are encrypted and mounted into the container at
@ -867,7 +846,7 @@ export function ConfigTab({ workspaceId }: Props) {
</p> </p>
{currentModelSpec?.required_env?.length && {currentModelSpec?.required_env?.length &&
!arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && ( !arraysEqual(config.runtime_config?.required_env ?? [], currentModelSpec.required_env) && (
<div className="text-[10px] text-ink-mid mt-1 flex items-center gap-2"> <div className="text-[10px] text-ink-soft mt-1 flex items-center gap-2">
<span> <span>
Template suggests{" "} Template suggests{" "}
<code className="text-ink-mid">{currentModelSpec.required_env.join(", ")}</code>{" "} <code className="text-ink-mid">{currentModelSpec.required_env.join(", ")}</code>{" "}
@ -890,9 +869,9 @@ export function ConfigTab({ workspaceId }: Props) {
(config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && ( (config.runtime_config?.model || config.model || "").toLowerCase().includes("anthropic")) && (
<Section title="Claude Settings" defaultOpen={false}> <Section title="Claude Settings" defaultOpen={false}>
<div> <div>
<label htmlFor={effortId} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={effortId} className="text-[10px] text-ink-soft block mb-1">
Effort Effort
<span className="ml-1 text-ink-mid">(output_config.effort Opus 4.7+)</span> <span className="ml-1 text-ink-soft">(output_config.effort Opus 4.7+)</span>
</label> </label>
<select <select
id={effortId} id={effortId}
@ -910,9 +889,9 @@ export function ConfigTab({ workspaceId }: Props) {
</select> </select>
</div> </div>
<div> <div>
<label htmlFor={taskBudgetId} className="text-[10px] text-ink-mid block mb-1"> <label htmlFor={taskBudgetId} className="text-[10px] text-ink-soft block mb-1">
Task Budget (tokens) Task Budget (tokens)
<span className="ml-1 text-ink-mid">(output_config.task_budget.total 0 = unset)</span> <span className="ml-1 text-ink-soft">(output_config.task_budget.total 0 = unset)</span>
</label> </label>
<input <input
id={taskBudgetId} id={taskBudgetId}
@ -938,7 +917,7 @@ export function ConfigTab({ workspaceId }: Props) {
showing the misnamed list-input affordance. */} showing the misnamed list-input affordance. */}
<Section title="Prompt Files" defaultOpen={false}> <Section title="Prompt Files" defaultOpen={false}>
<p className="text-[10px] text-ink-mid px-1 pb-1"> <p className="text-[10px] text-ink-soft px-1 pb-1">
Markdown files that compose this workspace&apos;s system prompt. Markdown files that compose this workspace&apos;s system prompt.
Loaded in order at boot from the workspace config dir Loaded in order at boot from the workspace config dir
(e.g. <code className="font-mono">system-prompt.md</code>,{' '} (e.g. <code className="font-mono">system-prompt.md</code>,{' '}
@ -966,7 +945,7 @@ export function ConfigTab({ workspaceId }: Props) {
<Section title="Sandbox" defaultOpen={false}> <Section title="Sandbox" defaultOpen={false}>
<div> <div>
<label htmlFor={sandboxBackendId} className="text-[10px] text-ink-mid block mb-1">Backend</label> <label htmlFor={sandboxBackendId} className="text-[10px] text-ink-soft block mb-1">Backend</label>
<select <select
id={sandboxBackendId} id={sandboxBackendId}
value={config.sandbox?.backend || "docker"} value={config.sandbox?.backend || "docker"}

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