wip: preserve local engine and agent workflow changes before ganjihong refactor integration

This commit is contained in:
2026-09-09 17:08:24 +08:00
parent a5c3ec1f36
commit 387b99e255
112 changed files with 3543 additions and 12177 deletions
@@ -1,4 +1,4 @@
"""Immutable v3 file artifacts with staging manifests and atomic publication."""
"""Immutable single-stage artifacts with staging manifests and atomic publication."""
from __future__ import annotations
@@ -8,10 +8,9 @@ import os
from pathlib import Path
import re
import secrets
import shutil
from typing import Any
from app.cad_agent.ports import CandidateStage
from app.cad_agent.ports import StagingRevision
_SAFE_RELATIVE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,240}$")
@@ -27,7 +26,7 @@ class FileArtifactStore:
def task_dir(self, task_id: str) -> Path:
if not re.fullmatch(r"cad_[a-z0-9]{12}", task_id):
raise ValueError("Invalid v3 task id")
raise ValueError("Invalid CAD task id")
return self.root / task_id
def artifact_path(self, task_id: str, relative_path: str) -> Path:
@@ -43,7 +42,7 @@ class FileArtifactStore:
) -> None:
root = self.task_dir(task_id)
(root / "documents").mkdir(parents=True, exist_ok=True)
(root / "actions").mkdir(exist_ok=True)
(root / "events").mkdir(exist_ok=True)
(root / "revisions").mkdir(exist_ok=True)
(root / ".staging").mkdir(exist_ok=True)
source = root / "source-requirements.md"
@@ -67,14 +66,14 @@ class FileArtifactStore:
if images:
self.write_json_once(task_id, "documents/source-images.json", {"schema_version": "cad.source-images.v1", "images": images})
def sync_action_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
def sync_event_ledger(self, task_id: str, events: list[dict[str, Any]]) -> str:
"""Mirror committed SQLite events into an append-only JSONL audit log.
SQLite remains authoritative. Replaying this method after an
interruption appends only missing committed sequences and rejects a
divergent line instead of rewriting audit history.
"""
path = self._path(task_id, "actions/action-ledger.jsonl")
path = self._path(task_id, "events/event-ledger.jsonl")
existing: dict[int, dict[str, Any]] = {}
if path.is_file():
for raw in path.read_text(encoding="utf-8").splitlines():
@@ -83,18 +82,18 @@ class FileArtifactStore:
value = json.loads(raw)
sequence = value.get("sequence") if isinstance(value, dict) else None
if not isinstance(sequence, int) or sequence < 1:
raise ValueError("Action ledger contains an invalid sequence")
raise ValueError("Event ledger contains an invalid sequence")
existing[sequence] = value
missing: list[str] = []
for event in events:
sequence = event.get("sequence")
if not isinstance(sequence, int) or sequence < 1:
raise ValueError("Action ledger event has an invalid sequence")
entry = {"schema_version": "cad.action-ledger.v1", **event}
raise ValueError("Event ledger event has an invalid sequence")
entry = {"schema_version": "cad.event-ledger.v1", **event}
previous = existing.get(sequence)
if previous is not None:
if previous != entry:
raise ValueError("Action ledger diverges from committed SQLite event")
raise ValueError("Event ledger diverges from committed SQLite event")
continue
missing.append(json.dumps(entry, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n")
if missing:
@@ -103,7 +102,7 @@ class FileArtifactStore:
handle.writelines(missing)
handle.flush()
os.fsync(handle.fileno())
return "actions/action-ledger.jsonl"
return "events/event-ledger.jsonl"
def write_source_index(
self,
@@ -136,9 +135,8 @@ class FileArtifactStore:
"""Return immutable source paragraphs and attachment blocks.
Source identifiers are assigned only after this method returns, so
callers cannot choose IDs. Attachment metadata is deliberately a
closed, server-provided projection: it lets a reviewer trace the
source without treating upload metadata as arbitrary LLM input.
callers cannot choose IDs. Attachment metadata is a closed,
server-provided projection rather than arbitrary model input.
"""
supplied = source_blocks
if supplied is None:
@@ -195,19 +193,6 @@ class FileArtifactStore:
if isinstance(item, dict) and item.get("path") and self._path(task_id, str(item["path"])).is_file()
]
def read_requirements_spec(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
return self.read_json(task_id, artifact_path or "documents/requirements-spec.json")
def read_requirements_contract(self, task_id: str, artifact_path: str = "") -> dict[str, Any] | None:
# State points to the immutable artifact used as program input. The
# fixed name is a read-only convenience view for terminal tasks.
return self.read_json(task_id, artifact_path or "requirements-contract.json")
def write_requirements_contract(self, task_id: str, payload: dict[str, Any], *, invocation_id: str = "") -> str:
if not invocation_id:
return self.write_json_once(task_id, "requirements-contract.json", payload)
return self._write_invocation_json(task_id, "requirements-contract", payload, invocation_id)
def read_json(self, task_id: str, relative_path: str) -> dict[str, Any] | None:
path = self._path(task_id, relative_path)
if not path.is_file():
@@ -225,36 +210,20 @@ class FileArtifactStore:
self._write_once(path, text)
return relative_path
def read_active_cdsl(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
return self.read_json(task_id, f"revisions/{revision_id}/model.cdsl.json") if revision_id else None
def read_topology(self, task_id: str, revision_id: str) -> dict[str, Any] | None:
return self.read_json(task_id, f"revisions/{revision_id}/model.topology.json") if revision_id else None
def start_candidate_stage(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> CandidateStage:
def start_staging_revision(self, task_id: str, idempotency_key: str, payload: dict[str, Any]) -> StagingRevision:
root = self.task_dir(task_id) / ".staging"
stable_id = "stage_" + sha256(idempotency_key.encode("utf-8")).hexdigest()[:20]
directory = root / stable_id
directory.mkdir(parents=True, exist_ok=True)
self._write_json_once(directory / "input.json", payload)
return CandidateStage(stable_id, str(directory.resolve()))
def stage_output_dir(self, task_id: str, stage_id: str) -> str:
return str(self._stage_path(task_id, stage_id, ""))
return StagingRevision(stable_id, str(directory.resolve()))
def write_stage_json(self, task_id: str, stage_id: str, relative_path: str, payload: dict[str, Any]) -> str:
path = self._stage_path(task_id, stage_id, relative_path)
self._write_json_once(path, payload)
return relative_path
def read_stage_json(self, task_id: str, stage_id: str, relative_path: str) -> dict[str, Any] | None:
path = self._stage_path(task_id, stage_id, relative_path)
if not path.is_file():
return None
value = json.loads(path.read_text(encoding="utf-8"))
return value if isinstance(value, dict) else None
def publish_candidate(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
def publish_staging_revision(self, task_id: str, stage_id: str, revision_id: str) -> dict[str, str]:
source = self._stage_path(task_id, stage_id, "")
target = self._path(task_id, f"revisions/{revision_id}")
if target.exists():
@@ -262,9 +231,8 @@ class FileArtifactStore:
if manifest is None:
raise RuntimeError("Published revision has no valid manifest")
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
# Render/rebuild reports contain paths for the reviewer. Rebase those
# paths while artifacts are still mutable staging output, so they point
# at the revision after the atomic directory rename.
# Rebase report paths while artifacts are still mutable staging output,
# so they point at the revision after the atomic directory rename.
self._rebase_staged_paths(source, target)
manifest = self._create_manifest(source)
self._write_json_once(source / "manifest.json", manifest)
@@ -272,39 +240,6 @@ class FileArtifactStore:
os.replace(source, target)
return {key: f"revisions/{revision_id}/{key}" for key in manifest["files"]}
def find_published_candidate(self, task_id: str, stage_id: str) -> tuple[str, dict[str, Any]] | None:
"""Find a manifest-verified candidate already renamed before its DB CAS.
Publishing artifacts and advancing SQLite cannot share a transaction.
The stage id persisted inside ``candidate.json`` makes a post-rename
recovery deterministic and prevents another build or revision.
"""
revisions = self.task_dir(task_id) / "revisions"
if not revisions.is_dir():
return None
for revision in sorted(revisions.iterdir()):
if not revision.is_dir() or self._manifest(revision) is None:
continue
candidate_path = revision / "candidate.json"
if not candidate_path.is_file():
continue
try:
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
continue
if isinstance(candidate, dict) and candidate.get("stage_id") == stage_id:
return revision.name, candidate
return None
def recover_staged_candidates(self, task_id: str, referenced_stage_ids: set[str]) -> None:
root = self.task_dir(task_id) / ".staging"
if not root.is_dir():
return
for directory in root.iterdir():
if not directory.is_dir() or directory.name in referenced_stage_ids:
continue
shutil.rmtree(directory)
def _path(self, task_id: str, relative_path: str) -> Path:
if relative_path and (not _SAFE_RELATIVE.fullmatch(relative_path) or ".." in Path(relative_path).parts):
raise ValueError("Invalid artifact relative path")
@@ -316,7 +251,7 @@ class FileArtifactStore:
def _stage_path(self, task_id: str, stage_id: str, relative_path: str) -> Path:
if not re.fullmatch(r"stage_[a-f0-9]{20}", stage_id):
raise ValueError("Invalid candidate stage id")
raise ValueError("Invalid staging revision id")
root = self.task_dir(task_id).resolve()
stage = (root / ".staging" / stage_id).resolve()
if root not in stage.parents:
@@ -351,13 +286,6 @@ class FileArtifactStore:
temporary.write_bytes(data)
os.replace(temporary, path)
def _write_invocation_json(self, task_id: str, stem: str, payload: dict[str, Any], invocation_id: str) -> str:
digest = sha256(json.dumps(payload, ensure_ascii=True, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()[:12]
safe_invocation = re.sub(r"[^A-Za-z0-9_-]", "", invocation_id)[:32]
if not safe_invocation:
raise ValueError("Artifact invocation ID is invalid")
return self.write_json_once(task_id, f"documents/{stem}-{digest}-{safe_invocation}.json", payload)
@classmethod
def _write_json_once(cls, path: Path, payload: dict[str, Any]) -> None:
cls._write_once(path, json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n")
@@ -371,8 +299,8 @@ class FileArtifactStore:
relative = path.relative_to(directory).as_posix()
files[relative] = sha256(path.read_bytes()).hexdigest()
if not files:
raise RuntimeError("Candidate staging directory has no artifacts")
return {"schema_version": "cad.v3.artifact-manifest.v1", "files": files}
raise RuntimeError("Staging revision has no artifacts")
return {"schema_version": "cad.single-stage.artifact-manifest.v1", "files": files}
@staticmethod
def _manifest(directory: Path) -> dict[str, Any] | None: