Implements the Lazarus Pit v2.0 foundation: - cell.py: Cell model, SQLite registry, filesystem packager with per-cell HERMES_HOME - operator_ctl.py: summon, invite, team, status, close, destroy commands - backends/base.py + process_backend.py: backend abstraction with process implementation - cli.py: operator CLI entrypoint - tests/test_cell.py: 13 tests for isolation, registry, TTL, lifecycle - README.md: quick start and architecture invariants All acceptance criteria for #274 and #269 are scaffolded and tested. 13 tests pass.
241 lines
8.0 KiB
Python
241 lines
8.0 KiB
Python
"""
|
|
Cell model for Lazarus Pit v2.0.
|
|
|
|
A cell is a bounded execution/living space for one or more participants
|
|
on a single project target. Cells are isolated by filesystem, credentials,
|
|
and optionally process/container boundaries.
|
|
"""
|
|
|
|
import json
|
|
import shutil
|
|
import uuid
|
|
from dataclasses import dataclass, field, asdict
|
|
from datetime import datetime, timedelta
|
|
from enum import Enum, auto
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Set
|
|
|
|
|
|
class CellState(Enum):
|
|
PROPOSED = "proposed"
|
|
ACTIVE = "active"
|
|
IDLE = "idle"
|
|
CLOSING = "closing"
|
|
ARCHIVED = "archived"
|
|
DESTROYED = "destroyed"
|
|
|
|
|
|
class CellRole(Enum):
|
|
EXECUTOR = "executor" # Can read/write code, run tools, push commits
|
|
OBSERVER = "observer" # Read-only cell access
|
|
DIRECTOR = "director" # Can assign, close, publish back to Gitea
|
|
|
|
|
|
@dataclass
|
|
class CellMember:
|
|
identity: str # e.g. "agent:allegro", "bot:kimi", "human:Alexander"
|
|
role: CellRole
|
|
joined_at: str
|
|
|
|
|
|
@dataclass
|
|
class Cell:
|
|
cell_id: str
|
|
project: str # e.g. "Timmy_Foundation/timmy-config#262"
|
|
owner: str # e.g. "human:Alexander"
|
|
backend: str # "process", "venv", "docker", "remote"
|
|
state: CellState
|
|
created_at: str
|
|
ttl_minutes: int
|
|
members: List[CellMember] = field(default_factory=list)
|
|
hermes_home: Optional[str] = None
|
|
workspace_path: Optional[str] = None
|
|
shared_notes_path: Optional[str] = None
|
|
shared_artifacts_path: Optional[str] = None
|
|
decisions_path: Optional[str] = None
|
|
archived_at: Optional[str] = None
|
|
destroyed_at: Optional[str] = None
|
|
|
|
def is_expired(self) -> bool:
|
|
if self.state in (CellState.CLOSING, CellState.ARCHIVED, CellState.DESTROYED):
|
|
return False
|
|
created = datetime.fromisoformat(self.created_at.replace("Z", "+00:00"))
|
|
return datetime.now().astimezone() > created + timedelta(minutes=self.ttl_minutes)
|
|
|
|
def to_dict(self) -> dict:
|
|
d = asdict(self)
|
|
d["state"] = self.state.value
|
|
d["members"] = [
|
|
{**asdict(m), "role": m.role.value} for m in self.members
|
|
]
|
|
return d
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "Cell":
|
|
d = dict(d)
|
|
d["state"] = CellState(d["state"])
|
|
d["members"] = [
|
|
CellMember(
|
|
identity=m["identity"],
|
|
role=CellRole(m["role"]),
|
|
joined_at=m["joined_at"],
|
|
)
|
|
for m in d.get("members", [])
|
|
]
|
|
return cls(**d)
|
|
|
|
|
|
class CellRegistry:
|
|
"""SQLite-backed registry of all cells."""
|
|
|
|
def __init__(self, db_path: Optional[str] = None):
|
|
self.db_path = Path(db_path or "/root/.lazarus_registry.sqlite")
|
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
self._init_db()
|
|
|
|
def _init_db(self):
|
|
import sqlite3
|
|
with sqlite3.connect(str(self.db_path)) as conn:
|
|
conn.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS cells (
|
|
cell_id TEXT PRIMARY KEY,
|
|
project TEXT NOT NULL,
|
|
owner TEXT NOT NULL,
|
|
backend TEXT NOT NULL,
|
|
state TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
ttl_minutes INTEGER NOT NULL,
|
|
members TEXT,
|
|
hermes_home TEXT,
|
|
workspace_path TEXT,
|
|
shared_notes_path TEXT,
|
|
shared_artifacts_path TEXT,
|
|
decisions_path TEXT,
|
|
archived_at TEXT,
|
|
destroyed_at TEXT
|
|
)
|
|
"""
|
|
)
|
|
|
|
def save(self, cell: Cell):
|
|
import sqlite3
|
|
with sqlite3.connect(str(self.db_path)) as conn:
|
|
conn.execute(
|
|
"""
|
|
INSERT OR REPLACE INTO cells (
|
|
cell_id, project, owner, backend, state, created_at, ttl_minutes,
|
|
members, hermes_home, workspace_path, shared_notes_path,
|
|
shared_artifacts_path, decisions_path, archived_at, destroyed_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""",
|
|
(
|
|
cell.cell_id,
|
|
cell.project,
|
|
cell.owner,
|
|
cell.backend,
|
|
cell.state.value,
|
|
cell.created_at,
|
|
cell.ttl_minutes,
|
|
json.dumps(cell.to_dict()["members"]),
|
|
cell.hermes_home,
|
|
cell.workspace_path,
|
|
cell.shared_notes_path,
|
|
cell.shared_artifacts_path,
|
|
cell.decisions_path,
|
|
cell.archived_at,
|
|
cell.destroyed_at,
|
|
),
|
|
)
|
|
|
|
def load(self, cell_id: str) -> Optional[Cell]:
|
|
import sqlite3
|
|
with sqlite3.connect(str(self.db_path)) as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM cells WHERE cell_id = ?", (cell_id,)
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
keys = [
|
|
"cell_id", "project", "owner", "backend", "state", "created_at",
|
|
"ttl_minutes", "members", "hermes_home", "workspace_path",
|
|
"shared_notes_path", "shared_artifacts_path", "decisions_path",
|
|
"archived_at", "destroyed_at",
|
|
]
|
|
d = dict(zip(keys, row))
|
|
d["members"] = json.loads(d["members"])
|
|
return Cell.from_dict(d)
|
|
|
|
def list_active(self) -> List[Cell]:
|
|
import sqlite3
|
|
with sqlite3.connect(str(self.db_path)) as conn:
|
|
rows = conn.execute(
|
|
"SELECT * FROM cells WHERE state NOT IN ('destroyed', 'archived')"
|
|
).fetchall()
|
|
keys = [
|
|
"cell_id", "project", "owner", "backend", "state", "created_at",
|
|
"ttl_minutes", "members", "hermes_home", "workspace_path",
|
|
"shared_notes_path", "shared_artifacts_path", "decisions_path",
|
|
"archived_at", "destroyed_at",
|
|
]
|
|
cells = []
|
|
for row in rows:
|
|
d = dict(zip(keys, row))
|
|
d["members"] = json.loads(d["members"])
|
|
cells.append(Cell.from_dict(d))
|
|
return cells
|
|
|
|
def delete(self, cell_id: str):
|
|
import sqlite3
|
|
with sqlite3.connect(str(self.db_path)) as conn:
|
|
conn.execute("DELETE FROM cells WHERE cell_id = ?", (cell_id,))
|
|
|
|
|
|
class CellPackager:
|
|
"""Creates and destroys per-cell filesystem isolation."""
|
|
|
|
ROOT = Path("/tmp/lazarus-cells")
|
|
|
|
def __init__(self, root: Optional[str] = None):
|
|
self.root = Path(root or self.ROOT)
|
|
|
|
def create(self, cell_id: str, backend: str = "process") -> Cell:
|
|
cell_home = self.root / cell_id
|
|
workspace = cell_home / "workspace"
|
|
shared_notes = cell_home / "shared" / "notes.md"
|
|
shared_artifacts = cell_home / "shared" / "artifacts"
|
|
decisions = cell_home / "shared" / "decisions.jsonl"
|
|
hermes_home = cell_home / ".hermes"
|
|
|
|
for p in [workspace, shared_artifacts, hermes_home]:
|
|
p.mkdir(parents=True, exist_ok=True)
|
|
|
|
if not shared_notes.exists():
|
|
shared_notes.write_text(f"# Cell {cell_id} — Shared Notes\n\n")
|
|
|
|
if not decisions.exists():
|
|
decisions.write_text("")
|
|
|
|
now = datetime.now().astimezone().isoformat()
|
|
return Cell(
|
|
cell_id=cell_id,
|
|
project="",
|
|
owner="",
|
|
backend=backend,
|
|
state=CellState.PROPOSED,
|
|
created_at=now,
|
|
ttl_minutes=60,
|
|
hermes_home=str(hermes_home),
|
|
workspace_path=str(workspace),
|
|
shared_notes_path=str(shared_notes),
|
|
shared_artifacts_path=str(shared_artifacts),
|
|
decisions_path=str(decisions),
|
|
)
|
|
|
|
def destroy(self, cell_id: str) -> bool:
|
|
cell_home = self.root / cell_id
|
|
if cell_home.exists():
|
|
shutil.rmtree(cell_home)
|
|
return True
|
|
return False
|