#!/usr/bin/env python3 """Build the molecule-ai-workspace-runtime PyPI package from monorepo workspace/. Monorepo workspace/ is the single source-of-truth for runtime code. The PyPI package is a publish-time mirror produced by this script, NOT a parallel editable copy. Anyone editing the runtime should edit workspace/, never the sibling molecule-ai-workspace-runtime repo. What this does -------------- 1. Copies workspace/ source into build/molecule_runtime/ (note the rename: bare modules become a real Python package). 2. Rewrites top-level imports so e.g. `from a2a_client import X` becomes `from molecule_runtime.a2a_client import X`. The rewrite is regex-based on a closed allowlist of modules — third-party imports like `from a2a.X` (the a2a-sdk package) are left alone because the regex is anchored on exact module names. 3. Writes a pyproject.toml with the requested version + the README + the py.typed marker. 4. Leaves the build dir ready for `python -m build` to produce a wheel/sdist. Usage ----- scripts/build_runtime_package.py --version 0.1.6 --out /tmp/runtime-build cd /tmp/runtime-build && python -m build python -m twine upload dist/* The publish workflow (.github/workflows/publish-runtime.yml) drives this on every `runtime-v*` tag push. """ from __future__ import annotations import argparse import re import shutil import sys from pathlib import Path # Top-level Python modules in workspace/ that become molecule_runtime.X. # Anything imported as `from import` or `import ` (where # matches one of these) gets rewritten to use the package prefix. # # Closed list (not "every .py we copy") because a typo in workspace/ would # otherwise leak into a wrong rewrite. The set is asserted against # `workspace/*.py` at build time — if the disk contents drift from this # list (new module added, old one removed), the build fails loud instead # of silently shipping unrewritten imports. That gap caused 0.1.16 to # ship `from transcript_auth import ...` (unrewritten — module added # without updating this set), which broke every workspace startup with # `ModuleNotFoundError: No module named 'transcript_auth'`. TOP_LEVEL_MODULES = { "a2a_cli", "a2a_client", "a2a_executor", "a2a_mcp_server", "a2a_tools", "a2a_tools_delegation", "a2a_tools_inbox", "a2a_tools_memory", "a2a_tools_messaging", "a2a_tools_rbac", "adapter_base", "agent", "agents_md", "boot_routes", "card_helpers", "config", "configs_dir", "consolidation", "coordinator", "event_log", "events", "executor_helpers", "heartbeat", "inbox", "inbox_uploads", "initial_prompt", "internal_chat_uploads", "internal_file_read", "main", "mcp_cli", "mcp_doctor", "mcp_heartbeat", "mcp_inbox_pollers", "mcp_workspace_resolver", "molecule_ai_status", "not_configured_handler", "platform_auth", "platform_inbound_auth", "plugins", "preflight", "prompt", "runtime_wedge", "secret_redactor", "shared_runtime", "smoke_mode", "transcript_auth", "watcher", } # Subdirectory packages — these are already real packages (they have or will # have __init__.py) so the rewrite is `from ` → `from molecule_runtime.`. SUBPACKAGES = { "adapters", "builtin_tools", "lib", "platform_tools", "plugins_registry", "policies", "skill_loader", } # Files in workspace/ NOT included in the published package. These are # build artifacts, dev scripts, or monorepo-only scaffolding. EXCLUDE_FILES = { "Dockerfile", "build-all.sh", "rebuild-runtime-images.sh", "entrypoint.sh", "pytest.ini", "requirements.txt", # Note: adapter_base.py, agents_md.py, hermes_executor.py, shared_runtime.py # are kept (referenced by adapters/__init__.py and other modules); they get # their imports rewritten via TOP_LEVEL_MODULES. Excluding them broke the # smoke-test install with `ModuleNotFoundError: adapter_base`. } EXCLUDE_DIRS = { "__pycache__", "tests", "molecule_audit", # only used by tests; not on production import path "scripts", } def build_import_rewriter() -> re.Pattern: """Compile a single regex matching all import statements that need rewriting. The match groups capture the keyword + module name so the replacement preserves whitespace and trailing punctuation. Modules included: TOP_LEVEL_MODULES ∪ SUBPACKAGES. The negative-lookahead on `\\.` in the suffix prevents matching `from a2a.server.X import Y` against bare `a2a` (which isn't in our set, but the principle matters for any future short module name that happens to be a prefix of a real package name). """ names = sorted(TOP_LEVEL_MODULES | SUBPACKAGES) alt = "|".join(re.escape(n) for n in names) # Matches: # from (\.|\s|import) # import (\s|$|,) # And captures the keyword + name so we can re-emit with prefix. pattern = ( r"(?m)^(?P\s*)" # leading whitespace (preserved) r"(?Pfrom|import)\s+" # 'from' or 'import' r"(?P" + alt + r")" # the module name r"(?P[\s.,]|$)" # what follows: '.subpath', ' import …', ',', whitespace, EOL ) return re.compile(pattern) def rewrite_imports(text: str, regex: re.Pattern) -> str: """Replace bare imports with package-prefixed ones. `import X` → `import molecule_runtime.X as X` (preserve binding) `from X import Y` → `from molecule_runtime.X import Y` `from X.sub import Y` → `from molecule_runtime.X.sub import Y` Rejects `import X as Y` because the rewrite would produce `import molecule_runtime.X as X as Y`, a syntax error. The PR #2433 incident shipped this exact pattern past `Python Lint & Test` (which runs against pre-rewrite source) but blew up the wheel-smoke gate. Detecting it here turns the silent build failure into a build-time error with a clear path: use `from X import …` or plain `import X`. """ def repl(m: re.Match) -> str: indent, kw, mod, rest = m.group("indent"), m.group("kw"), m.group("mod"), m.group("rest") if kw == "from": # `from X` or `from X.sub` — always safe to prefix. return f"{indent}from molecule_runtime.{mod}{rest}" # `import X` — preserve the binding name `X` (callers do `X.foo`) # by aliasing. `import X.sub` is uncommon for our modules and would # need a different binding form, but isn't used in workspace/ today. if rest.startswith("."): # `import X.sub` — rewrite as `import molecule_runtime.X.sub` and # leave the trailing dot pattern intact for the rest of the line. return f"{indent}import molecule_runtime.{mod}{rest}" # Detect `import X as Y` — the regex's `rest` group captures only # the immediate following char (whitespace, comma, or EOL), so we # have to peek at the surrounding line context. The match start is # at the line's `import` keyword; everything after the matched # name on the same line is what the source author wrote. line_start = text.rfind("\n", 0, m.start()) + 1 line_end = text.find("\n", m.end()) if line_end == -1: line_end = len(text) line_after = text[m.end() - len(rest):line_end] # Strip comments from consideration so `import X # noqa` doesn't trip. line_after_no_comment = line_after.split("#", 1)[0] if re.search(r"^\s*as\s+\w+", line_after_no_comment): raise ValueError( f"rewrite_imports: cannot rewrite 'import {mod} as ' on a " f"workspace module — the regex would produce " f"'import molecule_runtime.{mod} as {mod} as ', invalid syntax. " f"Use 'from {mod} import …' or plain 'import {mod}' instead. " f"Offending line: {text[line_start:line_end]!r}" ) # Plain `import X` — alias preserves the local name. return f"{indent}import molecule_runtime.{mod} as {mod}{rest}" return regex.sub(repl, text) def copy_tree_filtered(src: Path, dst: Path) -> list[Path]: """Copy src/ → dst/ skipping EXCLUDE_FILES + EXCLUDE_DIRS. Returns the list of .py files copied so the caller can run the import rewrite over them in one pass.""" py_files: list[Path] = [] if dst.exists(): shutil.rmtree(dst) dst.mkdir(parents=True) for entry in src.iterdir(): if entry.is_dir(): if entry.name in EXCLUDE_DIRS: continue sub_py = copy_tree_filtered(entry, dst / entry.name) py_files.extend(sub_py) else: if entry.name in EXCLUDE_FILES: continue shutil.copy2(entry, dst / entry.name) if entry.suffix == ".py": py_files.append(dst / entry.name) return py_files PYPROJECT_TEMPLATE = """\ [build-system] requires = ["setuptools>=68.0", "wheel"] build-backend = "setuptools.build_meta" [project] name = "molecule-ai-workspace-runtime" version = "{version}" description = "Molecule AI workspace runtime — shared infrastructure for all agent adapters" requires-python = ">=3.11" license = {{text = "BSL-1.1"}} readme = "README.md" dependencies = [ "a2a-sdk[http-server]>=1.0.0,<2.0", "httpx>=0.27.0", "uvicorn>=0.30.0", "starlette>=0.38.0", "websockets>=12.0", "pyyaml>=6.0", "langchain-core>=0.3.0", "opentelemetry-api>=1.24.0", "opentelemetry-sdk>=1.24.0", "opentelemetry-exporter-otlp-proto-http>=1.24.0", "temporalio>=1.7.0", ] [project.scripts] molecule-runtime = "molecule_runtime.main:main_sync" molecule-mcp = "molecule_runtime.mcp_cli:main" [tool.setuptools.packages.find] where = ["."] include = ["molecule_runtime*"] [tool.setuptools.package-data] "molecule_runtime" = ["py.typed"] """ README_TEMPLATE = """\ # molecule-ai-workspace-runtime Shared workspace runtime for [Molecule AI](https://github.com/Molecule-AI/molecule-core) agent adapters. Installed by every workspace template image (`workspace-template-claude-code`, `-langgraph`, `-hermes`, etc.) to provide A2A delegation, heartbeat, memory, plugin loading, and skill management. This package is **published from the molecule-core monorepo `workspace/` directory** by the `publish-runtime` GitHub Actions workflow on every `runtime-v*` tag push. **Do not edit this package directly** — edit `workspace/` in the monorepo. ## External-runtime MCP server (`molecule-mcp`) Operators running an agent outside the platform's container fleet (any runtime that supports MCP stdio — Claude Code, hermes, codex, etc.) can install this wheel and run the universal MCP server locally. ### Requirements * **Python ≥3.11.** The wheel sets `requires-python = ">=3.11"`. On older interpreters `pip install` returns the cryptic `Could not find a version that satisfies the requirement` — that message is pip filtering this wheel out, NOT the package missing from PyPI. Upgrade with `brew install python@3.12` / `apt install python3.12` / `pyenv install 3.12` first. * **`pipx` recommended over `pip`.** `pipx install` puts `molecule-mcp` on PATH automatically and isolates the runtime's deps from your system Python. Plain `pip install --user` works but the binary lands in `~/.local/bin` (Linux) or `~/Library/Python/3.X/bin` (macOS) which is often not on PATH on a fresh shell — `claude mcp add molecule -- molecule-mcp` then fails with "command not found" at first use. ### Install ```sh # Recommended: pipx install molecule-ai-workspace-runtime # Alternative (manage PATH yourself): pip install --user molecule-ai-workspace-runtime ``` ### Run ```sh WORKSPACE_ID= \\ PLATFORM_URL=https://.staging.moleculesai.app \\ MOLECULE_WORKSPACE_TOKEN= \\ molecule-mcp ``` That exposes the same 8 platform tools (`delegate_task`, `list_peers`, `send_message_to_user`, `commit_memory`, etc.) that container-bound runtimes already get via the workspace's auto-spawned MCP. Register the binary in your agent's MCP config (e.g. Claude Code's `claude mcp add molecule -- molecule-mcp` with the env above). ### Keeping the token out of shell history Inline `MOLECULE_WORKSPACE_TOKEN=` ends up in `~/.zsh_history` and (when registered via `claude mcp add`) plaintext in `~/.claude.json`. To avoid that, write the token to a 0600 file and point `MOLECULE_WORKSPACE_TOKEN_FILE` at it: ```sh umask 077 printf '%s' "" > ~/.config/molecule/token WORKSPACE_ID= \\ PLATFORM_URL=https://.staging.moleculesai.app \\ MOLECULE_WORKSPACE_TOKEN_FILE=$HOME/.config/molecule/token \\ molecule-mcp ``` Token resolution order: `MOLECULE_WORKSPACE_TOKEN` (inline env) → `MOLECULE_WORKSPACE_TOKEN_FILE` (path) → `${CONFIGS_DIR}/.auth_token` (in-container default). The token comes from the canvas → Tokens tab. Restarting an external workspace from the canvas no longer revokes the token (PR #2412), so operator tokens persist across status nudges. ### Push vs poll delivery (Claude Code specifics) By default the inbox runs in **poll mode** — every turn the agent calls `wait_for_message`, which blocks up to ~60s on `/activity?since_id=…`. Real-time push delivery is also supported, but on Claude Code it requires THREE conditions, ALL of which must hold: 1. **The MCP server declares `experimental.claude/channel`** — this wheel does (see `_build_initialize_result`). Nothing for you to do. 2. **Claude Code installs the server as a marketplace plugin** — a plain `claude mcp add molecule -- molecule-mcp` produces a non-plugin-sourced server, which Claude Code rejects with `channel_enable requires a marketplace plugin`. Until the official `moleculesai/claude-code-plugin` marketplace lands (tracking [#2936](https://github.com/Molecule-AI/molecule-core/issues/2936)), operators who want push must scaffold their own local marketplace under `~/.claude/marketplaces/molecule-local/` containing a `marketplace.json` + `plugin.json` that points at this wheel. 3. **Claude Code is launched with the dev-channels flag** — pass `--dangerously-load-development-channels plugin:molecule@` on the `claude` invocation. Without this flag the channel capability is silently ignored. Symptom of any condition failing: messages arrive but only via the poll path (every ~1–60s), not real-time. There's currently no diagnostic surfaced — `molecule-mcp doctor` (tracking [#2937](https://github.com/Molecule-AI/molecule-core/issues/2937)) is planned. If you don't need real-time push, the default poll path works universally with no extra setup; both modes converge on the same `inbox_pop` ack so messages never duplicate. See [`docs/workspace-runtime-package.md`](https://github.com/Molecule-AI/molecule-core/blob/main/docs/workspace-runtime-package.md) for the publish flow and architecture. """ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", required=True, help="Package version, e.g. 0.1.6") parser.add_argument("--out", required=True, type=Path, help="Build output directory (will be wiped)") parser.add_argument("--source", type=Path, default=Path(__file__).resolve().parent.parent / "workspace", help="Path to monorepo workspace/ directory (default: ../workspace from this script)") args = parser.parse_args() src = args.source.resolve() out = args.out.resolve() if not src.is_dir(): print(f"error: source not a directory: {src}", file=sys.stderr) return 2 # Drift gate: assert TOP_LEVEL_MODULES matches workspace/*.py. # Without this, a new top-level module added to workspace/ ships # with unrewritten `from import` statements that explode at # runtime with ModuleNotFoundError. (See 0.1.16 transcript_auth # incident — closed list silently went stale.) on_disk_modules = { f.stem for f in src.glob("*.py") if f.stem not in {"__init__", "conftest"} } missing = on_disk_modules - TOP_LEVEL_MODULES stale = TOP_LEVEL_MODULES - on_disk_modules if missing or stale: print("error: TOP_LEVEL_MODULES drifted from workspace/*.py contents:", file=sys.stderr) if missing: print(f" in workspace/ but NOT in TOP_LEVEL_MODULES (will ship un-rewritten): {sorted(missing)}", file=sys.stderr) if stale: print(f" in TOP_LEVEL_MODULES but NOT in workspace/ (no-op, but misleading): {sorted(stale)}", file=sys.stderr) print(" Edit scripts/build_runtime_package.py:TOP_LEVEL_MODULES to match.", file=sys.stderr) return 3 # Same drift gate for SUBPACKAGES — catches the inverse class of # bug where a workspace/ subdirectory is referenced by main.py # (`from lib.pre_stop import ...`) but is either missing from # SUBPACKAGES (so the rewriter doesn't qualify the import) or # accidentally listed in EXCLUDE_DIRS (so the directory itself # isn't shipped). 0.1.16-0.1.19 had `lib` in EXCLUDE_DIRS while # main.py imported from it — `ModuleNotFoundError: No module # named 'lib'` at every workspace startup. on_disk_subpkgs = { d.name for d in src.iterdir() if d.is_dir() and d.name not in EXCLUDE_DIRS and d.name not in {"__pycache__"} and (d / "__init__.py").exists() } sub_missing = on_disk_subpkgs - SUBPACKAGES sub_stale = SUBPACKAGES - on_disk_subpkgs if sub_missing or sub_stale: print("error: SUBPACKAGES drifted from workspace/ subdirectories:", file=sys.stderr) if sub_missing: print(f" in workspace/ but NOT in SUBPACKAGES (will ship un-rewritten or be excluded): {sorted(sub_missing)}", file=sys.stderr) if sub_stale: print(f" in SUBPACKAGES but NOT in workspace/ (no-op, but misleading): {sorted(sub_stale)}", file=sys.stderr) print(" Edit scripts/build_runtime_package.py:SUBPACKAGES + EXCLUDE_DIRS to match.", file=sys.stderr) return 3 pkg_dir = out / "molecule_runtime" print(f"[build] source: {src}") print(f"[build] output: {out}") print(f"[build] package: {pkg_dir}") if out.exists(): shutil.rmtree(out) out.mkdir(parents=True) py_files = copy_tree_filtered(src, pkg_dir) print(f"[build] copied {len(py_files)} .py files") # Ensure top-level package marker exists. workspace/ doesn't have one # (it's not a package in monorepo), but the published artifact must. init = pkg_dir / "__init__.py" if not init.exists(): init.write_text('"""Molecule AI workspace runtime."""\n') # Touch py.typed so type-checkers in adapter consumers see the package # as typed. Empty file is the convention. (pkg_dir / "py.typed").touch() # Rewrite imports in every .py file we copied + the new __init__.py. regex = build_import_rewriter() rewrites = 0 for f in [*py_files, init]: original = f.read_text() rewritten = rewrite_imports(original, regex) if rewritten != original: f.write_text(rewritten) rewrites += 1 print(f"[build] rewrote imports in {rewrites} files") # Emit pyproject.toml + README at build root. (out / "pyproject.toml").write_text(PYPROJECT_TEMPLATE.format(version=args.version)) (out / "README.md").write_text(README_TEMPLATE) print(f"[build] done. To publish:") print(f" cd {out}") print(f" python -m build") print(f" python -m twine upload dist/*") return 0 if __name__ == "__main__": sys.exit(main())