workspace.py file-level

at sha256:c · View file ↗ · Intel ↗

History
1 files
1 commits
0 hotspots
0 🧊 dead
0 💥 blast risk
sha256:c docs(KD-STAGING): sync governance after KD-6b DONE Mirror workspace go… · aaronrene · Jul 10, 2026
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 ``muse workspace sync --workers 8`` parallelises across members.
38
39 Security model
40 --------------
41 - Manifest size is capped at ``_MAX_MANIFEST_BYTES`` before reading.
42 - The manifest file and its parent directory are checked for symlinks before
43 any read or write to prevent path-traversal attacks.
44 - All free-form string fields (name, url, path, branch) are TOML-escaped
45 before serialisation to prevent injection via crafted values.
46 - Member ``path`` values are validated to resolve *within* the workspace root.
47 - Member ``url`` values are checked for a valid scheme (https, http, or local
48 path); shell metacharacters are rejected before passing to subprocess.
49 """
50
51 from __future__ import annotations
52
53 import concurrent.futures
54 import logging
55 import pathlib
56 import subprocess
57 from dataclasses import dataclass
58 from typing import TypedDict
59
60 logger = logging.getLogger(__name__)
61
62 _WORKSPACE_FILE = ".muse/workspace.toml"
63
64 # 1 MiB — a manifest with 1 000 members at ~200 bytes each is ~200 KiB.
65 _MAX_MANIFEST_BYTES = 1 * 1024 * 1024
66
67 # Allowed URL schemes for member repositories.
68 _ALLOWED_SCHEMES = frozenset({"https", "http"})
69
70
71 # ---------------------------------------------------------------------------
72 # Types
73 # ---------------------------------------------------------------------------
74
75
76 class WorkspaceMemberDict(TypedDict):
77 """One entry in the workspace manifest."""
78
79 name: str
80 url: str
81 path: str
82 branch: str
83
84
85 class WorkspaceManifestDict(TypedDict):
86 """Top-level workspace manifest."""
87
88 members: list[WorkspaceMemberDict]
89
90
91 @dataclass
92 class WorkspaceMemberStatus:
93 """Runtime status of one workspace member.
94
95 ``branch`` is the configured tracking branch from *workspace.toml* — the
96 branch this member is *supposed* to be on according to the manifest.
97
98 ``actual_branch`` is the branch currently checked out in the working
99 directory (read from ``HEAD``). When it differs from ``branch`` the member
100 is checked out somewhere unexpected; agents should surface this discrepancy.
101
102 ``head_commit`` is the commit that ``HEAD`` currently resolves to — i.e.
103 the actual checked-out commit, not the tip of the configured branch.
104
105 ``stash_count`` is the number of stashed changesets (0 = clean).
106
107 ``feature_branches`` lists every local branch that is not ``main`` or
108 ``dev`` — short-lived task/feat/bugfix branches that have not been cleaned
109 up yet.
110 """
111
112 name: str
113 path: pathlib.Path
114 branch: str # configured tracking branch from workspace.toml
115 url: str
116 present: bool
117 head_commit: str | None # actual HEAD commit (what HEAD resolves to)
118 dirty: bool
119 actual_branch: str | None # currently checked-out branch
120 stash_count: int # number of stashed changesets
121 feature_branches: list[str] # local branches other than main / dev
122
123
124 class WorkspaceSyncResult(TypedDict):
125 """Result of syncing one workspace member.
126
127 ``status`` is one of ``'cloned'``, ``'pulled'``, ``'skipped'``, or
128 ``'error: <message>'``.
129 """
130
131 name: str
132 status: str
133
134
135 # ---------------------------------------------------------------------------
136 # TOML helpers
137 # ---------------------------------------------------------------------------
138
139
140 def _toml_escape(value: str) -> str:
141 """Escape *value* for safe embedding inside a TOML double-quoted string.
142
143 TOML basic strings forbid unescaped backslash, double-quote, and control
144 characters (newline, carriage-return, tab, etc.). All are escaped here so
145 that crafted values like ``core"\\nname = "injected`` or values containing
146 literal newlines cannot break the manifest structure.
147 """
148 return (
149 value
150 .replace("\\", "\\\\")
151 .replace('"', '\\"')
152 .replace("\n", "\\n")
153 .replace("\r", "\\r")
154 .replace("\t", "\\t")
155 .replace("\x00", "\\u0000")
156 )
157
158
159 # ---------------------------------------------------------------------------
160 # Paths
161 # ---------------------------------------------------------------------------
162
163
164 def _workspace_path(repo_root: pathlib.Path) -> pathlib.Path:
165 return repo_root / ".muse" / "workspace.toml"
166
167
168 def find_workspace_root(start: pathlib.Path | None = None) -> pathlib.Path | None:
169 """Walk up from *start* (default: cwd) to find the directory containing
170 ``.muse/workspace.toml``. Returns ``None`` if no workspace is found.
171
172 This mirrors ``find_repo_root()`` so that workspace commands resolve the
173 correct manifest regardless of CWD or ``-C`` flag usage.
174 """
175 current = (start or pathlib.Path.cwd()).resolve()
176 for directory in (current, *current.parents):
177 if (directory / ".muse" / "workspace.toml").exists():
178 return directory
179 return None
180
181
182 def require_workspace_root(start: pathlib.Path | None = None) -> pathlib.Path:
183 """Return the workspace root or exit with a clear error message."""
184 from muse.core.errors import ExitCode
185 root = find_workspace_root(start)
186 if root is None:
187 import sys
188 print(
189 "❌ Not inside a Muse workspace.\n"
190 " No .muse/workspace.toml found in this directory or any parent.",
191 file=sys.stderr,
192 )
193 raise SystemExit(ExitCode.REPO_NOT_FOUND)
194 return root
195
196
197 # ---------------------------------------------------------------------------
198 # Persistence
199 # ---------------------------------------------------------------------------
200
201
202 def _load_manifest(repo_root: pathlib.Path) -> WorkspaceManifestDict | None:
203 """Read and parse the workspace manifest.
204
205 Security guards applied before any read:
206
207 - Symlink check: a symlink at the manifest path could redirect reads to
208 sensitive files outside the repo.
209 - Size cap (``_MAX_MANIFEST_BYTES``): a corrupt or tampered manifest cannot
210 exhaust memory.
211 """
212 import tomllib
213
214 path = _workspace_path(repo_root)
215 if not path.exists():
216 return None
217 if path.is_symlink():
218 logger.warning(
219 "⚠️ Workspace manifest is a symlink — ignoring to prevent path traversal"
220 )
221 return None
222 try:
223 size = path.stat().st_size
224 if size > _MAX_MANIFEST_BYTES:
225 logger.warning(
226 "⚠️ Workspace manifest is %.1f MiB — exceeds cap of %d MiB; ignoring",
227 size / (1024 * 1024),
228 _MAX_MANIFEST_BYTES // (1024 * 1024),
229 )
230 return None
231 raw = tomllib.loads(path.read_text(encoding="utf-8"))
232 except Exception as exc:
233 logger.warning("⚠️ Could not read workspace manifest: %s", exc)
234 return None
235 members: list[WorkspaceMemberDict] = []
236 for m in raw.get("members", []):
237 if not isinstance(m, dict):
238 continue
239 members.append(
240 WorkspaceMemberDict(
241 name=str(m.get("name", "")),
242 url=str(m.get("url", "")),
243 path=str(m.get("path", "")),
244 branch=str(m.get("branch", "main")),
245 )
246 )
247 return WorkspaceManifestDict(members=members)
248
249
250 def _save_manifest(repo_root: pathlib.Path, manifest: WorkspaceManifestDict) -> None:
251 """Write the manifest atomically.
252
253 Security guards:
254
255 - The manifest file and its parent directory are checked for symlinks
256 before writing to prevent path-traversal via a planted symlink.
257 - All string values are TOML-escaped to prevent injection.
258 """
259 path = _workspace_path(repo_root)
260 parent = path.parent
261 parent.mkdir(parents=True, exist_ok=True)
262
263 if parent.is_symlink():
264 raise OSError(f"Refusing to write manifest — parent directory is a symlink: {parent}")
265 if path.exists() and path.is_symlink():
266 raise OSError(f"Refusing to write manifest — file is a symlink: {path}")
267
268 lines: list[str] = []
269 for m in manifest["members"]:
270 lines.append("[[members]]")
271 lines.append(f'name = "{_toml_escape(m["name"])}"')
272 lines.append(f'url = "{_toml_escape(m["url"])}"')
273 lines.append(f'path = "{_toml_escape(m["path"])}"')
274 lines.append(f'branch = "{_toml_escape(m["branch"])}"')
275 lines.append("")
276 tmp = path.with_suffix(".tmp")
277 tmp.write_text("\n".join(lines), encoding="utf-8")
278 tmp.replace(path)
279
280
281 # ---------------------------------------------------------------------------
282 # Validation helpers
283 # ---------------------------------------------------------------------------
284
285
286 def _validate_member_name(name: str) -> None:
287 """Raise ``ValueError`` if *name* is not a safe workspace member name.
288
289 Allowed: alphanumerics, hyphens, underscores, dots. No slashes, nulls,
290 or shell metacharacters. Must be 1–64 characters.
291 """
292 import re
293 if not name or len(name) > 64:
294 raise ValueError(f"Member name must be 1–64 characters, got {len(name)!r}.")
295 if not re.fullmatch(r"[A-Za-z0-9._-]+", name):
296 raise ValueError(
297 f"Member name {name!r} contains invalid characters. "
298 "Use only alphanumerics, hyphens, underscores, and dots."
299 )
300
301
302 def _validate_member_url(url: str) -> None:
303 """Raise ``ValueError`` if *url* is not a safe member URL or local path.
304
305 Accepted forms:
306 - ``https://`` or ``http://`` — remote MuseHub URL.
307 - An absolute local path (no scheme).
308 - A relative local path (no scheme).
309
310 Rejected:
311 - Null bytes in the URL string.
312 - ``file://`` — use a bare path instead.
313 - Any other scheme (``ftp://``, ``ssh://``, etc.).
314 """
315 import urllib.parse
316 if "\x00" in url:
317 raise ValueError("Member URL must not contain null bytes.")
318 parsed = urllib.parse.urlparse(url)
319 if parsed.scheme and parsed.scheme not in _ALLOWED_SCHEMES:
320 raise ValueError(
321 f"Member URL scheme {parsed.scheme!r} is not allowed. "
322 "Use https://, http://, or a bare filesystem path."
323 )
324
325
326 def _validate_member_path(repo_root: pathlib.Path, relative_path: str) -> None:
327 """Raise ``ValueError`` if *relative_path* escapes the workspace root.
328
329 Path components like ``../../etc`` would let a crafted manifest point
330 members at arbitrary directories. We resolve the candidate path and
331 confirm it sits within *repo_root*.
332 """
333 if "\x00" in relative_path:
334 raise ValueError("Member path must not contain null bytes.")
335 candidate = (repo_root / relative_path).resolve()
336 try:
337 candidate.relative_to(repo_root.resolve())
338 except ValueError:
339 raise ValueError(
340 f"Member path {relative_path!r} resolves outside the workspace root."
341 )
342
343
344 # ---------------------------------------------------------------------------
345 # Public API
346 # ---------------------------------------------------------------------------
347
348
349 def add_workspace_member(
350 repo_root: pathlib.Path,
351 name: str,
352 url: str,
353 path: str = "",
354 branch: str = "main",
355 ) -> None:
356 """Register a new member repository in the workspace manifest.
357
358 Args:
359 repo_root: The workspace root (where ``.muse/`` lives).
360 name: Short identifier for this member (alphanumeric, hyphens,
361 underscores, dots; max 64 chars).
362 url: Remote URL (https/http) or local path to the member repo.
363 path: Relative checkout path inside the workspace (default:
364 ``repos/<name>``). Must not escape the workspace root.
365 branch: Branch to track (default: ``main``).
366
367 Raises:
368 ValueError: If name is invalid, URL scheme is disallowed, path escapes
369 the workspace root, or a member with the same name exists.
370 """
371 from muse.core.validation import validate_branch_name
372
373 _validate_member_name(name)
374 _validate_member_url(url)
375 validate_branch_name(branch)
376
377 effective_path = path or f"repos/{name}"
378 _validate_member_path(repo_root, effective_path)
379
380 manifest = _load_manifest(repo_root) or WorkspaceManifestDict(members=[])
381 for m in manifest["members"]:
382 if m["name"] == name:
383 raise ValueError(f"Workspace member '{name}' already exists.")
384
385 manifest["members"].append(
386 WorkspaceMemberDict(
387 name=name,
388 url=url,
389 path=effective_path,
390 branch=branch,
391 )
392 )
393 _save_manifest(repo_root, manifest)
394
395
396 def update_workspace_member(
397 repo_root: pathlib.Path,
398 name: str,
399 url: str | None = None,
400 path: str | None = None,
401 branch: str | None = None,
402 ) -> None:
403 """Update the URL, path, or branch for an existing workspace member.
404
405 Only the supplied keyword arguments are changed. Raises ``ValueError`` if
406 no member with *name* exists.
407
408 Args:
409 repo_root: The workspace root.
410 name: Member name to update.
411 url: New URL (or ``None`` to keep current).
412 path: New relative checkout path (or ``None`` to keep current).
413 branch: New branch to track (or ``None`` to keep current).
414
415 Raises:
416 ValueError: If the member does not exist or any new value is invalid.
417 """
418 from muse.core.validation import validate_branch_name
419
420 if url is not None:
421 _validate_member_url(url)
422 if branch is not None:
423 validate_branch_name(branch)
424 if path is not None:
425 _validate_member_path(repo_root, path)
426
427 manifest = _load_manifest(repo_root)
428 if manifest is not None:
429 for m in manifest["members"]:
430 if m["name"] == name:
431 if url is not None:
432 m["url"] = url
433 if path is not None:
434 m["path"] = path
435 if branch is not None:
436 m["branch"] = branch
437 _save_manifest(repo_root, manifest)
438 return
439 raise ValueError(f"Workspace member '{name}' not found.")
440
441
442 def remove_workspace_member(repo_root: pathlib.Path, name: str) -> None:
443 """Remove a member from the workspace manifest.
444
445 Does **not** delete the member's directory — only its registration in the
446 manifest is removed.
447
448 Raises:
449 ValueError: If no member with that name exists.
450 """
451 manifest = _load_manifest(repo_root)
452 if manifest is None:
453 raise ValueError("No workspace manifest found.")
454 before = len(manifest["members"])
455 manifest["members"] = [m for m in manifest["members"] if m["name"] != name]
456 if len(manifest["members"]) == before:
457 raise ValueError(f"Workspace member '{name}' not found.")
458 _save_manifest(repo_root, manifest)
459
460
461 def get_workspace_member(
462 repo_root: pathlib.Path,
463 name: str,
464 ) -> WorkspaceMemberStatus:
465 """Return the status for a single named workspace member.
466
467 Raises:
468 ValueError: If no member with that name is registered.
469 """
470 manifest = _load_manifest(repo_root)
471 if manifest is None:
472 raise ValueError("No workspace manifest found.")
473 for m in manifest["members"]:
474 if m["name"] == name:
475 return _member_status(repo_root, m)
476 raise ValueError(f"Workspace member '{name}' not found.")
477
478
479 def _member_status(repo_root: pathlib.Path, m: WorkspaceMemberDict) -> WorkspaceMemberStatus:
480 """Build a ``WorkspaceMemberStatus`` for one manifest entry."""
481 import json as _json
482
483 member_path = repo_root / m["path"]
484 present = member_path.exists() and (member_path / ".muse").exists()
485 head_commit: str | None = None
486 dirty = False
487 actual_branch: str | None = None
488 stash_count = 0
489 feature_branches: list[str] = []
490
491 if present:
492 # One subprocess: muse status gives us dirty, actual branch, and HEAD commit.
493 try:
494 result = subprocess.run(
495 ["muse", "status", "--json"],
496 capture_output=True,
497 text=True,
498 cwd=str(member_path),
499 timeout=10,
500 )
501 if result.returncode == 0:
502 status_data = _json.loads(result.stdout)
503 dirty = bool(status_data.get("dirty", False))
504 actual_branch = status_data.get("branch") or None
505 head_commit = status_data.get("head_commit") or None
506 except Exception as exc:
507 logger.debug("Could not read status for member %r: %s", m["name"], exc)
508
509 # Stash count — one subprocess.
510 try:
511 result = subprocess.run(
512 ["muse", "stash", "list", "--json"],
513 capture_output=True,
514 text=True,
515 cwd=str(member_path),
516 timeout=10,
517 )
518 if result.returncode == 0:
519 stash_list = _json.loads(result.stdout)
520 if isinstance(stash_list, list):
521 stash_count = len(stash_list)
522 except Exception as exc:
523 logger.debug("Could not read stash list for member %r: %s", m["name"], exc)
524
525 # Feature branches — pure file I/O, no subprocess.
526 try:
527 heads_dir = member_path / ".muse" / "refs" / "heads"
528 if heads_dir.is_dir():
529 standard = {"main", "dev"}
530 feature_branches = sorted(
531 p.name
532 for p in heads_dir.iterdir()
533 if p.is_file() and p.name not in standard
534 )
535 except Exception as exc:
536 logger.debug("Could not read branches for member %r: %s", m["name"], exc)
537
538 return WorkspaceMemberStatus(
539 name=m["name"],
540 path=member_path,
541 branch=m["branch"],
542 url=m["url"],
543 present=present,
544 head_commit=head_commit,
545 dirty=dirty,
546 actual_branch=actual_branch,
547 stash_count=stash_count,
548 feature_branches=feature_branches,
549 )
550
551
552 def list_workspace_members(repo_root: pathlib.Path) -> list[WorkspaceMemberStatus]:
553 """Return status for every workspace member."""
554 manifest = _load_manifest(repo_root)
555 if manifest is None:
556 return []
557 return [_member_status(repo_root, m) for m in manifest["members"]]
558
559
560 def sync_workspace_member(
561 repo_root: pathlib.Path,
562 member: WorkspaceMemberDict,
563 dry_run: bool = False,
564 ) -> WorkspaceSyncResult:
565 """Clone or pull the latest state for one workspace member.
566
567 Returns a :class:`WorkspaceSyncResult` dict with ``name`` and ``status``.
568 ``status`` is one of ``'cloned'``, ``'pulled'``, ``'skipped'`` (dry-run),
569 or ``'error: <message>'``.
570
571 Security: ``url`` and ``branch`` are passed as separate list elements to
572 ``subprocess.run`` (never via the shell), so shell injection is not
573 possible. Size of error output is capped at 200 chars.
574 """
575 member_path = repo_root / member["path"]
576 name = member["name"]
577
578 if dry_run:
579 action = "clone" if (not member_path.exists() or not (member_path / ".muse").exists()) else "pull"
580 return WorkspaceSyncResult(name=name, status=f"skipped (dry-run would {action})")
581
582 if not member_path.exists() or not (member_path / ".muse").exists():
583 member_path.parent.mkdir(parents=True, exist_ok=True)
584 result = subprocess.run(
585 ["muse", "clone", member["url"], str(member_path)],
586 capture_output=True,
587 text=True,
588 timeout=300,
589 )
590 if result.returncode != 0:
591 err = result.stderr.strip()[:200]
592 logger.warning("⚠️ Clone failed for member %r: %s", name, err)
593 return WorkspaceSyncResult(name=name, status=f"error: {err}")
594 return WorkspaceSyncResult(name=name, status="cloned")
595
596 result = subprocess.run(
597 ["muse", "pull", "--branch", member["branch"]],
598 capture_output=True,
599 text=True,
600 cwd=str(member_path),
601 timeout=120,
602 )
603 if result.returncode != 0:
604 err = result.stderr.strip()[:200]
605 logger.warning("⚠️ Pull failed for member %r: %s", name, err)
606 return WorkspaceSyncResult(name=name, status=f"error: {err}")
607 return WorkspaceSyncResult(name=name, status="pulled")
608
609
610 def sync_workspace(
611 repo_root: pathlib.Path,
612 member_name: str | None = None,
613 dry_run: bool = False,
614 workers: int = 1,
615 ) -> list[WorkspaceSyncResult]:
616 """Sync all (or one named) workspace members.
617
618 Args:
619 repo_root: The workspace root.
620 member_name: Sync only this member (default: all).
621 dry_run: Show what would happen without doing it.
622 workers: Number of parallel sync workers (default: 1 — sequential).
623 Set to > 1 to parallelise across members.
624
625 Returns:
626 List of :class:`WorkspaceSyncResult` dicts, one per member synced.
627 """
628 manifest = _load_manifest(repo_root)
629 if manifest is None:
630 return []
631
632 targets = (
633 [m for m in manifest["members"] if m["name"] == member_name]
634 if member_name is not None
635 else manifest["members"]
636 )
637
638 if workers <= 1 or len(targets) <= 1:
639 return [sync_workspace_member(repo_root, m, dry_run=dry_run) for m in targets]
640
641 results: list[WorkspaceSyncResult] = []
642 effective_workers = min(workers, len(targets))
643 with concurrent.futures.ThreadPoolExecutor(max_workers=effective_workers) as pool:
644 futures = {
645 pool.submit(sync_workspace_member, repo_root, m, dry_run): m["name"]
646 for m in targets
647 }
648 for future in concurrent.futures.as_completed(futures):
649 try:
650 results.append(future.result())
651 except Exception as exc:
652 name = futures[future]
653 logger.warning("⚠️ Unexpected sync error for member %r: %s", name, exc)
654 results.append(WorkspaceSyncResult(name=name, status=f"error: {exc}"))
655 return results