workspace.py
python
| 1 | """Workspace management — compose multiple Muse repositories. |
| 2 | |
| 3 | A *workspace* is a collection of related Muse repositories that are developed |
| 4 | together. Think of a film score that references a sound library, a machine |
| 5 | learning pipeline that includes a dataset repo, or a multi-service codebase |
| 6 | where each service lives in its own Muse repo. |
| 7 | |
| 8 | Design |
| 9 | ------ |
| 10 | Workspaces are distinct from worktrees: |
| 11 | |
| 12 | - A **worktree** is one checkout of *one* repo with *one* ``.muse/`` store. |
| 13 | - A **workspace** is an envelope that *links* multiple separate repos together. |
| 14 | |
| 15 | The workspace manifest lives at ``.muse/workspace.toml``:: |
| 16 | |
| 17 | [[members]] |
| 18 | name = "core" |
| 19 | url = "https://musehub.ai/acme/core" |
| 20 | path = "repos/core" # relative to workspace root |
| 21 | branch = "main" # pinned branch |
| 22 | |
| 23 | [[members]] |
| 24 | name = "dataset" |
| 25 | url = "https://musehub.ai/acme/dataset" |
| 26 | path = "repos/dataset" |
| 27 | branch = "v2" |
| 28 | |
| 29 | Agent workflow |
| 30 | -------------- |
| 31 | Each member repo is a fully independent Muse repository. Agents can commit |
| 32 | to member repos independently and the workspace provides a unified status view |
| 33 | and one-shot sync. |
| 34 | |
| 35 | ``muse workspace sync`` walks all members and runs ``muse fetch`` + ``muse pull`` |
| 36 | so the workspace root always has the latest HEAD for every pinned branch. |
| 37 | """ |
| 38 | |
| 39 | from __future__ import annotations |
| 40 | |
| 41 | import json |
| 42 | import logging |
| 43 | import pathlib |
| 44 | import subprocess |
| 45 | from dataclasses import dataclass |
| 46 | from typing import TypedDict |
| 47 | |
| 48 | logger = logging.getLogger(__name__) |
| 49 | |
| 50 | _WORKSPACE_FILE = ".muse/workspace.toml" |
| 51 | |
| 52 | |
| 53 | # --------------------------------------------------------------------------- |
| 54 | # Types |
| 55 | # --------------------------------------------------------------------------- |
| 56 | |
| 57 | |
| 58 | class WorkspaceMemberDict(TypedDict): |
| 59 | """One entry in the workspace manifest.""" |
| 60 | |
| 61 | name: str |
| 62 | url: str |
| 63 | path: str |
| 64 | branch: str |
| 65 | |
| 66 | |
| 67 | class WorkspaceManifestDict(TypedDict): |
| 68 | """Top-level workspace manifest.""" |
| 69 | |
| 70 | members: list[WorkspaceMemberDict] |
| 71 | |
| 72 | |
| 73 | @dataclass |
| 74 | class WorkspaceMemberStatus: |
| 75 | """Runtime status of one workspace member.""" |
| 76 | |
| 77 | name: str |
| 78 | path: pathlib.Path |
| 79 | branch: str |
| 80 | url: str |
| 81 | present: bool |
| 82 | head_commit: str | None |
| 83 | |
| 84 | |
| 85 | # --------------------------------------------------------------------------- |
| 86 | # Paths |
| 87 | # --------------------------------------------------------------------------- |
| 88 | |
| 89 | |
| 90 | def _workspace_path(repo_root: pathlib.Path) -> pathlib.Path: |
| 91 | return repo_root / ".muse" / "workspace.toml" |
| 92 | |
| 93 | |
| 94 | # --------------------------------------------------------------------------- |
| 95 | # Persistence |
| 96 | # --------------------------------------------------------------------------- |
| 97 | |
| 98 | |
| 99 | def _load_manifest(repo_root: pathlib.Path) -> WorkspaceManifestDict | None: |
| 100 | import tomllib |
| 101 | |
| 102 | path = _workspace_path(repo_root) |
| 103 | if not path.exists(): |
| 104 | return None |
| 105 | try: |
| 106 | raw = tomllib.loads(path.read_text(encoding="utf-8")) |
| 107 | except Exception as exc: |
| 108 | logger.warning("⚠️ Could not read workspace manifest: %s", exc) |
| 109 | return None |
| 110 | members: list[WorkspaceMemberDict] = [] |
| 111 | for m in raw.get("members", []): |
| 112 | if not isinstance(m, dict): |
| 113 | continue |
| 114 | members.append(WorkspaceMemberDict( |
| 115 | name=str(m.get("name", "")), |
| 116 | url=str(m.get("url", "")), |
| 117 | path=str(m.get("path", "")), |
| 118 | branch=str(m.get("branch", "main")), |
| 119 | )) |
| 120 | return WorkspaceManifestDict(members=members) |
| 121 | |
| 122 | |
| 123 | def _save_manifest(repo_root: pathlib.Path, manifest: WorkspaceManifestDict) -> None: |
| 124 | path = _workspace_path(repo_root) |
| 125 | path.parent.mkdir(parents=True, exist_ok=True) |
| 126 | lines: list[str] = [] |
| 127 | for m in manifest["members"]: |
| 128 | lines.append("[[members]]") |
| 129 | lines.append(f'name = "{m["name"]}"') |
| 130 | lines.append(f'url = "{m["url"]}"') |
| 131 | lines.append(f'path = "{m["path"]}"') |
| 132 | lines.append(f'branch = "{m["branch"]}"') |
| 133 | lines.append("") |
| 134 | tmp = path.with_suffix(".tmp") |
| 135 | tmp.write_text("\n".join(lines), encoding="utf-8") |
| 136 | tmp.replace(path) |
| 137 | |
| 138 | |
| 139 | # --------------------------------------------------------------------------- |
| 140 | # Public API |
| 141 | # --------------------------------------------------------------------------- |
| 142 | |
| 143 | |
| 144 | def add_workspace_member( |
| 145 | repo_root: pathlib.Path, |
| 146 | name: str, |
| 147 | url: str, |
| 148 | path: str = "", |
| 149 | branch: str = "main", |
| 150 | ) -> None: |
| 151 | """Register a new member repository in the workspace manifest. |
| 152 | |
| 153 | Args: |
| 154 | repo_root: The workspace root (where ``.muse/`` lives). |
| 155 | name: Short identifier for this member. |
| 156 | url: URL or local path to the member Muse repository. |
| 157 | path: Relative checkout path inside the workspace (default: ``repos/<name>``). |
| 158 | branch: Branch to track (default: ``main``). |
| 159 | |
| 160 | Raises: |
| 161 | ValueError: If a member with the same name already exists. |
| 162 | """ |
| 163 | from muse.core.validation import validate_branch_name |
| 164 | |
| 165 | validate_branch_name(name) |
| 166 | effective_path = path or f"repos/{name}" |
| 167 | |
| 168 | manifest = _load_manifest(repo_root) or WorkspaceManifestDict(members=[]) |
| 169 | for m in manifest["members"]: |
| 170 | if m["name"] == name: |
| 171 | raise ValueError(f"Workspace member '{name}' already exists.") |
| 172 | |
| 173 | manifest["members"].append(WorkspaceMemberDict( |
| 174 | name=name, |
| 175 | url=url, |
| 176 | path=effective_path, |
| 177 | branch=branch, |
| 178 | )) |
| 179 | _save_manifest(repo_root, manifest) |
| 180 | |
| 181 | |
| 182 | def remove_workspace_member(repo_root: pathlib.Path, name: str) -> None: |
| 183 | """Remove a member from the workspace manifest (does not delete the directory). |
| 184 | |
| 185 | Raises: |
| 186 | ValueError: If no member with that name exists. |
| 187 | """ |
| 188 | manifest = _load_manifest(repo_root) |
| 189 | if manifest is None: |
| 190 | raise ValueError("No workspace manifest found.") |
| 191 | before = len(manifest["members"]) |
| 192 | manifest["members"] = [m for m in manifest["members"] if m["name"] != name] |
| 193 | if len(manifest["members"]) == before: |
| 194 | raise ValueError(f"Workspace member '{name}' not found.") |
| 195 | _save_manifest(repo_root, manifest) |
| 196 | |
| 197 | |
| 198 | def list_workspace_members(repo_root: pathlib.Path) -> list[WorkspaceMemberStatus]: |
| 199 | """Return status for every workspace member.""" |
| 200 | manifest = _load_manifest(repo_root) |
| 201 | if manifest is None: |
| 202 | return [] |
| 203 | |
| 204 | results: list[WorkspaceMemberStatus] = [] |
| 205 | for m in manifest["members"]: |
| 206 | member_path = repo_root / m["path"] |
| 207 | present = member_path.exists() and (member_path / ".muse").exists() |
| 208 | head_commit: str | None = None |
| 209 | if present: |
| 210 | try: |
| 211 | from muse.core.store import get_head_commit_id |
| 212 | head_commit = get_head_commit_id(member_path, m["branch"]) |
| 213 | except Exception: |
| 214 | pass |
| 215 | results.append(WorkspaceMemberStatus( |
| 216 | name=m["name"], |
| 217 | path=member_path, |
| 218 | branch=m["branch"], |
| 219 | url=m["url"], |
| 220 | present=present, |
| 221 | head_commit=head_commit, |
| 222 | )) |
| 223 | return results |
| 224 | |
| 225 | |
| 226 | def sync_workspace_member( |
| 227 | repo_root: pathlib.Path, |
| 228 | member: WorkspaceMemberDict, |
| 229 | ) -> str: |
| 230 | """Clone or pull the latest state for one workspace member. |
| 231 | |
| 232 | Returns: |
| 233 | A status string: 'cloned', 'pulled', or 'error: <msg>'. |
| 234 | """ |
| 235 | member_path = repo_root / member["path"] |
| 236 | |
| 237 | if not member_path.exists() or not (member_path / ".muse").exists(): |
| 238 | # Clone from URL. |
| 239 | member_path.parent.mkdir(parents=True, exist_ok=True) |
| 240 | result = subprocess.run( |
| 241 | ["muse", "clone", member["url"], str(member_path)], |
| 242 | capture_output=True, |
| 243 | text=True, |
| 244 | ) |
| 245 | if result.returncode != 0: |
| 246 | return f"error: {result.stderr.strip()[:200]}" |
| 247 | return "cloned" |
| 248 | |
| 249 | # Pull to get latest. |
| 250 | result = subprocess.run( |
| 251 | ["muse", "pull", "--branch", member["branch"]], |
| 252 | capture_output=True, |
| 253 | text=True, |
| 254 | cwd=str(member_path), |
| 255 | ) |
| 256 | if result.returncode != 0: |
| 257 | return f"error: {result.stderr.strip()[:200]}" |
| 258 | return "pulled" |
| 259 | |
| 260 | |
| 261 | def sync_workspace( |
| 262 | repo_root: pathlib.Path, |
| 263 | member_name: str | None = None, |
| 264 | ) -> list[tuple[str, str]]: |
| 265 | """Sync all (or one named) workspace members. |
| 266 | |
| 267 | Returns: |
| 268 | List of (member_name, status_str) pairs. |
| 269 | """ |
| 270 | manifest = _load_manifest(repo_root) |
| 271 | if manifest is None: |
| 272 | return [] |
| 273 | |
| 274 | targets = ( |
| 275 | [m for m in manifest["members"] if m["name"] == member_name] |
| 276 | if member_name is not None |
| 277 | else manifest["members"] |
| 278 | ) |
| 279 | |
| 280 | results: list[tuple[str, str]] = [] |
| 281 | for m in targets: |
| 282 | status = sync_workspace_member(repo_root, m) |
| 283 | results.append((m["name"], status)) |
| 284 | return results |