diff --git a/.github/workflows/auto-promote-staging.yml b/.github/workflows/auto-promote-staging.yml index a62010f2..de6ce46a 100644 --- a/.github/workflows/auto-promote-staging.yml +++ b/.github/workflows/auto-promote-staging.yml @@ -364,3 +364,21 @@ jobs: else echo "::error::Failed to dispatch publish-workspace-server-image. Run manually: gh workflow run publish-workspace-server-image.yml --ref main" fi + + # ALSO dispatch auto-sync-main-to-staging.yml. Same root cause as + # publish above (issue #2357): the merge-queue-initiated push to + # main is by GITHUB_TOKEN → no `on: push` triggers fire downstream. + # Without this dispatch, every staging→main promote leaves staging + # one merge commit BEHIND main, which silently dead-locks the NEXT + # promote PR as `mergeStateStatus: BEHIND` because main's + # branch-protection has `strict: true`. Verified empirically on + # 2026-05-02 against PR #2442 (Phase 2 promote): only the explicit + # publish-workspace-server-image dispatch fired on the previous + # promote SHA 76c604fb, while auto-sync silently no-op'd, leaving + # staging behind for ~24h until manually bridged. + if gh workflow run auto-sync-main-to-staging.yml \ + --repo "$REPO" --ref main 2>&1; then + echo "::notice::Dispatched auto-sync-main-to-staging on ref=main as molecule-ai App — staging will absorb the new main merge commit via PR + merge queue." + else + echo "::error::Failed to dispatch auto-sync-main-to-staging. Run manually: gh workflow run auto-sync-main-to-staging.yml --ref main" + fi diff --git a/.github/workflows/auto-sync-main-to-staging.yml b/.github/workflows/auto-sync-main-to-staging.yml index 36ab63f7..9a0140d7 100644 --- a/.github/workflows/auto-sync-main-to-staging.yml +++ b/.github/workflows/auto-sync-main-to-staging.yml @@ -60,6 +60,24 @@ name: Auto-sync main → staging on: push: branches: [main] + # workflow_dispatch lets: + # 1. Operators manually backfill a missed sync (e.g. after a manual + # UI merge that the runner missed). + # 2. auto-promote-staging.yml's polling tail explicitly invoke us + # after the promote PR lands. This is load-bearing: when the + # merge queue lands a promote-PR merge, the resulting push to + # `main` is "by GITHUB_TOKEN", and per GitHub's no-recursion + # rule (https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#triggering-a-workflow-from-a-workflow) + # that push event does NOT fire any downstream workflows. The + # `on: push` trigger above is silently dead for the very pattern + # we exist to handle. Verified empirically 2026-05-02 against + # SHA 76c604fb (PR #2437 staging→main): only ONE workflow fired + # (publish-workspace-server-image, dispatched explicitly by + # auto-promote's polling tail with an App token). Every other + # `on: push: branches: [main]` workflow — including this one — + # was suppressed. Until the underlying merge call moves to an + # App token, an explicit dispatch is the only reliable path. + workflow_dispatch: permissions: contents: write @@ -71,8 +89,14 @@ concurrency: jobs: sync-staging: - # Self-hosted Mac mini matches the rest of this repo's workflows. - runs-on: [self-hosted, macos, arm64] + # ubuntu-latest matches every other workflow in this repo. The + # earlier `[self-hosted, macos, arm64]` was a copy-paste artefact + # from the molecule-controlplane repo (which IS private and uses a + # Mac runner) — molecule-core has no Mac runner registered, so the + # job sat unassigned whenever the trigger fired. Verified 2026-05-02: + # this is the ONLY workflow in molecule-core/.github/workflows/ with + # a non-ubuntu runs-on. + runs-on: ubuntu-latest steps: - name: Checkout staging uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/.github/workflows/harness-replays.yml b/.github/workflows/harness-replays.yml index 6330e885..fc642ba4 100644 --- a/.github/workflows/harness-replays.yml +++ b/.github/workflows/harness-replays.yml @@ -106,16 +106,6 @@ jobs: path: molecule-ai-plugin-github-app-auth token: ${{ secrets.PLUGIN_REPO_PAT || secrets.GITHUB_TOKEN }} - - name: Add /etc/hosts entry for harness-tenant.localhost - # ubuntu-latest doesn't auto-resolve *.localhost the way macOS - # sometimes does. seed.sh + replay scripts curl - # http://harness-tenant.localhost:8080 — without the entry - # they'd fail with getaddrinfo ENOTFOUND. - if: needs.detect-changes.outputs.run == 'true' - run: | - echo "127.0.0.1 harness-tenant.localhost" | sudo tee -a /etc/hosts >/dev/null - getent hosts harness-tenant.localhost - - name: Install Python deps for replays # peer-discovery-404 (and future replays) eval Python against the # running tenant — importing workspace/a2a_client.py pulls in @@ -144,19 +134,32 @@ jobs: run: ./run-all-replays.sh - name: Dump compose logs on failure + # SECRETS_ENCRYPTION_KEY: docker compose validates the entire compose + # file even for read-only `logs` calls. up.sh generates a per-run key + # and exports it to its OWN shell — this step runs in a fresh shell + # that wouldn't see it, so without a placeholder the validate step + # errors before logs print (verified against PR #2492's first run: + # "required variable SECRETS_ENCRYPTION_KEY is missing a value"). + # A placeholder is fine — we're only reading log streams, not booting. if: failure() && needs.detect-changes.outputs.run == 'true' working-directory: tests/harness + env: + SECRETS_ENCRYPTION_KEY: dump-logs-placeholder run: | echo "=== docker compose ps ===" docker compose -f compose.yml ps || true - echo "=== tenant logs ===" - docker compose -f compose.yml logs tenant || true + echo "=== tenant-alpha logs ===" + docker compose -f compose.yml logs tenant-alpha || true + echo "=== tenant-beta logs ===" + docker compose -f compose.yml logs tenant-beta || true echo "=== cp-stub logs ===" docker compose -f compose.yml logs cp-stub || true echo "=== cf-proxy logs ===" docker compose -f compose.yml logs cf-proxy || true - echo "=== postgres logs (last 100) ===" - docker compose -f compose.yml logs --tail 100 postgres || true + echo "=== postgres-alpha logs (last 100) ===" + docker compose -f compose.yml logs --tail 100 postgres-alpha || true + echo "=== postgres-beta logs (last 100) ===" + docker compose -f compose.yml logs --tail 100 postgres-beta || true - name: Force teardown # We pass KEEP_UP=1 to run-all-replays.sh so the dump step diff --git a/.github/workflows/runtime-prbuild-compat.yml b/.github/workflows/runtime-prbuild-compat.yml index 96f1a289..0bc9a511 100644 --- a/.github/workflows/runtime-prbuild-compat.yml +++ b/.github/workflows/runtime-prbuild-compat.yml @@ -23,55 +23,88 @@ name: Runtime PR-Built Compatibility # # By building from the PR's source and smoke-importing THAT wheel, we # fail at PR-time instead of after publish. +# +# Required-check shape (2026-05-01): the workflow runs on EVERY push + +# PR + merge_group event with no top-level `paths:` filter, then uses a +# detect-changes job + per-step `if:` gates inside ONE always-running +# job named `PR-built wheel + import smoke`. PRs that don't touch +# wheel-relevant paths get a no-op SUCCESS check run, satisfying branch +# protection without re-running the heavy build. Same pattern as +# e2e-api.yml — see its comment for the full rationale + the 2026-04-29 +# PR #2264 incident that motivated the always-run-with-if-gates shape. on: push: branches: [main, staging] - paths: - # Broad filter: this workflow's verdict can change whenever any - # workspace/ source file changes (because the wheel we build is - # produced from those files), or when the build script itself - # changes (it controls the wheel layout). - - 'workspace/**' - - 'scripts/build_runtime_package.py' - - 'scripts/wheel_smoke.py' - - '.github/workflows/runtime-prbuild-compat.yml' pull_request: branches: [main, staging] - paths: - - 'workspace/**' - - 'scripts/build_runtime_package.py' - - 'scripts/wheel_smoke.py' - - '.github/workflows/runtime-prbuild-compat.yml' workflow_dispatch: - # Required-check support: when this becomes a branch-protection gate, - # merge_group runs let the queue green-check this in addition to PRs. merge_group: types: [checks_requested] - # No cron: the same pre-merge run already covered the commit, and - # re-running daily wouldn't surface anything new (workspace/ doesn't - # change between cron firings unless a PR already passed this gate). concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.sha }} cancel-in-progress: true jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + wheel: ${{ steps.decide.outputs.wheel }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + wheel: + - 'workspace/**' + - 'scripts/build_runtime_package.py' + - 'scripts/wheel_smoke.py' + - '.github/workflows/runtime-prbuild-compat.yml' + - id: decide + # Always run real work for manual dispatch + merge_group — no + # diff-against-base in those contexts, and the gate exists to + # validate the to-be-merged state regardless of which paths it + # touched (paths-filter would default to "no changes" which is + # the wrong answer when the queue is composing many PRs). + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ] || [ "${{ github.event_name }}" = "merge_group" ]; then + echo "wheel=true" >> "$GITHUB_OUTPUT" + else + echo "wheel=${{ steps.filter.outputs.wheel }}" >> "$GITHUB_OUTPUT" + fi + + # ONE job (no job-level `if:`) that always runs and reports under the + # required-check name `PR-built wheel + import smoke`. Real work is + # gated per-step on `needs.detect-changes.outputs.wheel`. Same shape + # as e2e-api.yml's e2e-api job — see its comment block for the full + # rationale (SKIPPED check runs block branch protection even with + # SUCCESS siblings; collapsing to one always-run job emits exactly + # one SUCCESS check run). local-build-install: - # Builds the wheel from THIS PR's workspace/ + scripts/ and tests - # IT — the artifact that WOULD be published if this PR merges. + needs: detect-changes name: PR-built wheel + import smoke runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - name: No-op pass (paths filter excluded this commit) + if: needs.detect-changes.outputs.wheel != 'true' + run: | + echo "No workspace/ / scripts/{build_runtime_package,wheel_smoke}.py / workflow changes — wheel gate satisfied without rebuilding." + echo "::notice::PR-built wheel + import smoke no-op pass (paths filter excluded this commit)." + - if: needs.detect-changes.outputs.wheel == 'true' + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - if: needs.detect-changes.outputs.wheel == 'true' + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' cache: pip cache-dependency-path: workspace/requirements.txt - name: Install build tooling + if: needs.detect-changes.outputs.wheel == 'true' run: pip install build - name: Build wheel from PR source (mirrors publish-runtime.yml) + if: needs.detect-changes.outputs.wheel == 'true' # Use a fixed test version so the wheel filename is predictable. # Doesn't reach PyPI — this build is local-only for the smoke. # Use the SAME build script with the SAME args as @@ -88,6 +121,7 @@ jobs: --out /tmp/runtime-build cd /tmp/runtime-build && python -m build - name: Install built wheel + workspace requirements + if: needs.detect-changes.outputs.wheel == 'true' run: | python -m venv /tmp/venv-built /tmp/venv-built/bin/pip install --upgrade pip @@ -96,6 +130,7 @@ jobs: /tmp/venv-built/bin/pip show molecule-ai-workspace-runtime a2a-sdk \ | grep -E '^(Name|Version):' - name: Smoke import the PR-built wheel + if: needs.detect-changes.outputs.wheel == 'true' # Same script publish-runtime.yml runs against the to-be-PyPI wheel. # Closes the PR-time vs publish-time gap: a PR adding a new SDK # call-shape no longer passes here (narrow `import main_sync`) only diff --git a/.github/workflows/test-ops-scripts.yml b/.github/workflows/test-ops-scripts.yml index 3c6488fa..ca8cb0af 100644 --- a/.github/workflows/test-ops-scripts.yml +++ b/.github/workflows/test-ops-scripts.yml @@ -1,19 +1,27 @@ name: Ops Scripts Tests -# Runs the unittest suite for scripts/ops/ on every PR + push that touches -# the directory. Kept separate from the main CI so a script-only change -# doesn't trigger the heavier Go/Canvas/Python pipelines. +# Runs the unittest suite for scripts/ on every PR + push that touches +# anything under scripts/. Kept separate from the main CI so a script-only +# change doesn't trigger the heavier Go/Canvas/Python pipelines. +# +# Discovery layout: tests sit alongside the code they test (see +# scripts/ops/test_sweep_cf_decide.py for the pattern; scripts/ +# test_build_runtime_package.py for the rewriter coverage). The job +# below runs `unittest discover` TWICE — once from `scripts/`, once +# from `scripts/ops/` — because neither dir has an `__init__.py`, so +# a single discover from `scripts/` doesn't recurse into the ops +# subdir. Two passes is simpler than retrofitting namespace packages. on: push: branches: [main, staging] paths: - - 'scripts/ops/**' + - 'scripts/**' - '.github/workflows/test-ops-scripts.yml' pull_request: branches: [main, staging] paths: - - 'scripts/ops/**' + - 'scripts/**' - '.github/workflows/test-ops-scripts.yml' merge_group: types: [checks_requested] @@ -31,6 +39,14 @@ jobs: - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' - - name: Run unittest + - name: Run scripts/ unittests (build_runtime_package, …) + # Top-level scripts/ tests live alongside their target file + # (e.g. scripts/test_build_runtime_package.py exercises + # scripts/build_runtime_package.py). discover from scripts/ + # picks up only top-level test_*.py because scripts/ops/ has + # no __init__.py — that's intentional, so we run two passes. + working-directory: scripts + run: python -m unittest discover -t . -p 'test_*.py' -v + - name: Run scripts/ops/ unittests (sweep_cf_decide, …) working-directory: scripts/ops run: python -m unittest discover -p 'test_*.py' -v diff --git a/.gitignore b/.gitignore index 05da25ee..3b6e7451 100644 --- a/.gitignore +++ b/.gitignore @@ -146,3 +146,4 @@ backups/ *-temp.txt /test-pmm-*.txt /tick-reflections-*.md +tests/harness/cp-stub/cp-stub diff --git a/README.md b/README.md index 3e3e0fb4..c054253d 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,8 @@ Workspace Runtime

-[![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://github.com/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://github.com/Molecule-AI/molecule-monorepo) @@ -249,8 +249,8 @@ Workspace Runtime (Python image with adapters) ## Quick Start ```bash -git clone https://github.com/Molecule-AI/molecule-core.git -cd molecule-core +git clone https://github.com/Molecule-AI/molecule-monorepo.git +cd molecule-monorepo cp .env.example .env # Defaults boot the stack locally out of the box. See .env.example for diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 11b2b405..a2c8dff1 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -12,6 +12,19 @@ interface WorkspaceOption { tier: number; } +// Subset of the /templates row used here. Mirrors the shape ConfigTab +// reads. `providers` is the per-template declarative list of supported +// LLM providers — sourced from the template's +// runtime_config.providers (config.yaml). When present, it filters +// the modal's provider . Empty/missing list falls back to the full HERMES_PROVIDERS + // catalog so older templates without the field keep working. + const [templateSpecs, setTemplateSpecs] = useState([]); // External-runtime path: skip docker provision, mint a workspace_auth_token, // and surface the connection snippet in a modal after create. When // isExternal is true the template / model / hermes-provider fields are @@ -130,6 +150,52 @@ export function CreateWorkspaceButton() { const isHermes = template.trim().toLowerCase() === "hermes"; + // Resolve the selected template's spec from the /templates response. + // The `template` input is free-text; templates can be matched by id, + // name, or runtime so any of those work. Lower-cased compare keeps + // "Hermes" / "hermes" / "HERMES" interchangeable. + const selectedTemplateSpec = useMemo(() => { + const t = template.trim().toLowerCase(); + if (!t) return null; + return ( + templateSpecs.find( + (s) => + (s.id || "").toLowerCase() === t || + (s.name || "").toLowerCase() === t || + (s.runtime || "").toLowerCase() === t, + ) ?? null + ); + }, [template, templateSpecs]); + + // Filter HERMES_PROVIDERS by what the template declares it supports. + // Empty/missing declared list → fall back to the full catalog so + // templates that haven't migrated to the explicit `providers:` field + // (and self-hosted setups without /templates) keep working unchanged. + const availableProviders = useMemo(() => { + const declared = selectedTemplateSpec?.providers; + if (!declared || declared.length === 0) return HERMES_PROVIDERS; + const allowed = new Set(declared.map((p) => p.toLowerCase())); + const filtered = HERMES_PROVIDERS.filter((p) => allowed.has(p.id.toLowerCase())); + // Defensive: if the template's declared list doesn't match anything + // in our static catalog (e.g. brand-new provider id we don't have + // metadata for yet), fall back to the full list rather than render + // an empty setModel(e.target.value)} + placeholder="e.g. minimax/MiniMax-M2.7" + aria-label="Model slug" + autoComplete="off" + spellCheck={false} + list="provider-picker-model-suggestions" + className="w-full bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors" + /> + + {modelSuggestions?.map((m) => ( + +

+ Slug determines provider routing at install time. +

+ + )}
Provider @@ -364,8 +461,12 @@ function ProviderPickerModal({ Cancel Deploy + @@ -95,6 +119,7 @@ function makeTemplate(over: Partial