EPIC R06 (Team Hoa) - workspace/filesystem isolation, no cross-project
mutable state.
R06-T01 domain/workspaces/workspace_session.py
WorkspaceSession - project_id/workspace_root/sandbox_dir/allowed_paths
frozen snapshot + is_allowed(path), same "capture once at submit time"
shape as R04's ConversationExecutionRequest.
R06-T02 infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py
Real bug fixed: core/projects.py::save_project and core/history.py's
save_conversation/rename_conversation/set_pinned did a plain
path.write_text(json.dumps(...)) - two syscalls, no atomicity. A crash
between them leaves a half-written file that load_project/load_conversation
then silently treat as "missing". All four now write through
atomic_write.write_json (temp file + os.replace). WorkspaceRepository/
ConversationRepository are thin object-shaped facades over the same
(now-atomic) functions, for future application-layer callers.
NOTE: atomic_write.py is deliberately NOT named atomic_json_file.py -
R02-T01 (Team Nam) claims that filename for the same purpose app-wide;
see the checklist for the consolidation TODO.
R06-T03 infrastructure/filesystem/execution_workspace.py
ExecutionWorkspace names the output_dir/scratch_dir split that already
exists (core/chat_agent.py's flat workspace_root/.scratch) - does not
move anything.
R06-T04 ui/chat_panel.py
The actual race: ChatPanel._persist_session (saves a BACKGROUND turn's
conversation) resolved its save directory via a live
self.ctx.config.history_dir() read at save time. ui/workspace_tab.py::
_load_current mutates that same config field on every project switch, so
a turn still running when the user switched projects got saved into the
NEW project's history folder. Fixed by adding "home_history_dir" to the
per-turn ctx dict (same "home_*" snapshot convention already used for
session id/messages/title), captured at submit time. Verified with a real
offscreen-Qt test, not just a unit double:
tests/integration/test_history_dir_race.py.
R06-T05 application/workspaces/file_workspace_service.py
FileWorkspaceService - the File Explorer / AI Editor entry point for the
same safe read/write/edit operations the agent tool loop has, by calling
core/tools.py::execute_tool directly (same dispatch, same ToolContext
containment, same audit log) rather than reimplementing any of it.
New tests: tests/unit/test_workspace_session.py,
test_atomic_write_and_repositories.py, test_execution_workspace.py,
test_file_workspace_service.py, tests/integration/test_history_dir_race.py
(29 new tests, incl. 2 real offscreen-Qt integration tests).
Suite: 283 passed, 4 pre-existing failures unrelated to R05/R06 (see
checklist). check_imports: PASS. All new files < 400 LOC.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
155 lines
5.4 KiB
Python
155 lines
5.4 KiB
Python
"""Conversation persistence with a per-conversation file model.
|
|
|
|
Each conversation is stored as one JSON file::
|
|
|
|
{"kind": "cowork"|"code", "session_id": str, "title": str,
|
|
"created": ISO8601, "messages": [...canonical...]}
|
|
|
|
File name: ``<kind>__<session_id>.json`` so the sidebar can group by kind and
|
|
sort by recency. History can live locally or in a OneDrive folder (resolved by
|
|
``AppConfig.history_dir``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
from ..config import HISTORY_DIR
|
|
|
|
|
|
def new_session_id() -> str:
|
|
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
|
|
|
|
|
|
def derive_title(messages: List[Dict[str, Any]]) -> str:
|
|
for m in messages:
|
|
if m.get("role") == "user" and m.get("content"):
|
|
text = " ".join(m["content"].split())
|
|
return text[:60] + ("…" if len(text) > 60 else "")
|
|
return "(empty)"
|
|
|
|
|
|
def save_conversation(
|
|
directory: Path,
|
|
kind: str,
|
|
session_id: str,
|
|
messages: List[Dict[str, Any]],
|
|
title: str = "",
|
|
created: str = "",
|
|
inputs: List[str] | None = None,
|
|
outputs: List[str] | None = None,
|
|
project_id: str = "",
|
|
) -> Path:
|
|
directory.mkdir(parents=True, exist_ok=True)
|
|
path = directory / f"{kind}__{session_id}.json"
|
|
pinned = False # preserve pin flag + project across autosaves
|
|
prev_project = ""
|
|
if path.exists():
|
|
try:
|
|
prev = json.loads(path.read_text(encoding="utf-8"))
|
|
pinned = bool(prev.get("pinned", False))
|
|
prev_project = prev.get("project_id", "")
|
|
except (OSError, json.JSONDecodeError):
|
|
pinned = False
|
|
payload = {
|
|
"kind": kind,
|
|
"session_id": session_id,
|
|
"title": title or derive_title(messages),
|
|
"created": created or datetime.now().isoformat(timespec="seconds"),
|
|
"pinned": pinned,
|
|
# A conversation belongs to a project (Claude-Projects style); legacy
|
|
# files without one fall back to the default project.
|
|
"project_id": project_id or prev_project or "default",
|
|
"inputs": list(inputs or []),
|
|
"outputs": list(outputs or []),
|
|
"messages": messages,
|
|
}
|
|
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
|
|
from ..infrastructure.persistence.json.atomic_write import write_json
|
|
write_json(path, payload)
|
|
return path
|
|
|
|
|
|
def delete_conversation(path) -> None:
|
|
try:
|
|
Path(path).unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def rename_conversation(path, new_title: str) -> None:
|
|
from ..infrastructure.persistence.json.atomic_write import write_json
|
|
|
|
data = load_conversation(path)
|
|
data["title"] = new_title
|
|
write_json(Path(path), data)
|
|
|
|
|
|
def set_pinned(path, pinned: bool) -> None:
|
|
from ..infrastructure.persistence.json.atomic_write import write_json
|
|
|
|
data = load_conversation(path)
|
|
data["pinned"] = bool(pinned)
|
|
write_json(Path(path), data)
|
|
|
|
|
|
def load_conversation(path: Path) -> Dict[str, Any]:
|
|
try:
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {"kind": "", "title": "(read error)", "messages": []}
|
|
if isinstance(data, list): # tolerate legacy format
|
|
data = {"kind": "", "title": derive_title(data), "messages": data}
|
|
return data
|
|
|
|
|
|
def _matches_query(query: str, title: str, messages: List[Dict[str, Any]]) -> bool:
|
|
"""True if ``query`` (already lowercased) appears in the title or in any
|
|
message's text content — a conversation "matches" by title OR content."""
|
|
if query in (title or "").lower():
|
|
return True
|
|
for m in messages or []:
|
|
content = m.get("content")
|
|
if isinstance(content, str) and query in content.lower():
|
|
return True
|
|
return False
|
|
|
|
|
|
def list_conversations(directory: Path = HISTORY_DIR, query: str = "") -> List[Dict[str, Any]]:
|
|
"""List saved conversations, most recent first (pinned always on top).
|
|
|
|
``query`` (from the sidebar's search box), when non-empty, keeps only
|
|
conversations whose title OR any message's content contains it
|
|
(case-insensitive) — since every file is already parsed to build the
|
|
metadata below, this search costs no extra I/O over listing alone."""
|
|
if not directory or not directory.exists():
|
|
return []
|
|
q = (query or "").strip().lower()
|
|
items: List[Dict[str, Any]] = []
|
|
for path in directory.glob("*.json"):
|
|
try:
|
|
data = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
if isinstance(data, list):
|
|
data = {"kind": "", "title": derive_title(data), "messages": data}
|
|
title = data.get("title", path.stem)
|
|
if q and not _matches_query(q, title, data.get("messages", [])):
|
|
continue
|
|
items.append({
|
|
"path": path,
|
|
"kind": data.get("kind", ""),
|
|
"title": title,
|
|
"created": data.get("created", ""),
|
|
"session_id": data.get("session_id", path.stem),
|
|
"pinned": bool(data.get("pinned", False)),
|
|
"project_id": data.get("project_id", "") or "default",
|
|
"count": len(data.get("messages", [])),
|
|
"mtime": path.stat().st_mtime,
|
|
})
|
|
# pinned first, then most recent
|
|
items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
|
|
return items
|