#!/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 <name> import` or `import <name>` (where <name>
# 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 = {
    "_sanitize_a2a",
    "a2a_cli",
    "a2a_client",
    "a2a_executor",
    "a2a_mcp_server",
    "a2a_response",
    "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 <pkg>` → `from molecule_runtime.<pkg>`.
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 <name>(\.|\s|import)
    #   import <name>(\s|$|,)
    # And captures the keyword + name so we can re-emit with prefix.
    pattern = (
        r"(?m)^(?P<indent>\s*)"          # leading whitespace (preserved)
        r"(?P<kw>from|import)\s+"        # 'from' or 'import'
        r"(?P<mod>" + alt + r")"          # the module name
        r"(?P<rest>[\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 <alias>' on a "
                f"workspace module — the regex would produce "
                f"'import molecule_runtime.{mod} as {mod} as <alias>', 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*", "plugins_registry*"]

[tool.setuptools.package-data]
"molecule_runtime" = ["py.typed"]
"plugins_registry" = ["py.typed"]
"""


README_TEMPLATE = """\
# molecule-ai-workspace-runtime

Shared workspace runtime for [Molecule AI](https://git.moleculesai.app/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=<uuid> \\
  PLATFORM_URL=https://<tenant>.staging.moleculesai.app \\
  MOLECULE_WORKSPACE_TOKEN=<bearer> \\
  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=<bearer>` 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' "<bearer>" > ~/.config/molecule/token
WORKSPACE_ID=<uuid> \\
  PLATFORM_URL=https://<tenant>.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://git.moleculesai.app/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@<marketplace>`
   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://git.moleculesai.app/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://git.moleculesai.app/molecule-ai/molecule-core/src/branch/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 <name> 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")

    # Install plugins_registry/ at the wheel TOP LEVEL so that plugin adapter
    # code (workspace-template-*) can use bare `from plugins_registry import ...`.
    # The molecule-runtime package (molecule_runtime/) also ships it at
    # molecule_runtime/plugins_registry/ (satisfies the rewritten
    # `from molecule_runtime.plugins_registry import ...` in adapter_base.py).
    # Both copies coexist: they serve different import namespaces.
    plugins_src = src / "plugins_registry"
    plugins_dst = out / "plugins_registry"
    if plugins_src.is_dir():
        shutil.copytree(plugins_src, plugins_dst)
        print(f"[build] installed plugins_registry/ at top level (bare-import shim)")

    # 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())
