更新功能
This commit is contained in:
@@ -181,21 +181,62 @@ class WorkspaceStore:
|
||||
path = self.task_path(tid)
|
||||
current = read_json(path)
|
||||
if current:
|
||||
return current
|
||||
return self._migrate_task(current, path)
|
||||
task_dir = self.task_dir(tid)
|
||||
(task_dir / "revisions").mkdir(parents=True, exist_ok=True)
|
||||
record = {
|
||||
"schema_version": "1.2",
|
||||
"schema_version": "1.3",
|
||||
"task_id": tid,
|
||||
"request": request,
|
||||
"created_at": now_iso(),
|
||||
"updated_at": now_iso(),
|
||||
"current_revision": "",
|
||||
"active_revision": "",
|
||||
"published_revision": "",
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"generation_spec_path": "",
|
||||
"run_context_path": "",
|
||||
"active_node_id": "",
|
||||
"run_failure_path": "",
|
||||
"revisions": [],
|
||||
}
|
||||
write_json(path, record)
|
||||
return record
|
||||
|
||||
def _migrate_task(self, task: dict[str, Any], path: Path) -> dict[str, Any]:
|
||||
"""Add run-state fields lazily without rewriting successful history."""
|
||||
changed = False
|
||||
current = str(task.get("current_revision") or "")
|
||||
defaults = {
|
||||
"schema_version": "1.3",
|
||||
"active_revision": current,
|
||||
"published_revision": current,
|
||||
"lifecycle": "completed",
|
||||
"run_id": "",
|
||||
"generation_spec_path": "",
|
||||
"run_context_path": "",
|
||||
"active_node_id": "",
|
||||
"run_failure_path": "",
|
||||
}
|
||||
for key, value in defaults.items():
|
||||
if key not in task:
|
||||
task[key] = value
|
||||
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}"
|
||||
@@ -208,16 +249,225 @@ class WorkspaceStore:
|
||||
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 read_task(self, task_id: str) -> dict[str, Any] | None:
|
||||
return read_json(self.task_path(task_id))
|
||||
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_node_id": "",
|
||||
"run_failure_path": "",
|
||||
"request": request or task.get("request") or "",
|
||||
"active_revision": str(task.get("current_revision") or ""),
|
||||
"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"}:
|
||||
raise ValueError("Generation lifecycle must be completed or failed")
|
||||
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_node_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"] = ""
|
||||
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_active_node(self, task_id: str, node_id: str) -> dict[str, Any]:
|
||||
task = self.ensure_task(task_id, "")
|
||||
task["active_node_id"] = node_id
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return task
|
||||
|
||||
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.set_active_revision(task_id, revision_id, branch_id=branch_id)
|
||||
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 parent and child:
|
||||
children.setdefault(parent, set()).add(child)
|
||||
superseded: set[str] = set()
|
||||
pending = list(children.get(revision_id, set())) if revision_id else [
|
||||
str(item.get("revision_id") or "")
|
||||
for item in task.get("revisions") or ()
|
||||
if isinstance(item, dict) and not str(item.get("parent_revision_id") or "")
|
||||
]
|
||||
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["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 generation_spec_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "generation-spec.json"
|
||||
|
||||
def write_generation_spec(self, task_id: str, spec: dict[str, Any]) -> Path:
|
||||
task = self.ensure_task(task_id, "")
|
||||
path = self.generation_spec_path(task_id)
|
||||
write_json(path, spec)
|
||||
task["generation_spec_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def generation_run_context_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "generation-run-context.json"
|
||||
|
||||
def write_generation_run_context(self, task_id: str, context: dict[str, Any]) -> Path:
|
||||
"""Persist the frozen authoring inputs needed to resume after restart."""
|
||||
task = self.ensure_task(task_id, "")
|
||||
path = self.generation_run_context_path(task_id)
|
||||
write_json(path, context)
|
||||
task["run_context_path"] = path.relative_to(self.task_dir(task_id)).as_posix()
|
||||
task["updated_at"] = now_iso()
|
||||
write_json(self.task_path(task_id), task)
|
||||
return path
|
||||
|
||||
def read_generation_run_context(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("run_context_path") or "")
|
||||
context = read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
return context if isinstance(context, dict) else None
|
||||
|
||||
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 read_generation_spec(self, task_id: str) -> dict[str, Any] | None:
|
||||
task = self.read_task(task_id) or {}
|
||||
relative = str(task.get("generation_spec_path") or "")
|
||||
return read_json(self.artifact_path(task_id, relative)) if relative else None
|
||||
|
||||
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("current_revision") or "")
|
||||
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"
|
||||
@@ -235,6 +485,52 @@ class WorkspaceStore:
|
||||
return revision_id, path
|
||||
return None
|
||||
|
||||
def feature_plan_path(self, task_id: str) -> Path:
|
||||
return self.task_dir(task_id) / "feature-plan.json"
|
||||
|
||||
def read_feature_plan(self, task_id: str) -> dict[str, Any] | None:
|
||||
safe_task = safe_task_id(task_id)
|
||||
plan = read_json(self.feature_plan_path(safe_task))
|
||||
if not isinstance(plan, dict):
|
||||
return None
|
||||
plan_task = str(plan.get("task_id") or "")
|
||||
if plan_task and plan_task != safe_task:
|
||||
return None
|
||||
return plan
|
||||
|
||||
def write_feature_plan(self, task_id: str, plan: dict[str, Any]) -> Path:
|
||||
safe_task = safe_task_id(task_id)
|
||||
if not isinstance(plan, dict):
|
||||
raise ValueError("Feature plan must be an object")
|
||||
plan_task = str(plan.get("task_id") or "")
|
||||
if plan_task and plan_task != safe_task:
|
||||
raise ValueError("Feature plan task_id does not match its task")
|
||||
plan["task_id"] = safe_task
|
||||
path = self.feature_plan_path(safe_task)
|
||||
write_json(path, plan)
|
||||
return path
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user