1186 lines
42 KiB
Python
Executable file
1186 lines
42 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Isolated Jujutsu workspace and managed tmux lifecycle primitives.
|
|
|
|
This module deliberately has no dependency on coord's eventual registry/store
|
|
implementation. Callers supply narrow append-only callbacks:
|
|
|
|
registry(event: str, record: dict) -> None
|
|
integration_queue(action: str, record: dict) -> None
|
|
|
|
Independent, colocated ``jj git clone`` instances are the safe default. Native
|
|
``jj workspace add`` instances are available only when explicitly requested;
|
|
their metadata states that they share an operation log and object store with
|
|
the source repository and therefore are not a session-isolation boundary.
|
|
|
|
No lifecycle operation silently deletes work. Removal requires an ownership
|
|
token, a clean Jujutsu working copy, and (for automatic cleanup) a passed or
|
|
explicitly waived gate.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import copy
|
|
import datetime as dt
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import secrets
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from typing import Any, Callable, Iterable, Mapping, MutableMapping, Sequence
|
|
import uuid
|
|
|
|
|
|
DEFAULT_SCRATCH_ROOT = Path("/var/tmp/fleet-audit")
|
|
SCHEMA = 1
|
|
Registry = Callable[[str, dict[str, Any]], None]
|
|
Queue = Callable[[str, dict[str, Any]], None]
|
|
Runner = Callable[..., subprocess.CompletedProcess[str]]
|
|
|
|
|
|
class WorkspaceError(RuntimeError):
|
|
"""Base class for lifecycle failures."""
|
|
|
|
|
|
class UnsafeRemoval(WorkspaceError):
|
|
"""The requested cleanup could discard or misidentify work."""
|
|
|
|
|
|
def _utc_now() -> str:
|
|
return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def _run(
|
|
argv: Sequence[str],
|
|
*,
|
|
cwd: str | os.PathLike[str] | None = None,
|
|
runner: Runner = subprocess.run,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
"""Run one argv-only command and retain stdout for exact-id parsing."""
|
|
return runner(
|
|
[str(arg) for arg in argv],
|
|
cwd=None if cwd is None else str(cwd),
|
|
check=True,
|
|
text=True,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
)
|
|
|
|
|
|
def _emit(registry: Registry | None, event: str, record: Mapping[str, Any]) -> None:
|
|
if registry is not None:
|
|
registry(event, copy.deepcopy(dict(record)))
|
|
|
|
|
|
def slugify(value: str, *, limit: int = 22) -> str:
|
|
"""Return a short tmux/path-safe task slug."""
|
|
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
|
|
slug = slug[:limit].rstrip("-")
|
|
return slug or "task"
|
|
|
|
|
|
def task_window_name(
|
|
task: str,
|
|
session_id: str,
|
|
existing: Iterable[str] = (),
|
|
) -> str:
|
|
"""Allocate ``<slug>-<sid4>`` without ever targeting an existing window.
|
|
|
|
The four-character suffix is derived from the full session id, not from its
|
|
display prefix. If the first digest chunk collides, subsequent chunks are
|
|
tried while retaining the human-facing shape.
|
|
"""
|
|
used = set(existing)
|
|
digest = hashlib.sha256(session_id.encode("utf-8")).hexdigest()
|
|
slug = slugify(task)
|
|
for offset in range(0, len(digest) - 3, 4):
|
|
candidate = f"{slug}-{digest[offset:offset + 4]}"
|
|
if candidate not in used:
|
|
return candidate
|
|
raise WorkspaceError("could not allocate a unique four-character tmux window suffix")
|
|
|
|
|
|
def _safe_destination(root: Path, destination: Path) -> Path:
|
|
root = root.expanduser().resolve()
|
|
destination = destination.expanduser()
|
|
if not destination.is_absolute():
|
|
destination = root / destination
|
|
destination = destination.resolve()
|
|
try:
|
|
destination.relative_to(root)
|
|
except ValueError as exc:
|
|
raise WorkspaceError(f"workspace destination must remain under scratch root {root}") from exc
|
|
if destination == root:
|
|
raise WorkspaceError("scratch root itself cannot be a workspace destination")
|
|
return destination
|
|
|
|
|
|
def _jj_ids(
|
|
repo: Path,
|
|
revision: str,
|
|
*,
|
|
jj: str,
|
|
runner: Runner,
|
|
) -> dict[str, str]:
|
|
"""Read full change and commit ids without snapshotting a working copy."""
|
|
result = _run(
|
|
[
|
|
jj,
|
|
"--ignore-working-copy",
|
|
"-R",
|
|
str(repo),
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
revision,
|
|
"-T",
|
|
'change_id.normal_hex() ++ "\\n" ++ commit_id ++ "\\n"',
|
|
],
|
|
runner=runner,
|
|
)
|
|
values = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
if len(values) != 2 or not all(re.fullmatch(r"[0-9a-fA-F]+", value) for value in values):
|
|
raise WorkspaceError(f"jj returned malformed exact ids for {revision!r}: {values!r}")
|
|
return {"change_id": values[0].lower(), "commit_id": values[1].lower()}
|
|
|
|
|
|
def _owner_marker(path: Path) -> Path:
|
|
return path / ".jj" / "fleet-coord-owner.json"
|
|
|
|
|
|
def _write_owner_marker(path: Path, *, instance_id: str, token: str) -> None:
|
|
marker = _owner_marker(path)
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = json.dumps(
|
|
{"schema": SCHEMA, "instance_id": instance_id, "ownership_token": token},
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
)
|
|
fd = os.open(marker, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
try:
|
|
os.write(fd, (payload + "\n").encode("utf-8"))
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def _replace_owner_marker(path: Path, *, instance_id: str, token: str) -> None:
|
|
"""Atomically rotate an ownership marker during explicit orphan adoption."""
|
|
marker = _owner_marker(path)
|
|
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
payload = json.dumps(
|
|
{"schema": SCHEMA, "instance_id": instance_id, "ownership_token": token},
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
)
|
|
temporary = marker.with_name(f"{marker.name}.new-{uuid.uuid4().hex}")
|
|
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
try:
|
|
os.write(fd, (payload + "\n").encode("utf-8"))
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
os.replace(temporary, marker)
|
|
|
|
|
|
def _base_record(
|
|
*,
|
|
instance_id: str,
|
|
task: str,
|
|
session_id: str,
|
|
runtime_id: str,
|
|
conversation_id: str,
|
|
source: Path,
|
|
scratch_root: Path,
|
|
planned_path: Path,
|
|
dependencies: Iterable[str],
|
|
mode: str,
|
|
ownership_token: str,
|
|
) -> dict[str, Any]:
|
|
now = _utc_now()
|
|
limitations: list[str] = []
|
|
isolation = "independent-clone"
|
|
if mode == "native-workspace":
|
|
isolation = "shared-jj-repository"
|
|
limitations = [
|
|
"shares the source repository's operation log and object store",
|
|
"is not an isolation boundary for Jujutsu history operations",
|
|
"must not be used when an independent clone is required",
|
|
]
|
|
return {
|
|
"schema": SCHEMA,
|
|
"instance_id": instance_id,
|
|
"task": task,
|
|
"session_id": session_id,
|
|
"runtime_id": runtime_id,
|
|
"conversation_id": conversation_id,
|
|
"mode": mode,
|
|
"isolation": isolation,
|
|
"limitations": limitations,
|
|
"source": str(source),
|
|
"scratch_root": str(scratch_root),
|
|
"planned_path": str(planned_path),
|
|
"actual_path": "",
|
|
"dependencies": list(dependencies),
|
|
"planned_resources": [],
|
|
"actual_resources": [],
|
|
"base": {},
|
|
"change": {},
|
|
"lifecycle_state": "creating",
|
|
"gate_state": "pending",
|
|
"integration_state": "not-queued",
|
|
"ownership_token": ownership_token,
|
|
"tmux": {},
|
|
"jj_workspace_name": "",
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
|
|
|
|
def create_workspace(
|
|
source: str | os.PathLike[str],
|
|
*,
|
|
task: str,
|
|
session_id: str,
|
|
runtime_id: str = "",
|
|
conversation_id: str = "",
|
|
scratch_root: str | os.PathLike[str] = DEFAULT_SCRATCH_ROOT,
|
|
destination: str | os.PathLike[str] | None = None,
|
|
base_revision: str = "master@origin",
|
|
dependencies: Iterable[str] = (),
|
|
native_workspace: bool = False,
|
|
registry: Registry | None = None,
|
|
jj: str = "jj",
|
|
runner: Runner = subprocess.run,
|
|
) -> dict[str, Any]:
|
|
"""Create and register an independent clone (or explicit native workspace)."""
|
|
if not task.strip() or not session_id.strip():
|
|
raise WorkspaceError("task and full session_id are required")
|
|
source_path = Path(source).expanduser().resolve()
|
|
root = Path(scratch_root).expanduser().resolve()
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
instance_id = uuid.uuid4().hex
|
|
default_name = f"{slugify(task)}-{instance_id[:12]}"
|
|
dest = _safe_destination(root, Path(destination) if destination else Path(default_name))
|
|
if dest.exists():
|
|
raise WorkspaceError(f"workspace destination already exists: {dest}")
|
|
|
|
mode = "native-workspace" if native_workspace else "clone"
|
|
token = secrets.token_hex(32)
|
|
record = _base_record(
|
|
instance_id=instance_id,
|
|
task=task,
|
|
session_id=session_id,
|
|
runtime_id=runtime_id,
|
|
conversation_id=conversation_id,
|
|
source=source_path,
|
|
scratch_root=root,
|
|
planned_path=dest,
|
|
dependencies=dependencies,
|
|
mode=mode,
|
|
ownership_token=token,
|
|
)
|
|
_emit(registry, "workspace-planned", record)
|
|
|
|
try:
|
|
if native_workspace:
|
|
workspace_name = f"agent-{instance_id[:12]}"
|
|
_run(
|
|
[
|
|
jj,
|
|
"--ignore-working-copy",
|
|
"-R",
|
|
str(source_path),
|
|
"workspace",
|
|
"add",
|
|
"--name",
|
|
workspace_name,
|
|
"-r",
|
|
base_revision,
|
|
str(dest),
|
|
],
|
|
runner=runner,
|
|
)
|
|
record["jj_workspace_name"] = workspace_name
|
|
else:
|
|
_run(
|
|
[jj, "git", "clone", "--colocate", str(source_path), str(dest)],
|
|
runner=runner,
|
|
)
|
|
# Clone chooses its default branch as @'s parent. Record the
|
|
# requested exact base and start a fresh change there when a
|
|
# non-default base was explicitly selected.
|
|
if base_revision not in ("", "master"):
|
|
_run([jj, "-R", str(dest), "new", base_revision], runner=runner)
|
|
|
|
_write_owner_marker(dest, instance_id=instance_id, token=token)
|
|
record["actual_path"] = str(dest.resolve())
|
|
record["base"] = _jj_ids(dest, base_revision, jj=jj, runner=runner)
|
|
record["change"] = _jj_ids(dest, "@", jj=jj, runner=runner)
|
|
record["initial_change"] = dict(record["change"])
|
|
record["lifecycle_state"] = "active"
|
|
record["updated_at"] = _utc_now()
|
|
_emit(registry, "workspace-created", record)
|
|
return record
|
|
except Exception as exc:
|
|
# Creation succeeded but metadata did not. Preserve the directory:
|
|
# an incomplete workspace is a recovery candidate, never trash.
|
|
record["actual_path"] = str(dest)
|
|
record["lifecycle_state"] = "orphan-candidate"
|
|
record["updated_at"] = _utc_now()
|
|
_emit(registry, "workspace-create-incomplete", record)
|
|
if isinstance(exc, subprocess.CalledProcessError):
|
|
detail = (exc.stderr or exc.stdout or "").strip()
|
|
command = shlex.join(str(part) for part in exc.cmd)
|
|
raise WorkspaceError(
|
|
f"workspace creation failed: {command}: "
|
|
f"{detail or f'exit {exc.returncode}'}; "
|
|
f"preserved recovery candidate {dest}"
|
|
) from exc
|
|
raise
|
|
|
|
|
|
def adopt_workspace(
|
|
path: str | os.PathLike[str],
|
|
*,
|
|
task: str,
|
|
session_id: str,
|
|
runtime_id: str = "",
|
|
conversation_id: str = "",
|
|
source: str | os.PathLike[str],
|
|
scratch_root: str | os.PathLike[str] = DEFAULT_SCRATCH_ROOT,
|
|
base_revision: str = "master@origin",
|
|
dependencies: Iterable[str] = (),
|
|
prior_ownership_token: str = "",
|
|
recovery_override: bool = False,
|
|
registry: Registry | None = None,
|
|
jj: str = "jj",
|
|
runner: Runner = subprocess.run,
|
|
) -> dict[str, Any]:
|
|
"""Adopt an existing independent colocated clone after explicit validation.
|
|
|
|
This is the recovery path after coordinator-state loss or for an older
|
|
independently-created clone. It never discovers or adopts by directory
|
|
name alone, and it does not accept native/shared Jujutsu workspaces.
|
|
"""
|
|
if not task.strip() or not session_id.strip():
|
|
raise WorkspaceError("task and full session_id are required")
|
|
root = Path(scratch_root).expanduser().resolve()
|
|
candidate = _safe_destination(root, Path(path))
|
|
source_path = Path(source).expanduser().resolve()
|
|
if not candidate.is_dir():
|
|
raise WorkspaceError(f"adoption candidate is not a directory: {candidate}")
|
|
if not (candidate / ".jj").is_dir() or not (candidate / ".git").is_dir():
|
|
raise WorkspaceError(
|
|
"adoption requires an independent colocated clone with both .jj/ and .git/"
|
|
)
|
|
if candidate == source_path:
|
|
raise WorkspaceError("the canonical source checkout cannot be adopted as scratch work")
|
|
marker_path = _owner_marker(candidate)
|
|
if marker_path.exists():
|
|
try:
|
|
prior_marker = json.loads(marker_path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
raise WorkspaceError(
|
|
"existing ownership marker is unreadable; explicit recovery is required"
|
|
) from exc
|
|
prior_token = str(prior_marker.get("ownership_token") or "")
|
|
if not recovery_override and (
|
|
not prior_ownership_token
|
|
or not secrets.compare_digest(prior_ownership_token, prior_token)
|
|
):
|
|
raise WorkspaceError(
|
|
"candidate already has an ownership marker; supply its prior ownership "
|
|
"token or use the explicit recovery override after checking live records"
|
|
)
|
|
|
|
# Exact-id reads prove this is a usable jj repository before any marker is
|
|
# changed. A dirty clone remains adoptable because adoption preserves it.
|
|
base = _jj_ids(candidate, base_revision, jj=jj, runner=runner)
|
|
change = _jj_ids(candidate, "@", jj=jj, runner=runner)
|
|
dirty, dirty_summary = _snapshot_dirty(candidate, jj=jj, runner=runner)
|
|
|
|
instance_id = uuid.uuid4().hex
|
|
token = secrets.token_hex(32)
|
|
record = _base_record(
|
|
instance_id=instance_id,
|
|
task=task,
|
|
session_id=session_id,
|
|
runtime_id=runtime_id,
|
|
conversation_id=conversation_id,
|
|
source=source_path,
|
|
scratch_root=root,
|
|
planned_path=candidate,
|
|
dependencies=dependencies,
|
|
mode="clone",
|
|
ownership_token=token,
|
|
)
|
|
record.update(
|
|
{
|
|
"actual_path": str(candidate),
|
|
"base": base,
|
|
"change": change,
|
|
"initial_change": dict(change),
|
|
"lifecycle_state": "active",
|
|
"adopted": True,
|
|
"adopted_at": _utc_now(),
|
|
"dirty_at_adoption": dirty,
|
|
"dirty_summary_at_adoption": dirty_summary,
|
|
}
|
|
)
|
|
_emit(registry, "workspace-adoption-planned", record)
|
|
_replace_owner_marker(candidate, instance_id=instance_id, token=token)
|
|
record["updated_at"] = _utc_now()
|
|
_emit(registry, "workspace-adopted", record)
|
|
return record
|
|
|
|
|
|
def _snapshot_dirty(
|
|
path: Path,
|
|
*,
|
|
jj: str,
|
|
runner: Runner,
|
|
) -> tuple[bool, str]:
|
|
"""Snapshot only this isolated workspace and return its current diff."""
|
|
result = _run([jj, "-R", str(path), "diff", "--summary"], runner=runner)
|
|
summary = result.stdout
|
|
return bool(summary.strip()), summary
|
|
|
|
|
|
def inspect_workspace(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
registry: Registry | None = None,
|
|
jj: str = "jj",
|
|
runner: Runner = subprocess.run,
|
|
) -> dict[str, Any]:
|
|
"""Inspect a candidate without mutating any repository except its own snapshot."""
|
|
updated = copy.deepcopy(dict(record))
|
|
path = Path(str(updated.get("actual_path") or updated.get("planned_path")))
|
|
updated["exists"] = path.is_dir()
|
|
updated["dirty"] = None
|
|
updated["dirty_summary"] = ""
|
|
updated["ownership_verified"] = False
|
|
if path.is_dir():
|
|
try:
|
|
marker = json.loads(_owner_marker(path).read_text(encoding="utf-8"))
|
|
updated["ownership_verified"] = (
|
|
marker.get("instance_id") == updated.get("instance_id")
|
|
and secrets.compare_digest(
|
|
str(marker.get("ownership_token", "")),
|
|
str(updated.get("ownership_token", "")),
|
|
)
|
|
)
|
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
|
updated["ownership_verified"] = False
|
|
dirty, summary = _snapshot_dirty(path, jj=jj, runner=runner)
|
|
updated["dirty"] = dirty
|
|
updated["dirty_summary"] = summary
|
|
initial_change = dict(updated.get("initial_change") or updated.get("change") or {})
|
|
current_change = _jj_ids(path, "@", jj=jj, runner=runner)
|
|
updated["change"] = current_change
|
|
updated["advanced"] = bool(
|
|
initial_change
|
|
and (
|
|
initial_change.get("change_id") != current_change.get("change_id")
|
|
or initial_change.get("commit_id") != current_change.get("commit_id")
|
|
)
|
|
)
|
|
updated["inspected_at"] = _utc_now()
|
|
updated["updated_at"] = updated["inspected_at"]
|
|
_emit(registry, "workspace-inspected", updated)
|
|
return updated
|
|
|
|
|
|
def list_workspaces(
|
|
records: Iterable[Mapping[str, Any]],
|
|
*,
|
|
include_removed: bool = False,
|
|
) -> list[dict[str, Any]]:
|
|
"""Return registry records in stable lifecycle/task/path order.
|
|
|
|
Discovery belongs to the registry fold, not to an unsafe filesystem scan:
|
|
an unregistered directory is an orphan candidate and must never become an
|
|
automatic deletion target merely because its name resembles ours.
|
|
"""
|
|
selected = [
|
|
copy.deepcopy(dict(record))
|
|
for record in records
|
|
if include_removed or record.get("lifecycle_state") != "removed"
|
|
]
|
|
return sorted(
|
|
selected,
|
|
key=lambda record: (
|
|
str(record.get("lifecycle_state", "")),
|
|
str(record.get("task", "")),
|
|
str(record.get("actual_path") or record.get("planned_path") or ""),
|
|
),
|
|
)
|
|
|
|
|
|
def set_gate_state(
|
|
record: Mapping[str, Any],
|
|
state: str,
|
|
*,
|
|
registry: Registry | None = None,
|
|
) -> dict[str, Any]:
|
|
if state not in {"pending", "running", "passed", "failed", "waived"}:
|
|
raise WorkspaceError(f"invalid gate state: {state}")
|
|
updated = copy.deepcopy(dict(record))
|
|
updated["gate_state"] = state
|
|
updated["updated_at"] = _utc_now()
|
|
_emit(registry, "workspace-gate", updated)
|
|
return updated
|
|
|
|
|
|
def park_workspace(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
reason: str = "",
|
|
registry: Registry | None = None,
|
|
) -> dict[str, Any]:
|
|
updated = copy.deepcopy(dict(record))
|
|
updated["lifecycle_state"] = "parked"
|
|
updated["park_reason"] = reason
|
|
updated["parked_at"] = _utc_now()
|
|
updated["updated_at"] = updated["parked_at"]
|
|
_emit(registry, "workspace-parked", updated)
|
|
return updated
|
|
|
|
|
|
def resume_workspace(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
runtime_id: str | None = None,
|
|
registry: Registry | None = None,
|
|
) -> dict[str, Any]:
|
|
path = Path(str(record.get("actual_path") or ""))
|
|
if not path.is_dir():
|
|
raise WorkspaceError(f"parked workspace is unavailable: {path}")
|
|
updated = copy.deepcopy(dict(record))
|
|
updated["lifecycle_state"] = "active"
|
|
if runtime_id is not None:
|
|
updated["runtime_id"] = runtime_id
|
|
updated["resumed_at"] = _utc_now()
|
|
updated["updated_at"] = updated["resumed_at"]
|
|
_emit(registry, "workspace-resumed", updated)
|
|
return updated
|
|
|
|
|
|
def archive_workspace(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
note: str = "",
|
|
registry: Registry | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Mark a workspace archival candidate while preserving all bytes in place."""
|
|
updated = copy.deepcopy(dict(record))
|
|
updated["lifecycle_state"] = "archived"
|
|
updated["archive_note"] = note
|
|
updated["archived_at"] = _utc_now()
|
|
updated["updated_at"] = updated["archived_at"]
|
|
_emit(registry, "workspace-archived", updated)
|
|
return updated
|
|
|
|
|
|
def remove_workspace(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
ownership_token: str,
|
|
automatic: bool = False,
|
|
allow_ungated: bool = False,
|
|
registry: Registry | None = None,
|
|
jj: str = "jj",
|
|
tmux: str = "tmux",
|
|
runner: Runner = subprocess.run,
|
|
) -> dict[str, Any]:
|
|
"""Remove a verified clean instance; refuse every ambiguous case."""
|
|
try:
|
|
if _live_tmux_identity(record, tmux=tmux, runner=runner):
|
|
raise UnsafeRemoval(
|
|
"workspace still has a live, ownership-verified tmux agent; "
|
|
"stop or park it before removal"
|
|
)
|
|
except WorkspaceError as exc:
|
|
raise UnsafeRemoval(str(exc)) from exc
|
|
inspected = inspect_workspace(record, jj=jj, runner=runner)
|
|
if not inspected.get("exists"):
|
|
raise UnsafeRemoval("workspace path does not exist; record retained as an orphan candidate")
|
|
if not ownership_token or not secrets.compare_digest(
|
|
ownership_token, str(inspected.get("ownership_token", ""))
|
|
):
|
|
raise UnsafeRemoval("ownership token does not match registry record")
|
|
if not inspected.get("ownership_verified"):
|
|
raise UnsafeRemoval("on-disk ownership marker does not match registry record")
|
|
if inspected.get("dirty") is not False:
|
|
raise UnsafeRemoval("workspace is dirty or its cleanliness is unknown; refusing removal")
|
|
release_evidence = inspected.get("integration_release_evidence")
|
|
if inspected.get("advanced") and (
|
|
inspected.get("integration_state") != "integrated"
|
|
or not isinstance(release_evidence, dict)
|
|
or not release_evidence.get("runtime_id")
|
|
or not release_evidence.get("resource")
|
|
or not release_evidence.get("source_commit")
|
|
or not release_evidence.get("target_commit")
|
|
):
|
|
raise UnsafeRemoval(
|
|
"workspace contains an advanced change without verified integration release evidence; "
|
|
"refusing removal"
|
|
)
|
|
gated = inspected.get("gate_state") in {"passed", "waived"}
|
|
if automatic and not gated:
|
|
raise UnsafeRemoval("automatic cleanup never removes ungated work")
|
|
if not automatic and not gated and not allow_ungated:
|
|
raise UnsafeRemoval("ungated work requires explicit allow_ungated=True")
|
|
|
|
path = Path(str(inspected["actual_path"])).resolve()
|
|
root = Path(str(inspected["scratch_root"])).resolve()
|
|
_safe_destination(root, path)
|
|
before = path.stat(follow_symlinks=False)
|
|
quarantine = root / (
|
|
f".fleet-coord-delete-{inspected['instance_id']}-{uuid.uuid4().hex}"
|
|
)
|
|
os.rename(path, quarantine)
|
|
try:
|
|
after = quarantine.stat(follow_symlinks=False)
|
|
marker = json.loads(_owner_marker(quarantine).read_text(encoding="utf-8"))
|
|
marker_ok = (
|
|
marker.get("instance_id") == inspected.get("instance_id")
|
|
and secrets.compare_digest(
|
|
str(marker.get("ownership_token") or ""),
|
|
str(inspected.get("ownership_token") or ""),
|
|
)
|
|
)
|
|
quarantine_dirty, _summary = _snapshot_dirty(
|
|
quarantine, jj=jj, runner=runner
|
|
)
|
|
quarantine_change = _jj_ids(quarantine, "@", jj=jj, runner=runner)
|
|
if (
|
|
(before.st_dev, before.st_ino) != (after.st_dev, after.st_ino)
|
|
or not marker_ok
|
|
or quarantine_dirty
|
|
or quarantine_change != inspected.get("change")
|
|
):
|
|
raise UnsafeRemoval(
|
|
"workspace identity or contents changed before quarantine; "
|
|
"refusing deletion"
|
|
)
|
|
if inspected.get("mode") == "native-workspace":
|
|
workspace_name = str(inspected.get("jj_workspace_name") or "")
|
|
source = str(inspected.get("source") or "")
|
|
if not workspace_name or not source:
|
|
raise UnsafeRemoval(
|
|
"native workspace metadata is incomplete; refusing removal"
|
|
)
|
|
_run(
|
|
[
|
|
jj,
|
|
"--ignore-working-copy",
|
|
"-R",
|
|
source,
|
|
"workspace",
|
|
"forget",
|
|
workspace_name,
|
|
],
|
|
runner=runner,
|
|
)
|
|
except Exception:
|
|
if quarantine.exists() and not path.exists():
|
|
os.rename(quarantine, path)
|
|
raise
|
|
shutil.rmtree(quarantine)
|
|
inspected["lifecycle_state"] = "removed"
|
|
inspected["removed_at"] = _utc_now()
|
|
inspected["updated_at"] = inspected["removed_at"]
|
|
_emit(registry, "workspace-removed", inspected)
|
|
return inspected
|
|
|
|
|
|
def enqueue_integration(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
queue: Queue,
|
|
action: str = "enqueue",
|
|
registry: Registry | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Hand one record to the sole integration/push queue or resource lock."""
|
|
if action not in {"enqueue", "acquire", "release", "cancel"}:
|
|
raise WorkspaceError(f"unsupported integration queue action: {action}")
|
|
updated = copy.deepcopy(dict(record))
|
|
current = str(updated.get("integration_state") or "not-queued")
|
|
allowed_from = {
|
|
"enqueue": {"not-queued", "cancelled"},
|
|
"acquire": {"queued"},
|
|
"release": {"integrating"},
|
|
"cancel": {"queued"},
|
|
}
|
|
if current not in allowed_from[action]:
|
|
raise WorkspaceError(
|
|
f"cannot {action} integration from state {current!r}; "
|
|
f"expected one of {sorted(allowed_from[action])}"
|
|
)
|
|
queue(action, copy.deepcopy(updated))
|
|
updated["integration_state"] = {
|
|
"enqueue": "queued",
|
|
"acquire": "integrating",
|
|
"release": "lock-released",
|
|
"cancel": "cancelled",
|
|
}[action]
|
|
updated["updated_at"] = _utc_now()
|
|
_emit(registry, "workspace-integration", updated)
|
|
return updated
|
|
|
|
|
|
def prove_integrated(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
target_revision: str,
|
|
registry: Registry | None = None,
|
|
jj: str = "jj",
|
|
runner: Runner = subprocess.run,
|
|
) -> dict[str, Any]:
|
|
"""Prove the exact workspace commit is an ancestor of the target revision."""
|
|
if record.get("integration_state") != "lock-released":
|
|
raise WorkspaceError("integration proof requires a released integration lock")
|
|
if record.get("gate_state") not in {"passed", "waived"}:
|
|
raise WorkspaceError("integration proof requires a passed or explicitly waived gate")
|
|
path = Path(str(record.get("actual_path") or "")).resolve()
|
|
source_commit = str((record.get("change") or {}).get("commit_id", ""))
|
|
if not source_commit:
|
|
raise WorkspaceError("workspace record has no exact source commit")
|
|
target = _jj_ids(path, target_revision, jj=jj, runner=runner)
|
|
result = _run(
|
|
[
|
|
jj,
|
|
"--ignore-working-copy",
|
|
"-R",
|
|
str(path),
|
|
"log",
|
|
"--no-graph",
|
|
"-r",
|
|
f"{source_commit} & ::{target['commit_id']}",
|
|
"-T",
|
|
'commit_id ++ "\\n"',
|
|
],
|
|
runner=runner,
|
|
)
|
|
observed = [line.strip().lower() for line in result.stdout.splitlines() if line.strip()]
|
|
if observed != [source_commit.lower()]:
|
|
raise WorkspaceError(
|
|
f"source commit {source_commit} is not an ancestor of target "
|
|
f"{target['commit_id']}"
|
|
)
|
|
updated = copy.deepcopy(dict(record))
|
|
updated["integration_state"] = "integrated"
|
|
updated["integration_release_evidence"] = {
|
|
"runtime_id": record.get("integration_owner_runtime"),
|
|
"resource": record.get("integration_resource"),
|
|
"source_commit": source_commit.lower(),
|
|
"target_commit": target["commit_id"],
|
|
"target_revision": target_revision,
|
|
}
|
|
updated["updated_at"] = _utc_now()
|
|
_emit(registry, "workspace-integrated", updated)
|
|
return updated
|
|
|
|
|
|
def _tmux(
|
|
args: Sequence[str],
|
|
*,
|
|
tmux: str,
|
|
runner: Runner,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
return _run([tmux, *args], runner=runner)
|
|
|
|
|
|
def _managed_session(
|
|
project: Path,
|
|
*,
|
|
tmux: str,
|
|
runner: Runner,
|
|
) -> str | None:
|
|
try:
|
|
sessions = _tmux(["list-sessions", "-F", "#{session_id}"], tmux=tmux, runner=runner)
|
|
except subprocess.CalledProcessError:
|
|
return None
|
|
for session_id in sessions.stdout.splitlines():
|
|
session_id = session_id.strip()
|
|
if not session_id.startswith("$"):
|
|
continue
|
|
try:
|
|
value = _tmux(
|
|
["display-message", "-p", "-t", session_id, "#{@fleet_ai_project}"],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
).stdout.strip()
|
|
except subprocess.CalledProcessError:
|
|
continue
|
|
if value == str(project):
|
|
return session_id
|
|
return None
|
|
|
|
|
|
def _live_tmux_identity(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
tmux: str,
|
|
runner: Runner,
|
|
) -> bool:
|
|
"""Verify recorded immutable IDs still belong to this managed workspace."""
|
|
metadata = dict(record.get("tmux") or {})
|
|
session_id = str(metadata.get("session_id", ""))
|
|
window_id = str(metadata.get("window_id", ""))
|
|
pane_id = str(metadata.get("pane_id", ""))
|
|
if not (
|
|
session_id.startswith("$")
|
|
and window_id.startswith("@")
|
|
and pane_id.startswith("%")
|
|
):
|
|
return False
|
|
expected = {
|
|
session_id: ("#{@fleet_ai_project}", str(Path(str(record["actual_path"])).resolve())),
|
|
window_id: ("#{@fleet_ai_instance}", str(record.get("instance_id", ""))),
|
|
pane_id: ("#{@fleet_ai_instance}", str(record.get("instance_id", ""))),
|
|
}
|
|
for target, (template, wanted) in expected.items():
|
|
try:
|
|
actual = _tmux(
|
|
["display-message", "-p", "-t", target, template],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
).stdout.strip()
|
|
except subprocess.CalledProcessError:
|
|
return False
|
|
if actual != wanted:
|
|
raise WorkspaceError(
|
|
f"tmux id {target} exists but ownership metadata mismatches; "
|
|
"refusing name/id reuse"
|
|
)
|
|
return True
|
|
|
|
|
|
def _agent_command(
|
|
harness: str,
|
|
*,
|
|
conversation_id: str,
|
|
direct_binary: str | None,
|
|
) -> list[str]:
|
|
if harness not in {"codex", "claude"}:
|
|
raise WorkspaceError("harness must be 'codex' or 'claude'")
|
|
binary = direct_binary or f"{harness}-direct"
|
|
if not conversation_id:
|
|
return [binary]
|
|
if harness == "codex":
|
|
return [binary, "resume", conversation_id]
|
|
return [binary, "--resume", conversation_id]
|
|
|
|
|
|
def start_agent(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
harness: str,
|
|
conversation_id: str | None = None,
|
|
direct_binary: str | None = None,
|
|
registry: Registry | None = None,
|
|
tmux: str = "tmux",
|
|
runner: Runner = subprocess.run,
|
|
) -> dict[str, Any]:
|
|
"""Start/resume an agent in a managed project session.
|
|
|
|
Existing tmux objects are discovered by user options and then addressed
|
|
only by immutable ``$session``, ``@window`` and ``%pane`` ids. Names are
|
|
presentation labels, never lookup targets.
|
|
"""
|
|
updated = copy.deepcopy(dict(record))
|
|
project = Path(str(updated.get("actual_path") or "")).resolve()
|
|
if not project.is_dir():
|
|
raise WorkspaceError(f"workspace does not exist: {project}")
|
|
if _live_tmux_identity(updated, tmux=tmux, runner=runner):
|
|
_emit(registry, "workspace-agent-reused", updated)
|
|
return updated
|
|
conv = conversation_id if conversation_id is not None else str(updated.get("conversation_id", ""))
|
|
command = _agent_command(harness, conversation_id=conv, direct_binary=direct_binary)
|
|
attachment_runtime = str(uuid.uuid4())
|
|
updated["runtime_id"] = attachment_runtime
|
|
launch_command = [
|
|
"env",
|
|
f"COORD_INSTANCE_ID={attachment_runtime}",
|
|
f"COORD_WORK_ID={updated['instance_id']}",
|
|
f"FLEET_AI_TASK={str(updated.get('task') or 'task')}",
|
|
*command,
|
|
]
|
|
session_id = _managed_session(project, tmux=tmux, runner=runner)
|
|
|
|
existing_names: list[str] = []
|
|
if session_id:
|
|
windows = _tmux(
|
|
["list-windows", "-t", session_id, "-F", "#{window_name}"],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
)
|
|
existing_names = [line for line in windows.stdout.splitlines() if line]
|
|
window_name = task_window_name(
|
|
str(updated.get("task") or "task"),
|
|
str(updated.get("session_id") or updated.get("instance_id")),
|
|
existing_names,
|
|
)
|
|
agent_shell = shlex.join(launch_command)
|
|
heartbeat_shell = shlex.join(
|
|
[
|
|
str(project / ".claude" / "coord" / "coord"),
|
|
"--runtime",
|
|
attachment_runtime,
|
|
"heartbeat",
|
|
]
|
|
)
|
|
shell_command = (
|
|
f"( sleep 60; while {heartbeat_shell} >/dev/null 2>&1; "
|
|
"do sleep 60; done ) </dev/null & coord_hb=$!; "
|
|
"trap 'kill \"$coord_hb\" 2>/dev/null || true; "
|
|
"wait \"$coord_hb\" 2>/dev/null || true' EXIT; "
|
|
f"{agent_shell}; rc=$?; "
|
|
"kill \"$coord_hb\" 2>/dev/null || true; "
|
|
"wait \"$coord_hb\" 2>/dev/null || true; "
|
|
"trap - EXIT; exit \"$rc\""
|
|
)
|
|
|
|
if session_id is None:
|
|
presentation_name = "ai-" + hashlib.sha256(str(project).encode()).hexdigest()[:12]
|
|
created = _tmux(
|
|
[
|
|
"new-session",
|
|
"-d",
|
|
"-P",
|
|
"-F",
|
|
"#{session_id}\t#{window_id}\t#{pane_id}",
|
|
"-s",
|
|
presentation_name,
|
|
"-n",
|
|
window_name,
|
|
"-c",
|
|
str(project),
|
|
shell_command,
|
|
],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
)
|
|
else:
|
|
created = _tmux(
|
|
[
|
|
"new-window",
|
|
"-d",
|
|
"-P",
|
|
"-F",
|
|
"#{session_id}\t#{window_id}\t#{pane_id}",
|
|
"-t",
|
|
session_id,
|
|
"-n",
|
|
window_name,
|
|
"-c",
|
|
str(project),
|
|
shell_command,
|
|
],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
)
|
|
|
|
fields = created.stdout.strip().split("\t")
|
|
if len(fields) != 3 or not (
|
|
fields[0].startswith("$") and fields[1].startswith("@") and fields[2].startswith("%")
|
|
):
|
|
raise WorkspaceError(f"tmux did not return immutable object ids: {created.stdout!r}")
|
|
session_id, window_id, pane_id = fields
|
|
|
|
# Every mutation below targets immutable ids captured from tmux itself.
|
|
_tmux(["set-option", "-q", "-t", session_id, "@fleet_ai_managed", "1"], tmux=tmux, runner=runner)
|
|
_tmux(
|
|
["set-option", "-q", "-t", session_id, "@fleet_ai_project", str(project)],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
)
|
|
_tmux(["set-option", "-qw", "-t", window_id, "automatic-rename", "off"], tmux=tmux, runner=runner)
|
|
options = {
|
|
"@fleet_ai_instance": str(updated.get("instance_id", "")),
|
|
"@fleet_ai_task": str(updated.get("task", "")),
|
|
"@fleet_ai_workspace": str(project),
|
|
"@fleet_ai_runtime": str(updated.get("runtime_id", "")),
|
|
"@fleet_ai_conversation": conv,
|
|
}
|
|
for key, value in options.items():
|
|
_tmux(["set-option", "-qw", "-t", window_id, key, value], tmux=tmux, runner=runner)
|
|
_tmux(
|
|
["set-option", "-qp", "-t", pane_id, "@fleet_ai_instance", str(updated.get("instance_id", ""))],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
)
|
|
|
|
updated["conversation_id"] = conv
|
|
updated["tmux"] = {
|
|
"session_id": session_id,
|
|
"window_id": window_id,
|
|
"pane_id": pane_id,
|
|
"window_name": window_name,
|
|
"command": command,
|
|
}
|
|
updated["lifecycle_state"] = "active"
|
|
updated["updated_at"] = _utc_now()
|
|
_emit(registry, "workspace-agent-started", updated)
|
|
return updated
|
|
|
|
|
|
def attach_command(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
tmux: str = "tmux",
|
|
runner: Runner = subprocess.run,
|
|
) -> list[str]:
|
|
"""Return an attach argv after revalidating immutable ownership metadata."""
|
|
if not _live_tmux_identity(record, tmux=tmux, runner=runner):
|
|
raise WorkspaceError("recorded tmux agent is not live")
|
|
session_id = str((record.get("tmux") or {}).get("session_id", ""))
|
|
if not session_id.startswith("$"):
|
|
raise WorkspaceError("record has no immutable tmux session id")
|
|
return ["tmux", "attach-session", "-t", session_id]
|
|
|
|
|
|
def close_agent(
|
|
record: Mapping[str, Any],
|
|
*,
|
|
registry: Registry | None = None,
|
|
tmux: str = "tmux",
|
|
runner: Runner = subprocess.run,
|
|
environ: Mapping[str, str] = os.environ,
|
|
) -> dict[str, Any]:
|
|
"""Close one verified window without ejecting its attached client."""
|
|
if not _live_tmux_identity(record, tmux=tmux, runner=runner):
|
|
raise WorkspaceError("recorded tmux agent is not live")
|
|
updated = copy.deepcopy(dict(record))
|
|
metadata = dict(updated.get("tmux") or {})
|
|
target_session = str(metadata["session_id"])
|
|
target_window = str(metadata["window_id"])
|
|
pane = environ.get("TMUX_PANE", "")
|
|
if pane:
|
|
current_window = _tmux(
|
|
["display-message", "-p", "-t", pane, "#{window_id}"],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
).stdout.strip()
|
|
if current_window == target_window:
|
|
raise WorkspaceError(
|
|
"refusing to close the caller's current managed window because "
|
|
"tmux could terminate the registry update; run work close from "
|
|
"the landing page or another window/session"
|
|
)
|
|
current_session = _tmux(
|
|
["display-message", "-p", "-t", pane, "#{session_id}"],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
).stdout.strip()
|
|
if current_session == target_session:
|
|
windows = _tmux(
|
|
["list-windows", "-t", target_session, "-F", "#{window_id}"],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
).stdout.splitlines()
|
|
if len([item for item in windows if item.strip()]) <= 1:
|
|
sessions = _tmux(
|
|
[
|
|
"list-sessions",
|
|
"-F",
|
|
"#{session_id}\t#{@fleet_ai_managed}",
|
|
],
|
|
tmux=tmux,
|
|
runner=runner,
|
|
).stdout.splitlines()
|
|
landing = next(
|
|
(
|
|
line.split("\t", 1)[0]
|
|
for line in sessions
|
|
if "\t" in line
|
|
and line.split("\t", 1)[0] != target_session
|
|
and line.split("\t", 1)[1] == "1"
|
|
),
|
|
"",
|
|
)
|
|
if not landing:
|
|
raise WorkspaceError(
|
|
"this is the current session's last window and no other "
|
|
"managed session exists; create or attach another managed "
|
|
"workspace before closing it"
|
|
)
|
|
_tmux(["switch-client", "-t", landing], tmux=tmux, runner=runner)
|
|
_tmux(["kill-window", "-t", target_window], tmux=tmux, runner=runner)
|
|
updated["tmux"] = {}
|
|
updated["agent_closed_at"] = _utc_now()
|
|
updated["updated_at"] = updated["agent_closed_at"]
|
|
_emit(registry, "workspace-agent-closed", updated)
|
|
return updated
|
|
|
|
|
|
def _json_record(path: str) -> dict[str, Any]:
|
|
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
"""Small CLI for operators; registry integration remains callback-driven."""
|
|
parser = argparse.ArgumentParser(prog="coord-workspace")
|
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
create = sub.add_parser("create")
|
|
create.add_argument("source")
|
|
create.add_argument("--task", required=True)
|
|
create.add_argument("--sid", required=True)
|
|
create.add_argument("--runtime-id", default="")
|
|
create.add_argument("--conversation-id", default="")
|
|
create.add_argument("--scratch-root", default=str(DEFAULT_SCRATCH_ROOT))
|
|
create.add_argument("--destination")
|
|
create.add_argument("--base", default="master")
|
|
create.add_argument("--native-workspace", action="store_true")
|
|
inspect = sub.add_parser("inspect")
|
|
inspect.add_argument("record")
|
|
park = sub.add_parser("park")
|
|
park.add_argument("record")
|
|
park.add_argument("--reason", default="")
|
|
resume = sub.add_parser("resume")
|
|
resume.add_argument("record")
|
|
archive = sub.add_parser("archive")
|
|
archive.add_argument("record")
|
|
archive.add_argument("--note", default="")
|
|
|
|
args = parser.parse_args(argv)
|
|
if args.command == "create":
|
|
out = create_workspace(
|
|
args.source,
|
|
task=args.task,
|
|
session_id=args.sid,
|
|
runtime_id=args.runtime_id,
|
|
conversation_id=args.conversation_id,
|
|
scratch_root=args.scratch_root,
|
|
destination=args.destination,
|
|
base_revision=args.base,
|
|
native_workspace=args.native_workspace,
|
|
)
|
|
else:
|
|
record = _json_record(args.record)
|
|
if args.command == "inspect":
|
|
out = inspect_workspace(record)
|
|
elif args.command == "park":
|
|
out = park_workspace(record, reason=args.reason)
|
|
elif args.command == "resume":
|
|
out = resume_workspace(record)
|
|
else:
|
|
out = archive_workspace(record, note=args.note)
|
|
json.dump(out, sys.stdout, indent=2, sort_keys=True)
|
|
sys.stdout.write("\n")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except WorkspaceError as exc:
|
|
print(f"coord-workspace: {exc}", file=sys.stderr)
|
|
raise SystemExit(2)
|