diff --git a/.gitea/scripts/sop-tier-check.sh b/.gitea/scripts/sop-tier-check.sh index 3a8964e6..c7b2c820 100755 --- a/.gitea/scripts/sop-tier-check.sh +++ b/.gitea/scripts/sop-tier-check.sh @@ -285,12 +285,26 @@ _passed_clauses="" _failed_clauses="" for _raw_clause in $EXPR; do - # Normalise: strip parens, split on comma, trim whitespace. - _clause=$(echo "$_raw_clause" | tr -d '()' | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$') + # 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 - _clause_names="${_clause_names:+, }${_t}" + # 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 @@ -311,11 +325,12 @@ for _raw_clause in $EXPR; do _label=$(echo "$_raw_clause" | tr -d '()' | tr ',' '/' | tr -d '[:space:]' | sed 's/???//g') if [ "$_clause_passed" = "yes" ]; then - _passed_clauses="${_passed_clauses:+, }$_label" + # 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:+, }$_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." + _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 diff --git a/.gitea/scripts/tests/test_sop_tier_check_clause_split.sh b/.gitea/scripts/tests/test_sop_tier_check_clause_split.sh new file mode 100755 index 00000000..3671faba --- /dev/null +++ b/.gitea/scripts/tests/test_sop_tier_check_clause_split.sh @@ -0,0 +1,101 @@ +#!/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 ] diff --git a/.gitea/workflows/publish-workspace-server-image.yml b/.gitea/workflows/publish-workspace-server-image.yml new file mode 100644 index 00000000..96a03b7e --- /dev/null +++ b/.gitea/workflows/publish-workspace-server-image.yml @@ -0,0 +1,155 @@ +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- — 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: [staging, 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- 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 + + # 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- + 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- + 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}" diff --git a/.github/workflows/publish-runtime.yml b/.github/workflows/publish-runtime.yml index 53a19d19..6118c113 100644 --- a/.github/workflows/publish-runtime.yml +++ b/.github/workflows/publish-runtime.yml @@ -180,7 +180,7 @@ jobs: # environment pypi-publish. The action mints a short-lived OIDC # token and exchanges it for a PyPI upload credential — no static # API token in this repo's secrets. - uses: pypa/gh-action-pypi-publish@release/v1 + uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1 with: packages-dir: ${{ runner.temp }}/runtime-build/dist/ diff --git a/.github/workflows/secret-pattern-drift.yml b/.github/workflows/secret-pattern-drift.yml index fa7fffa8..2517fea9 100644 --- a/.github/workflows/secret-pattern-drift.yml +++ b/.github/workflows/secret-pattern-drift.yml @@ -48,7 +48,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 41dd9f80..63afe664 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -13,7 +13,8 @@ interface Props { onClose: () => void; } -function extractMessageText(body: Record | null): string { +/** Exported for unit testing — see ConversationTraceModal.test.ts */ +export function extractMessageText(body: Record | null): string { if (!body) return ""; try { // Simple task format from MCP server: {task: "..."} diff --git a/canvas/src/components/Toolbar.tsx b/canvas/src/components/Toolbar.tsx index b81d8b56..01bddc3b 100644 --- a/canvas/src/components/Toolbar.tsx +++ b/canvas/src/components/Toolbar.tsx @@ -317,7 +317,7 @@ export function Toolbar() { 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" aria-expanded={helpOpen} - aria-label="Open quick help" + aria-label="Open shortcuts and tips" title="Help — shortcuts & quick start" >
-
- Quick start +
+
+ Shortcuts & tips
-
+
+ + + + + + - - - + +
{/* Link to the full keyboard shortcuts dialog */}