47e9b2c488
Deleting a project closed its store but left the delegate cached in _project_engine_delegates; re-creating a same-name project then reused the zombie engine and crashed in get_session. delete() now closes and evicts via _close_project_engine_store (also covers non-active deletes), the delegate cache self-heals when a cached store is closed, and _engine_for_project reopens a closed store for the non-evictable root engine case. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
358 lines
17 KiB
Python
358 lines
17 KiB
Python
"""Project lifecycle service shared by Office UI and CLI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import shutil
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from loguru import logger
|
|
from opc.layer5_memory.markdown_memory import MarkdownMemoryStore
|
|
from opc.plugins.office_ui.snapshot_builder import build_collab_sync, build_project_index_sync, build_snapshot
|
|
|
|
from .context import OfficeServiceContext
|
|
from .models import ServiceEvent, ServiceError, ServiceResult
|
|
|
|
|
|
class ProjectService:
|
|
def __init__(self, context: OfficeServiceContext) -> None:
|
|
self.context = context
|
|
|
|
@staticmethod
|
|
def _quote_sql_identifier(name: str) -> str:
|
|
return '"' + str(name).replace('"', '""') + '"'
|
|
|
|
@classmethod
|
|
def _rewrite_project_id_in_sqlite(cls, db_path: Path, old_project_id: str, new_project_id: str) -> dict[str, int]:
|
|
if not db_path.exists():
|
|
return {}
|
|
counts: dict[str, int] = {}
|
|
conn = sqlite3.connect(str(db_path))
|
|
try:
|
|
rows = conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'").fetchall()
|
|
for (table_name,) in rows:
|
|
table = str(table_name or "")
|
|
if not table or table.startswith("sqlite_"):
|
|
continue
|
|
quoted = cls._quote_sql_identifier(table)
|
|
columns = conn.execute(f"PRAGMA table_info({quoted})").fetchall()
|
|
if not any(str(col[1]) == "project_id" for col in columns):
|
|
continue
|
|
cursor = conn.execute(
|
|
f"UPDATE {quoted} SET project_id = ? WHERE project_id = ?",
|
|
(new_project_id, old_project_id),
|
|
)
|
|
counts[table] = int(cursor.rowcount or 0)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return counts
|
|
|
|
async def _close_project_engine_store(self, project_id: str) -> None:
|
|
root = self.context.root_engine
|
|
candidates: list[Any] = []
|
|
active = self.context.engine
|
|
if self.context.normalize_project_id(getattr(active, "project_id", None)) == project_id:
|
|
candidates.append(active)
|
|
if self.context.normalize_project_id(getattr(root, "project_id", None)) == project_id:
|
|
candidates.append(root)
|
|
delegates = getattr(root, "_project_engine_delegates", None)
|
|
if isinstance(delegates, dict):
|
|
delegate = delegates.pop(project_id, None)
|
|
if delegate is not None:
|
|
candidates.append(delegate)
|
|
seen: set[int] = set()
|
|
for engine in candidates:
|
|
marker = id(engine)
|
|
if marker in seen:
|
|
continue
|
|
seen.add(marker)
|
|
store = getattr(engine, "store", None)
|
|
close = getattr(store, "close", None)
|
|
if callable(close):
|
|
try:
|
|
maybe = close()
|
|
if asyncio.iscoroutine(maybe):
|
|
await maybe
|
|
except Exception:
|
|
logger.opt(exception=True).debug(f"Failed to close project store for {project_id}")
|
|
|
|
async def list(self, *, active_project_id: str | None = None) -> ServiceResult:
|
|
active = active_project_id or self.context.active_engine_project_id()
|
|
return ServiceResult({
|
|
"projects": self.context.list_project_entries(),
|
|
"active_project_id": self.context.normalize_project_id(active),
|
|
})
|
|
|
|
async def create(self, project_id: str, *, active_project_id: str | None = None) -> ServiceResult:
|
|
project_id = str(project_id or "").strip()
|
|
if not project_id:
|
|
raise ServiceError("missing_project_id", "Missing project_id")
|
|
if not self.context.is_safe_project_id(project_id):
|
|
raise ServiceError("invalid_project_id", "Invalid project_id (use alphanumeric, hyphens, underscores)")
|
|
|
|
projects_dir = self.context.project_dir(project_id)
|
|
memory_store = MarkdownMemoryStore(Path(self.context.root_engine.opc_home))
|
|
memory_path = memory_store.memory_path(project_id)
|
|
workplace = self.context.project_workplace(project_id)
|
|
if projects_dir.exists() or memory_path.exists() or workplace.exists():
|
|
raise ServiceError("project_exists", f"Project '{project_id}' already exists")
|
|
|
|
projects_dir.mkdir(parents=True, exist_ok=False)
|
|
workplace.mkdir(parents=True, exist_ok=False)
|
|
memory_store.ensure_memory_file(project_id, f"# Project Memory ({project_id})")
|
|
active = active_project_id or self.context.active_engine_project_id()
|
|
return ServiceResult({
|
|
"action": "create_project",
|
|
"project_id": project_id,
|
|
"projects": self.context.list_project_entries(),
|
|
"active_project_id": self.context.normalize_project_id(active),
|
|
})
|
|
|
|
async def rename(self, old_project_id: str, new_project_id: str) -> ServiceResult:
|
|
old_id = str(old_project_id or "").strip()
|
|
new_id = str(new_project_id or "").strip()
|
|
if not old_id or not new_id:
|
|
raise ServiceError("missing_project_id", "Missing project_id")
|
|
if old_id == "default":
|
|
raise ServiceError("default_project", "Cannot rename the default project")
|
|
if new_id == "default":
|
|
raise ServiceError("invalid_project_id", "Cannot rename a project to 'default'")
|
|
if not self.context.is_safe_project_id(old_id) or not self.context.is_safe_project_id(new_id):
|
|
raise ServiceError("invalid_project_id", "Invalid project_id (use alphanumeric, hyphens, underscores)")
|
|
if old_id == new_id:
|
|
return ServiceResult({
|
|
"action": "rename_project",
|
|
"old_project_id": old_id,
|
|
"project_id": new_id,
|
|
"new_project_id": new_id,
|
|
"renamed": False,
|
|
"projects": self.context.list_project_entries(),
|
|
"active_project_id": self.context.active_engine_project_id(),
|
|
})
|
|
|
|
old_dir = self.context.project_dir(old_id)
|
|
new_dir = self.context.project_dir(new_id)
|
|
memory_store = MarkdownMemoryStore(Path(self.context.root_engine.opc_home))
|
|
old_memory = memory_store.memory_path(old_id)
|
|
new_memory = memory_store.memory_path(new_id)
|
|
old_workplace = self.context.project_workplace(old_id)
|
|
new_workplace = self.context.project_workplace(new_id)
|
|
old_exists = old_dir.is_dir() or old_memory.exists() or old_workplace.exists()
|
|
if not old_exists:
|
|
raise ServiceError("project_not_found", f"Project '{old_id}' does not exist", {"project_id": old_id})
|
|
if new_dir.exists() or new_memory.exists() or new_workplace.exists():
|
|
raise ServiceError("project_exists", f"Project '{new_id}' already exists", {"project_id": new_id})
|
|
chat_data_exists = getattr(self.context.chat_store, "project_data_exists", None)
|
|
if callable(chat_data_exists) and await chat_data_exists(new_id):
|
|
raise ServiceError("project_exists", f"Project '{new_id}' already has UI data", {"project_id": new_id})
|
|
|
|
was_active = self.context.active_engine_project_id() == old_id
|
|
if was_active:
|
|
for task in list(self.context.background_tasks):
|
|
task.cancel()
|
|
self.context.background_tasks.clear()
|
|
self.context.task_bg_map.clear()
|
|
self.context.task_bg_context.clear()
|
|
await self._close_project_engine_store(old_id)
|
|
|
|
if old_dir.is_dir():
|
|
new_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
old_dir.rename(new_dir)
|
|
else:
|
|
new_dir.mkdir(parents=True, exist_ok=True)
|
|
if old_memory.exists():
|
|
new_memory.parent.mkdir(parents=True, exist_ok=True)
|
|
old_memory.rename(new_memory)
|
|
if old_workplace.exists():
|
|
new_workplace.parent.mkdir(parents=True, exist_ok=True)
|
|
old_workplace.rename(new_workplace)
|
|
|
|
db_counts = self._rewrite_project_id_in_sqlite(new_dir / "tasks.db", old_id, new_id)
|
|
chat_counts: dict[str, int] = {}
|
|
rename_chat = getattr(self.context.chat_store, "rename_project_data", None)
|
|
if callable(rename_chat):
|
|
try:
|
|
chat_counts = dict(await rename_chat(old_id, new_id) or {})
|
|
except ValueError as exc:
|
|
raise ServiceError("project_exists", str(exc), {"project_id": new_id}) from exc
|
|
|
|
active_id = self.context.active_engine_project_id()
|
|
events = [ServiceEvent("project_renamed", {"old_project_id": old_id, "project_id": new_id, "new_project_id": new_id})]
|
|
payload: dict[str, Any] = {
|
|
"action": "rename_project",
|
|
"old_project_id": old_id,
|
|
"project_id": new_id,
|
|
"new_project_id": new_id,
|
|
"renamed": True,
|
|
"projects": self.context.list_project_entries(),
|
|
"active_project_id": active_id,
|
|
"updated_task_tables": db_counts,
|
|
"updated_ui_rows": chat_counts,
|
|
}
|
|
if was_active:
|
|
engine = await self.context.activate_project(new_id)
|
|
await self.context.chat_store.ensure_activity_channel(project_id=new_id)
|
|
await self.context.chat_store.ensure_secretary_channel(project_id=new_id)
|
|
payload["active_project_id"] = new_id
|
|
payload["engine_project_id"] = getattr(engine, "project_id", new_id)
|
|
events.append(ServiceEvent("project_switched", {"project_id": new_id}))
|
|
snapshot = await build_snapshot(
|
|
self.context.engine,
|
|
self.context.agent_store,
|
|
self.context.chat_store,
|
|
self.context.event_adapter,
|
|
)
|
|
snapshot["exec_mode"] = self.context.mode_state.exec_mode
|
|
snapshot["company_profile"] = self.context.mode_state.company_profile
|
|
snapshot["task_preferred_agent"] = self.context.mode_state.task_preferred_agent
|
|
events.append(ServiceEvent("snapshot", snapshot))
|
|
collab = await build_collab_sync(
|
|
self.context.engine,
|
|
self.context.agent_store,
|
|
self.context.chat_store,
|
|
self.context.event_adapter,
|
|
exec_mode=self.context.mode_state.exec_mode,
|
|
)
|
|
events.append(ServiceEvent("collab_sync_push", collab))
|
|
return ServiceResult(payload, events)
|
|
|
|
async def delete(self, project_id: str) -> ServiceResult:
|
|
project_id = str(project_id or "").strip()
|
|
if not project_id or project_id == "default":
|
|
raise ServiceError("default_project", "Cannot delete the default project")
|
|
if not self.context.is_safe_project_id(project_id):
|
|
raise ServiceError("invalid_project_id", "Invalid project_id")
|
|
|
|
was_active = self.context.active_engine_project_id() == project_id
|
|
if was_active:
|
|
for task in list(self.context.background_tasks):
|
|
task.cancel()
|
|
self.context.background_tasks.clear()
|
|
self.context.task_bg_map.clear()
|
|
self.context.task_bg_context.clear()
|
|
|
|
deleted_channels = 0
|
|
delete_chat = getattr(self.context.chat_store, "delete_project_data", None)
|
|
if callable(delete_chat):
|
|
deleted_channels = int(await delete_chat(project_id) or 0)
|
|
logger.info(f"Deleted {deleted_channels} channels for project '{project_id}'")
|
|
|
|
# Close every engine bound to this project AND evict its delegate from
|
|
# the root engine's cache; a stale delegate would otherwise be reused
|
|
# (with a closed store) if a project with the same id is re-created.
|
|
await self._close_project_engine_store(project_id)
|
|
|
|
projects_dir = self.context.project_dir(project_id)
|
|
if projects_dir.is_dir():
|
|
shutil.rmtree(str(projects_dir), ignore_errors=True)
|
|
|
|
workplace = self.context.project_workplace(project_id)
|
|
if workplace.is_dir():
|
|
shutil.rmtree(str(workplace), ignore_errors=True)
|
|
|
|
memory = getattr(self.context.engine, "memory", None)
|
|
if memory:
|
|
delete_fn = getattr(memory, "delete_project", None)
|
|
if callable(delete_fn):
|
|
try:
|
|
maybe = delete_fn(project_id)
|
|
if asyncio.iscoroutine(maybe):
|
|
await maybe
|
|
except Exception:
|
|
logger.opt(exception=True).debug(f"memory.delete_project failed for {project_id}")
|
|
|
|
events = [ServiceEvent("project_deleted", {"project_id": project_id})]
|
|
payload: dict[str, Any] = {"project_id": project_id, "deleted_channels": deleted_channels}
|
|
if was_active:
|
|
self.context.project_dir("default").mkdir(parents=True, exist_ok=True)
|
|
await self.context.activate_project("default")
|
|
await self.context.chat_store.ensure_activity_channel(project_id="default")
|
|
await self.context.chat_store.ensure_secretary_channel(project_id="default")
|
|
payload["active_project_id"] = "default"
|
|
events.append(ServiceEvent("project_switched", {"project_id": "default"}))
|
|
snapshot = await build_snapshot(
|
|
self.context.engine,
|
|
self.context.agent_store,
|
|
self.context.chat_store,
|
|
self.context.event_adapter,
|
|
)
|
|
snapshot["exec_mode"] = self.context.mode_state.exec_mode
|
|
snapshot["company_profile"] = self.context.mode_state.company_profile
|
|
snapshot["task_preferred_agent"] = self.context.mode_state.task_preferred_agent
|
|
events.append(ServiceEvent("snapshot", snapshot))
|
|
collab = await build_collab_sync(
|
|
self.context.engine,
|
|
self.context.agent_store,
|
|
self.context.chat_store,
|
|
self.context.event_adapter,
|
|
exec_mode=self.context.mode_state.exec_mode,
|
|
)
|
|
events.append(ServiceEvent("collab_sync_push", collab))
|
|
return ServiceResult(payload, events)
|
|
|
|
async def switch(self, project_id: str, *, switch_seq: str = "", include_snapshot: bool = True) -> ServiceResult:
|
|
new_id = str(project_id or "").strip()
|
|
if not new_id:
|
|
raise ServiceError("missing_project_id", "Missing project_id")
|
|
if not self.context.is_safe_project_id(new_id):
|
|
raise ServiceError("invalid_project_id", "Invalid project_id")
|
|
async with self.context.project_switch_lock:
|
|
if new_id == "default":
|
|
self.context.project_dir(new_id).mkdir(parents=True, exist_ok=True)
|
|
self.context.project_workplace(new_id).mkdir(parents=True, exist_ok=True)
|
|
elif not self.context.project_dir(new_id).is_dir():
|
|
raise ServiceError("project_not_found", f"Project '{new_id}' does not exist", {"project_id": new_id, "switch_seq": switch_seq})
|
|
engine = await self.context.activate_project(new_id)
|
|
await self.context.chat_store.ensure_activity_channel(project_id=new_id)
|
|
await self.context.chat_store.ensure_secretary_channel(project_id=new_id)
|
|
|
|
events = [ServiceEvent("project_switched", {"project_id": new_id, "switch_seq": switch_seq})]
|
|
if include_snapshot:
|
|
index_payload = await self.project_index(new_id, switch_seq=switch_seq, include_snapshot=True)
|
|
for key in ("project_index", "snapshot"):
|
|
if key in index_payload.payload:
|
|
event_type = "project_index_push" if key == "project_index" else "snapshot"
|
|
events.append(ServiceEvent(event_type, index_payload.payload[key]))
|
|
return ServiceResult({"project_id": new_id, "switch_seq": switch_seq, "engine_project_id": getattr(engine, "project_id", new_id)}, events)
|
|
|
|
async def project_index(
|
|
self,
|
|
project_id: str,
|
|
*,
|
|
switch_seq: str = "",
|
|
view_generation: Any = None,
|
|
include_snapshot: bool = False,
|
|
) -> ServiceResult:
|
|
engine = await self.context.engine_for_project(project_id)
|
|
index_payload = await build_project_index_sync(
|
|
engine,
|
|
self.context.agent_store,
|
|
self.context.chat_store,
|
|
self.context.event_adapter,
|
|
exec_mode=self.context.mode_state.exec_mode,
|
|
)
|
|
index_payload["project_id"] = self.context.normalize_project_id(project_id)
|
|
index_payload["switch_seq"] = switch_seq
|
|
if view_generation is not None:
|
|
index_payload["view_generation"] = view_generation
|
|
payload: dict[str, Any] = {"project_index": index_payload}
|
|
if include_snapshot:
|
|
snapshot = await build_snapshot(
|
|
engine,
|
|
self.context.agent_store,
|
|
self.context.chat_store,
|
|
self.context.event_adapter,
|
|
)
|
|
snapshot["project_id"] = self.context.normalize_project_id(project_id)
|
|
snapshot["exec_mode"] = self.context.mode_state.exec_mode
|
|
snapshot["company_profile"] = self.context.mode_state.company_profile
|
|
snapshot["task_preferred_agent"] = self.context.mode_state.task_preferred_agent
|
|
snapshot["switch_seq"] = switch_seq
|
|
if view_generation is not None:
|
|
snapshot["view_generation"] = view_generation
|
|
payload["snapshot"] = snapshot
|
|
return ServiceResult(payload)
|