Files
cdsl-cad/backend/app/services/storage.py
T
2026-08-31 14:16:08 +08:00

688 lines
31 KiB
Python

from __future__ import annotations
import json
import re
import secrets
from copy import deepcopy
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from app.settings import Settings
TASK_ID = re.compile(r"^cad_[a-z0-9]{12}$")
CONVERSATION_ID = re.compile(r"^conv_[a-z0-9]{12}$")
CANDIDATE_ID = re.compile(r"^candidate_[a-z0-9]{12,32}$")
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def new_id(prefix: str) -> str:
return f"{prefix}_{secrets.token_hex(6)}"
def safe_task_id(task_id: str) -> str:
value = str(task_id or "").strip()
if not TASK_ID.fullmatch(value):
raise ValueError("Invalid task id")
return value
def safe_conversation_id(conversation_id: str) -> str:
value = str(conversation_id or "").strip()
if not CONVERSATION_ID.fullmatch(value):
raise ValueError("Invalid conversation id")
return value
def safe_relative_path(value: str) -> str:
path = Path(str(value or ""))
if not value or path.is_absolute() or ".." in path.parts:
raise ValueError("Invalid artifact path")
return path.as_posix()
def write_json(path: Path, payload: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def read_json(path: Path, fallback: Any = None) -> Any:
if not path.is_file():
return fallback
return json.loads(path.read_text(encoding="utf-8"))
class WorkspaceStore:
def __init__(self, settings: Settings) -> None:
self.settings = settings
self.settings.task_root.mkdir(parents=True, exist_ok=True)
self.settings.conversation_root.mkdir(parents=True, exist_ok=True)
def task_dir(self, task_id: str) -> Path:
return self.settings.task_root / safe_task_id(task_id)
def task_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "task.json"
def conversation_dir(self, conversation_id: str) -> Path:
return self.settings.conversation_root / safe_conversation_id(conversation_id)
def conversation_path(self, conversation_id: str) -> Path:
return self.conversation_dir(conversation_id) / "conversation.json"
def write_tool_call_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
"""Persist one failed model tool call without creating a CAD revision."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"tool_call_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def write_cdsl_attempt(self, conversation_id: str, cdsl: Any, iteration: int) -> str:
"""Retain a parsed model candidate before validation or execution."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"cdsl_attempt_{iteration:02d}_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, cdsl)
return (Path(conversation) / relative).as_posix()
def write_cdsl_validation_diagnostic(self, conversation_id: str, payload: dict[str, Any]) -> str:
"""Persist the reason a retained CDSL candidate was rejected."""
conversation = safe_conversation_id(conversation_id)
relative = Path("diagnostics") / f"cdsl_validation_{secrets.token_hex(8)}.json"
path = self.conversation_dir(conversation) / relative
write_json(path, payload)
return (Path(conversation) / relative).as_posix()
def ensure_conversation(
self,
conversation_id: str | None,
current_task_id: str | None = None,
) -> dict[str, Any]:
cid = safe_conversation_id(conversation_id) if conversation_id else new_id("conv")
path = self.conversation_path(cid)
current = read_json(path)
if current:
changed = False
if current_task_id:
current["current_task_id"] = safe_task_id(current_task_id)
changed = True
if changed:
current["updated_at"] = now_iso()
write_json(path, current)
return current
record = {
"schema_version": "1.2",
"conversation_id": cid,
"created_at": now_iso(),
"updated_at": now_iso(),
"current_task_id": safe_task_id(current_task_id) if current_task_id else "",
"messages": [],
"attachments": [],
}
write_json(path, record)
return record
def read_conversation(self, conversation_id: str) -> dict[str, Any] | None:
return read_json(self.conversation_path(conversation_id))
def append_conversation_message(self, conversation_id: str, message: dict[str, Any], current_task_id: str | None = None) -> dict[str, Any]:
record = self.ensure_conversation(conversation_id, current_task_id)
known = {str(item.get("id")) for item in record["messages"]}
if str(message.get("id")) not in known:
record["messages"].append(message)
if current_task_id:
record["current_task_id"] = safe_task_id(current_task_id)
record["updated_at"] = now_iso()
write_json(self.conversation_path(record["conversation_id"]), record)
return record
def write_conversation_upload(self, conversation_id: str, filename: str, data: bytes) -> tuple[str, Path]:
conversation = safe_conversation_id(conversation_id)
safe_name = re.sub(r"[^a-zA-Z0-9._-]+", "_", Path(filename).name).strip("._") or "attachment"
relative = Path("uploads") / f"upload_{secrets.token_hex(6)}_{safe_name}"
target = self.conversation_attachment_path(conversation, relative.as_posix())
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(data)
return relative.as_posix(), target
def add_conversation_attachment(self, conversation_id: str, attachment: dict[str, Any]) -> dict[str, Any]:
conversation = safe_conversation_id(conversation_id)
record = self.read_conversation(conversation)
if record is None:
raise ValueError("Conversation not found")
if str(attachment.get("conversation_id") or "") != conversation:
raise ValueError("Attachment does not belong to this conversation")
attachment_id = str(attachment.get("id") or "")
if not attachment_id:
raise ValueError("Attachment id is required")
self.conversation_attachment_path(conversation, str(attachment.get("path") or ""))
attachments = record.setdefault("attachments", [])
if any(str(item.get("id") or "") == attachment_id for item in attachments if isinstance(item, dict)):
raise ValueError("Attachment already exists")
attachments.append(attachment)
record["updated_at"] = now_iso()
write_json(self.conversation_path(conversation), record)
return record
def ensure_task(self, task_id: str | None, request: str) -> dict[str, Any]:
tid = safe_task_id(task_id) if task_id else new_id("cad")
path = self.task_path(tid)
current = read_json(path)
if current:
return self._migrate_task(current, path)
task_dir = self.task_dir(tid)
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
source_requirements = str(request or "").strip()
source_path = task_dir / "source-requirements.md"
source_path.write_text(source_requirements + "\n", encoding="utf-8")
record = {
"schema_version": "2.0",
"task_id": tid,
"request": request,
"source_requirements_path": "source-requirements.md",
"created_at": now_iso(),
"updated_at": now_iso(),
"current_revision": "",
"active_revision": "",
"published_revision": "",
"lifecycle": "completed",
"run_id": "",
"requirements_path": "",
"completion_checklist_path": "",
"modeling_plan_path": "",
"modeling_plan_review_path": "",
"modeling_plan_version": 0,
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
"run_failure_path": "",
"revisions": [],
}
write_json(path, record)
return record
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
"""Fill in omitted fields for native autonomous tasks only.
A task from a retired protocol must never be made to look resumable by
rewriting its schema version. In particular, doing so would make an
old revision and its obsolete planning state appear to be a valid
autonomous work head. Historical tasks remain read-only records; the
service startup path marks only *running* legacy tasks as obsolete.
"""
if str(task.get("schema_version") or "") != "2.0":
return task
changed = False
current = str(task.get("current_revision") or "")
defaults = {
"active_revision": current,
"published_revision": current,
"lifecycle": "completed",
"run_id": "",
"requirements_path": "",
"source_requirements_path": "",
"completion_checklist_path": "",
"modeling_plan_path": "",
"modeling_plan_review_path": "",
"modeling_plan_version": 0,
"agent_state_path": "",
"active_candidate_id": "",
"active_branch_id": "main",
"run_failure_path": "",
}
for key, value in defaults.items():
if key not in task:
task[key] = value
changed = True
if not str(task.get("source_requirements_path") or ""):
relative = Path("source-requirements.md")
source_path = path.parent / relative
if not source_path.exists():
source_path.write_text(str(task.get("request") or "").strip() + "\n", encoding="utf-8")
task["source_requirements_path"] = relative.as_posix()
changed = True
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
if "visibility" not in revision:
revision["visibility"] = "final" if str(revision.get("revision_id") or "") == str(task["published_revision"] or "") else "checkpoint"
changed = True
if "branch_id" not in revision:
revision["branch_id"] = "main"
changed = True
if changed:
task["updated_at"] = now_iso()
write_json(path, task)
return task
def next_revision(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "")
revision_id = f"rev_{len(task['revisions']) + 1:03d}"
revision_dir = self.task_dir(task_id) / "revisions" / revision_id
revision_dir.mkdir(parents=True, exist_ok=False)
return revision_id, revision_dir
def update_task(self, task_id: str, revision: dict[str, Any]) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
task["revisions"].append(revision)
if revision.get("status") == "success":
task["current_revision"] = revision["revision_id"]
task["active_revision"] = revision["revision_id"]
if revision.get("visibility") == "final":
task["published_revision"] = revision["revision_id"]
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def update_task_fields(self, task_id: str, fields: dict[str, Any]) -> dict[str, Any]:
"""Update task metadata without appending a synthetic revision."""
task = self.ensure_task(task_id, "")
for key, value in fields.items():
task[str(key)] = deepcopy(value)
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def read_task(self, task_id: str) -> dict[str, Any] | None:
task = read_json(self.task_path(task_id))
return self._migrate_task(task, self.task_path(task_id)) if isinstance(task, dict) else None
def start_generation(self, task_id: str, *, request: str, run_id: str | None = None) -> dict[str, Any]:
task = self.ensure_task(task_id, request)
if str(task.get("lifecycle") or "") == "running":
raise ValueError("CAD task is already running")
task.update({
"lifecycle": "running",
"run_id": run_id or new_id("run"),
"active_candidate_id": "",
"run_failure_path": "",
"request": request or task.get("request") or "",
"active_revision": str(task.get("current_revision") or ""),
"active_branch_id": str(task.get("active_branch_id") or "main"),
"updated_at": now_iso(),
})
write_json(self.task_path(task_id), task)
return task
def finish_generation(self, task_id: str, *, lifecycle: str, failure: dict[str, Any] | None = None) -> dict[str, Any]:
if lifecycle not in {"completed", "failed", "cancelled", "waiting_review", "failed_review_service"}:
raise ValueError("Generation lifecycle must be completed, failed, cancelled, waiting_review, or failed_review_service")
task = self.ensure_task(task_id, "")
failure_path = ""
if failure:
failure_path = "run-failures/" + f"failure_{secrets.token_hex(8)}.json"
write_json(self.task_dir(task_id) / failure_path, failure)
if lifecycle == "completed":
task["published_revision"] = str(task.get("active_revision") or task.get("current_revision") or "")
for revision in task.get("revisions") or ():
if isinstance(revision, dict) and revision.get("revision_id") == task["published_revision"]:
revision["visibility"] = "final"
task.update({
"lifecycle": lifecycle,
"active_candidate_id": "",
"run_failure_path": failure_path,
"updated_at": now_iso(),
})
write_json(self.task_path(task_id), task)
return task
def set_active_revision(self, task_id: str, revision_id: str, *, branch_id: str | None = None) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
if not revision_id:
task["active_revision"] = ""
task["current_revision"] = ""
if branch_id:
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
if not isinstance(revision, dict) or revision.get("status") != "success":
raise ValueError("Active revision must be a successful revision")
task["active_revision"] = revision_id
task["current_revision"] = revision_id
if branch_id:
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def set_agent_state(self, task_id: str, values: dict[str, Any]) -> dict[str, Any]:
"""Persist enough autonomous-agent state to resume after a restart."""
task = self.ensure_task(task_id, "")
relative = Path("agent-state.json")
write_json(self.task_dir(task_id) / relative, values)
task["agent_state_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def read_agent_state(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("agent_state_path") or "")
value = read_json(self.artifact_path(task_id, relative)) if relative else None
return value if isinstance(value, dict) else None
def requirements_document_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "requirements.md"
def read_source_requirements(self, task_id: str) -> str:
"""Return the server-owned, immutable user request for this task.
Older tasks predate the artifact. Their persisted request is a
read-only compatibility fallback; new tasks always have the artifact.
"""
task = self.read_task(task_id) or {}
relative = str(task.get("source_requirements_path") or "")
if relative:
path = self.artifact_path(task_id, relative)
if path.is_file():
return path.read_text(encoding="utf-8")
return str(task.get("request") or "")
def write_requirements_document(self, task_id: str, markdown: str) -> Path:
"""Write the one immutable requirements document for an agent run."""
text = str(markdown or "").strip()
if not text:
raise ValueError("requirements.md must not be empty")
task = self.ensure_task(task_id, "")
relative = Path("requirements.md")
path = self.task_dir(task_id) / relative
if path.exists() or str(task.get("requirements_path") or ""):
raise ValueError("requirements.md is frozen and cannot be rewritten")
path.write_text(text + "\n", encoding="utf-8")
task["requirements_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_requirements_document(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("requirements_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def completion_checklist_path(self, task_id: str) -> Path:
return self.task_dir(task_id) / "completion.md"
def write_completion_checklist(self, task_id: str, markdown: str) -> Path:
"""Persist the one immutable, author-owned completion checklist."""
text = str(markdown or "").strip()
if not text:
raise ValueError("completion.md must not be empty")
task = self.ensure_task(task_id, "")
if not self.read_requirements_document(task_id):
raise ValueError("requirements.md must be written before completion.md")
relative = Path("completion.md")
path = self.task_dir(task_id) / relative
if path.exists() or str(task.get("completion_checklist_path") or ""):
raise ValueError("completion.md is frozen and cannot be rewritten")
path.write_text(text + "\n", encoding="utf-8")
task["completion_checklist_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_completion_checklist(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("completion_checklist_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def write_modeling_plan(self, task_id: str, markdown: str, *, version: int = 1) -> Path:
"""Persist one immutable, versioned modeling-plan document."""
text = str(markdown or "").strip()
if not text:
raise ValueError("modeling plan must not be empty")
task = self.ensure_task(task_id, "")
version = max(1, int(version))
relative = Path("plans") / f"modeling-plan-v{version}.md"
path = self.task_dir(task_id) / relative
if path.exists():
raise ValueError("modeling plan version already exists")
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text + "\n", encoding="utf-8")
task["modeling_plan_path"] = relative.as_posix()
task["modeling_plan_version"] = version
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_modeling_plan(self, task_id: str) -> str:
task = self.read_task(task_id) or {}
relative = str(task.get("modeling_plan_path") or "")
if not relative:
return ""
path = self.artifact_path(task_id, relative)
return path.read_text(encoding="utf-8") if path.is_file() else ""
def write_modeling_plan_review(self, task_id: str, review: dict[str, Any], *, version: int) -> Path:
task = self.ensure_task(task_id, "")
version = max(1, int(version))
relative = Path("plans") / f"modeling-plan-v{version}.review.json"
path = self.task_dir(task_id) / relative
if path.exists():
raise ValueError("modeling plan review already exists")
path.parent.mkdir(parents=True, exist_ok=True)
write_json(path, review)
task["modeling_plan_review_path"] = relative.as_posix()
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return path
def read_modeling_plan_review(self, task_id: str) -> dict[str, Any] | None:
task = self.read_task(task_id) or {}
relative = str(task.get("modeling_plan_review_path") or "")
value = read_json(self.artifact_path(task_id, relative), {}) if relative else None
return value if isinstance(value, dict) else None
def new_candidate(self, task_id: str) -> tuple[str, Path]:
task = self.ensure_task(task_id, "")
candidate_id = f"candidate_{secrets.token_hex(8)}"
path = self.task_dir(task_id) / "candidates" / candidate_id
path.mkdir(parents=True, exist_ok=False)
task["active_candidate_id"] = candidate_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return candidate_id, path
def candidate_dir(self, task_id: str, candidate_id: str) -> Path:
if not CANDIDATE_ID.fullmatch(str(candidate_id or "")):
raise ValueError("Invalid candidate id")
return self.task_dir(task_id) / "candidates" / candidate_id
def clear_active_candidate(self, task_id: str, candidate_id: str | None = None) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
if candidate_id and str(task.get("active_candidate_id") or "") not in {"", candidate_id}:
raise ValueError("Candidate is not active")
task["active_candidate_id"] = ""
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def append_agent_audit(self, task_id: str, kind: str, payload: dict[str, Any]) -> str:
safe_kind = re.sub(r"[^a-z0-9_-]+", "-", str(kind).lower()).strip("-") or "event"
relative = Path("agent-audit") / f"{now_iso().replace(':', '-').replace('+', '_')}_{safe_kind}_{secrets.token_hex(4)}.json"
write_json(self.task_dir(task_id) / relative, {"recorded_at": now_iso(), "kind": safe_kind, **payload})
return relative.as_posix()
def update_revision_metadata(self, task_id: str, revision_id: str, values: dict[str, Any]) -> dict[str, Any]:
task = self.ensure_task(task_id, "")
revision = next((item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id), None)
if not isinstance(revision, dict):
raise ValueError("Revision does not exist")
revision.update(values)
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def rollback_to_revision(self, task_id: str, revision_id: str, *, branch_id: str) -> dict[str, Any]:
"""Move the generation head without deleting immutable checkpoint artifacts."""
task = self.ensure_task(task_id, "")
source_branch_id = str(task.get("active_branch_id") or "main")
if revision_id:
target = next(
(item for item in task.get("revisions") or () if isinstance(item, dict) and item.get("revision_id") == revision_id),
None,
)
if not isinstance(target, dict) or target.get("status") != "success":
raise ValueError("Active revision must be a successful revision")
children: dict[str, set[str]] = {}
for revision in task.get("revisions") or ():
if not isinstance(revision, dict):
continue
parent = str(revision.get("parent_revision_id") or "")
child = str(revision.get("revision_id") or "")
if child and str(revision.get("branch_id") or "main") == source_branch_id:
children.setdefault(parent, set()).add(child)
superseded: set[str] = set()
pending = list(children.get(revision_id, set()))
while pending:
child = pending.pop()
if not child or child in superseded:
continue
superseded.add(child)
pending.extend(children.get(child, set()))
for revision in task.get("revisions") or ():
if isinstance(revision, dict) and str(revision.get("revision_id") or "") in superseded and revision.get("visibility") == "checkpoint":
revision["visibility"] = "superseded"
task["active_revision"] = revision_id
task["current_revision"] = revision_id
task["active_branch_id"] = branch_id
task["updated_at"] = now_iso()
write_json(self.task_path(task_id), task)
return task
def rollback_anchor_for_nodes(
self,
task_id: str,
node_ids: list[str],
*,
fallback_revision_id: str = "",
) -> str:
"""Return the revision before every affected node's latest checkpoint.
Returning each affected revision's parent (rather than the revision
itself) ensures the faulty node is regenerated. A common ancestor
keeps unrelated upstream work intact while permitting a single rollback
over any number of affected nodes.
"""
task = self.read_task(task_id) or {}
revisions = [item for item in task.get("revisions") or () if isinstance(item, dict)]
by_id = {str(item.get("revision_id") or ""): item for item in revisions}
parents: list[str] = []
for node_id in dict.fromkeys(str(item) for item in node_ids if str(item)):
matching = [item for item in revisions if item.get("status") == "success" and str(item.get("node_id") or "") == node_id]
if matching:
parents.append(str(matching[-1].get("parent_revision_id") or ""))
if not parents:
return fallback_revision_id
def lineage(revision_id: str) -> list[str]:
chain = [revision_id]
seen = {revision_id}
current = revision_id
while current:
parent = str((by_id.get(current) or {}).get("parent_revision_id") or "")
if parent in seen:
break
chain.append(parent)
seen.add(parent)
current = parent
return chain
common = set(lineage(parents[0]))
for parent in parents[1:]:
common.intersection_update(lineage(parent))
if not common:
return fallback_revision_id
return next((revision for revision in lineage(parents[0]) if revision in common), fallback_revision_id)
def write_generation_failure(self, task_id: str, payload: dict[str, Any]) -> str:
"""Persist an attempt-level diagnostic without changing lifecycle."""
relative = Path("generation-failures") / f"failure_{secrets.token_hex(8)}.json"
write_json(self.task_dir(task_id) / relative, payload)
return relative.as_posix()
def running_tasks(self) -> list[dict[str, Any]]:
"""Enumerate durable tasks that need a process-local worker."""
tasks: list[dict[str, Any]] = []
for candidate in self.settings.task_root.glob("cad_*"):
if not candidate.is_dir() or not TASK_ID.fullmatch(candidate.name):
continue
task = self.read_task(candidate.name)
if isinstance(task, dict) and task.get("lifecycle") == "running":
tasks.append(task)
return tasks
def revision_dir(self, task_id: str, revision_id: str) -> Path:
return self.task_dir(task_id) / "revisions" / revision_id
def current_cdsl_path(self, task_id: str) -> Path | None:
task = self.read_task(task_id)
revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "")
if not revision_id:
return None
candidate = self.task_dir(task_id) / "revisions" / revision_id / "model.cdsl.json"
return candidate if candidate.is_file() else None
def revision_topology_path(self, task_id: str, revision_id: str) -> Path | None:
task = self.read_task(task_id)
revision = next(
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
None,
)
if not isinstance(revision, dict):
return None
relative = str(revision.get("topology_path") or "")
if not relative:
return None
candidate = self.artifact_path(task_id, relative)
return candidate if candidate.is_file() else None
def current_topology_path(self, task_id: str) -> Path | None:
task = self.read_task(task_id)
revision_id = str((task or {}).get("active_revision") or (task or {}).get("current_revision") or "")
if not revision_id:
return None
return self.revision_topology_path(task_id, revision_id)
def revision_cdsl_path(self, task_id: str, revision_id: str) -> Path | None:
task = self.read_task(task_id)
revision = next(
(item for item in (task or {}).get("revisions") or [] if str(item.get("revision_id") or "") == revision_id),
None,
)
if not isinstance(revision, dict):
return None
relative = str(revision.get("cdsl_path") or "")
if not relative:
return None
candidate = self.artifact_path(task_id, relative)
return candidate if candidate.is_file() else None
def artifact_path(self, task_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.task_dir(task_id).resolve()
target = (root / safe).resolve()
if root != target and root not in target.parents:
raise ValueError("Artifact path escapes task directory")
return target
def conversation_attachment_path(self, conversation_id: str, relative_path: str) -> Path:
safe = safe_relative_path(relative_path)
root = self.conversation_dir(conversation_id).resolve()
target = (root / safe).resolve()
if root != target and root not in target.parents:
raise ValueError("Attachment path escapes conversation directory")
return target