688 lines
31 KiB
Python
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
|