wip: preserve local engine and agent workflow changes before ganjihong refactor integration
This commit is contained in:
@@ -1 +1 @@
|
||||
"""Infrastructure adapters for the v3 ports."""
|
||||
"""Infrastructure adapters for the single-stage ports."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""Bounded, file-backed author guidance for the CDSL workflow.
|
||||
|
||||
The corpus is deliberately non-authoritative: contracts, schemas, topology
|
||||
tokens, and server preflight always remain the executable source of truth.
|
||||
Loading errors return an empty selection so authoring continues with the
|
||||
pre-guidance prompt instead of turning documentation into an availability
|
||||
dependency.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.state import TaskPhase
|
||||
from app.cad_agent.ports import AuthorGuidanceSelection
|
||||
|
||||
|
||||
_MIN_CHARS = 1_200
|
||||
_MAX_CHARS = 6_000
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Section:
|
||||
section_id: str
|
||||
title: str
|
||||
priority: int
|
||||
mandatory: bool
|
||||
content: str
|
||||
|
||||
@property
|
||||
def block(self) -> str:
|
||||
return f"## {self.title}\n{self.content.strip()}"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Corpus:
|
||||
version: str
|
||||
sections: dict[str, _Section]
|
||||
phase_sections: dict[str, tuple[str, ...]]
|
||||
repair_sections: tuple[str, ...]
|
||||
final_sections: tuple[str, ...]
|
||||
operation_sections: dict[str, tuple[str, ...]]
|
||||
|
||||
|
||||
class FileAuthorGuidance:
|
||||
"""Read and select the checked-in corpus deterministically.
|
||||
|
||||
Selection depends exclusively on workflow state and the runtime operation
|
||||
registry. It intentionally receives neither the user's request nor image
|
||||
observations, so it cannot become an implicit part-family classifier.
|
||||
"""
|
||||
|
||||
def __init__(self, root: Path, *, enabled: bool = True, max_chars: int = 3_600) -> None:
|
||||
self.root = root
|
||||
self.enabled = enabled
|
||||
self.max_chars = min(_MAX_CHARS, max(_MIN_CHARS, max_chars))
|
||||
self._corpus: _Corpus | None = None
|
||||
self._load_error = ""
|
||||
|
||||
def select(
|
||||
self,
|
||||
*,
|
||||
phase: TaskPhase,
|
||||
atomic_id: str,
|
||||
repair_required: bool,
|
||||
supported_atomic_ids: tuple[str, ...],
|
||||
) -> AuthorGuidanceSelection:
|
||||
if not self.enabled:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_disabled")
|
||||
corpus = self._load()
|
||||
if corpus is None:
|
||||
return AuthorGuidanceSelection(fallback_reason=self._load_error or "guidance_unavailable")
|
||||
supported = set(supported_atomic_ids)
|
||||
if set(corpus.operation_sections) != supported:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_operation_coverage_mismatch")
|
||||
if atomic_id and atomic_id not in supported:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_unknown_atomic_id")
|
||||
|
||||
if repair_required:
|
||||
requested = list(corpus.repair_sections)
|
||||
elif phase == TaskPhase.FINAL_VALIDATION:
|
||||
requested = list(corpus.final_sections)
|
||||
else:
|
||||
requested = list(corpus.phase_sections.get(phase.value, corpus.phase_sections.get("DEFAULT", ())))
|
||||
if atomic_id:
|
||||
requested.extend(corpus.operation_sections[atomic_id])
|
||||
requested = list(dict.fromkeys(requested))
|
||||
if not requested:
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_no_matching_sections")
|
||||
|
||||
mandatory = [section_id for section_id in requested if corpus.sections[section_id].mandatory]
|
||||
optional = [section_id for section_id in requested if not corpus.sections[section_id].mandatory]
|
||||
optional.sort(key=lambda section_id: (-corpus.sections[section_id].priority, requested.index(section_id)))
|
||||
selected: list[str] = []
|
||||
text = ""
|
||||
for section_id in [*mandatory, *optional]:
|
||||
block = corpus.sections[section_id].block
|
||||
candidate = block if not text else f"{text}\n\n{block}"
|
||||
if len(candidate) <= self.max_chars:
|
||||
text = candidate
|
||||
selected.append(section_id)
|
||||
elif section_id in mandatory:
|
||||
# Do not silently drop contract, datum, or operation guidance.
|
||||
return AuthorGuidanceSelection(fallback_reason="guidance_required_sections_exceed_budget")
|
||||
return AuthorGuidanceSelection(
|
||||
version=corpus.version,
|
||||
section_ids=tuple(selected),
|
||||
content=text,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
def _load(self) -> _Corpus | None:
|
||||
if self._corpus is not None:
|
||||
return self._corpus
|
||||
if self._load_error:
|
||||
return None
|
||||
try:
|
||||
manifest_path = self.root / "manifest.json"
|
||||
raw = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("manifest is not an object")
|
||||
version = raw.get("version")
|
||||
if raw.get("schema_version") != "cdsl.author-guidance.manifest.v1" or not isinstance(version, str) or not version:
|
||||
raise ValueError("manifest version is invalid")
|
||||
raw_sections = raw.get("sections")
|
||||
if not isinstance(raw_sections, list) or not raw_sections:
|
||||
raise ValueError("manifest sections are invalid")
|
||||
sections: dict[str, _Section] = {}
|
||||
root = self.root.resolve()
|
||||
for item in raw_sections:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("section declaration is invalid")
|
||||
section_id = item.get("id")
|
||||
filename = item.get("file")
|
||||
title = item.get("title")
|
||||
priority = item.get("priority")
|
||||
mandatory = item.get("mandatory", False)
|
||||
if (
|
||||
not isinstance(section_id, str) or not section_id
|
||||
or not isinstance(filename, str) or not filename
|
||||
or not isinstance(title, str) or not title
|
||||
or not isinstance(priority, int) or isinstance(priority, bool)
|
||||
or not isinstance(mandatory, bool)
|
||||
or section_id in sections
|
||||
):
|
||||
raise ValueError("section metadata is invalid")
|
||||
path = (self.root / filename).resolve()
|
||||
if root not in path.parents or not path.is_file():
|
||||
raise ValueError("section file is unavailable")
|
||||
content = path.read_text(encoding="utf-8").strip()
|
||||
if not content:
|
||||
raise ValueError("section content is empty")
|
||||
sections[section_id] = _Section(section_id, title, priority, mandatory, content)
|
||||
|
||||
def identifiers(value: Any, field: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item in sections for item in value):
|
||||
raise ValueError(f"{field} is invalid")
|
||||
return tuple(dict.fromkeys(value))
|
||||
|
||||
raw_phases = raw.get("phase_sections")
|
||||
if not isinstance(raw_phases, dict) or "DEFAULT" not in raw_phases:
|
||||
raise ValueError("phase sections are invalid")
|
||||
phase_sections = {
|
||||
phase: identifiers(section_ids, f"phase {phase}")
|
||||
for phase, section_ids in raw_phases.items()
|
||||
if isinstance(phase, str)
|
||||
}
|
||||
if len(phase_sections) != len(raw_phases):
|
||||
raise ValueError("phase name is invalid")
|
||||
operation_sections = {
|
||||
atomic_id: identifiers(section_ids, f"operation {atomic_id}")
|
||||
for atomic_id, section_ids in (raw.get("operation_sections") or {}).items()
|
||||
if isinstance(atomic_id, str)
|
||||
}
|
||||
if not operation_sections or len(operation_sections) != len(raw.get("operation_sections") or {}):
|
||||
raise ValueError("operation sections are invalid")
|
||||
self._corpus = _Corpus(
|
||||
version=version,
|
||||
sections=sections,
|
||||
phase_sections=phase_sections,
|
||||
repair_sections=identifiers(raw.get("repair_sections"), "repair sections"),
|
||||
final_sections=identifiers(raw.get("final_sections"), "final sections"),
|
||||
operation_sections=operation_sections,
|
||||
)
|
||||
return self._corpus
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as error:
|
||||
self._load_error = f"guidance_load_failed:{type(error).__name__}"
|
||||
return None
|
||||
@@ -1,4 +1,4 @@
|
||||
"""In-process idempotent event delivery for the v3 delivery boundary."""
|
||||
"""In-process idempotent event delivery for the single-stage protocol."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
"""Independent rendered-review adapter for protocol v3."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.adapters.structured_llm import StructuredModelGateway
|
||||
from app.cad_agent.ports import AdapterUnavailable
|
||||
|
||||
|
||||
class RenderedReviewGateway:
|
||||
def __init__(self, models: StructuredModelGateway) -> None:
|
||||
self.models = models
|
||||
|
||||
async def review(self, *, kind: str, payload: dict[str, Any], tool: dict[str, Any], provider_id: str, model_id: str) -> dict[str, Any]:
|
||||
name = str((tool.get("function") or {}).get("name") or "")
|
||||
if not name:
|
||||
raise RuntimeError("Review tool is missing a name")
|
||||
public_payload = {key: value for key, value in payload.items() if key != "reference_image_paths"}
|
||||
content: list[dict[str, Any]] = [{"type": "text", "text": json.dumps(public_payload, ensure_ascii=False)}]
|
||||
if kind in {"image_observation", "final"}:
|
||||
for raw_path in payload.get("reference_image_paths") or ():
|
||||
path = Path(str(raw_path))
|
||||
if path.is_file():
|
||||
content.append(self._image_part(path))
|
||||
if kind in {"candidate", "final"}:
|
||||
manifest = payload.get("render_manifest") if isinstance(payload.get("render_manifest"), dict) else {}
|
||||
for path in self._evidence_paths(manifest):
|
||||
content.append(self._image_part(path))
|
||||
return await self.models.call_tool(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are an independent CAD reviewer. Inspect supplied deterministic facts and rendered images. Return only the specified structured tool call."},
|
||||
{"role": "user", "content": content},
|
||||
],
|
||||
tool=tool,
|
||||
provider_id=provider_id,
|
||||
model_id=model_id,
|
||||
required_tool_name=name,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _evidence_paths(manifest: dict[str, Any]) -> list[Path]:
|
||||
selected: list[Path] = []
|
||||
contact = Path(str(manifest.get("contact_sheet_path") or ""))
|
||||
if contact.is_file():
|
||||
selected.append(contact)
|
||||
wanted = {"top", "front", "right", "isometric"}
|
||||
for item in manifest.get("views") or ():
|
||||
if not isinstance(item, dict) or str(item.get("id") or "") not in wanted:
|
||||
continue
|
||||
path = Path(str(item.get("path") or ""))
|
||||
if path.is_file():
|
||||
selected.append(path)
|
||||
if not selected:
|
||||
raise AdapterUnavailable("RENDER_SERVICE_UNAVAILABLE: review render evidence is unavailable")
|
||||
return selected[:5]
|
||||
|
||||
@staticmethod
|
||||
def _image_part(path: Path) -> dict[str, Any]:
|
||||
media_type = "image/jpeg" if path.suffix.lower() in {".jpg", ".jpeg"} else "image/png"
|
||||
return {"type": "image_url", "image_url": {"url": f"data:{media_type};base64,{base64.b64encode(path.read_bytes()).decode('ascii')}"}}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
||||
"""SQLite repository: the sole mutable state authority for protocol v3."""
|
||||
"""SQLite authority for ``cad.single-stage.v1`` tasks.
|
||||
|
||||
There is no reader or migration for the deleted feature-DAG protocol. Opening
|
||||
a legacy task database drops its task data before creating this schema.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
@@ -10,11 +13,13 @@ from threading import RLock
|
||||
from typing import Any, Iterator
|
||||
|
||||
from app.cad_agent.domain.errors import ErrorCode
|
||||
from app.cad_agent.domain.state import PendingAction, TaskPhase, TaskState
|
||||
from app.cad_agent.domain.state import TaskPhase, TaskState
|
||||
from app.cad_agent.ports import InvocationRecord
|
||||
|
||||
|
||||
class SqliteTaskRepository:
|
||||
PROTOCOL_VERSION = "cad.single-stage.v1"
|
||||
|
||||
def __init__(self, database_path: Path) -> None:
|
||||
self.database_path = database_path
|
||||
self.database_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -35,271 +40,151 @@ class SqliteTaskRepository:
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
# Protocol 3.2 intentionally has no migration path from the
|
||||
# structured-only / review-loop task model. Deployment starts with
|
||||
# an empty task database, as those tasks do not have immutable
|
||||
# Markdown source artifacts to compile from.
|
||||
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'tasks'").fetchone()
|
||||
if existing is not None and "'3.2'" not in str(existing[0] or ""):
|
||||
existing = connection.execute("SELECT sql FROM sqlite_master WHERE type='table' AND name='tasks'").fetchone()
|
||||
if existing is not None and self.PROTOCOL_VERSION not in str(existing[0] or ""):
|
||||
self.protocol_reset = True
|
||||
connection.executescript("""
|
||||
DROP TABLE IF EXISTS outbox;
|
||||
DROP TABLE IF EXISTS tool_audits;
|
||||
DROP TABLE IF EXISTS usage_records;
|
||||
DROP TABLE IF EXISTS invocations;
|
||||
DROP TABLE IF EXISTS ledger;
|
||||
DROP TABLE IF EXISTS model_capabilities;
|
||||
DROP TABLE IF EXISTS tasks;
|
||||
""")
|
||||
connection.executescript(
|
||||
"""
|
||||
# This database is dedicated to CAD task state. An old
|
||||
# protocol has no safe reader or migration, so remove every
|
||||
# persisted task table and let SQLite remove their indexes.
|
||||
tables = connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
).fetchall()
|
||||
for row in tables:
|
||||
name = str(row[0]).replace('"', '""')
|
||||
connection.execute(f'DROP TABLE IF EXISTS "{name}"')
|
||||
connection.executescript(f"""
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '3.2'),
|
||||
request TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
state_version INTEGER NOT NULL,
|
||||
active_revision TEXT NOT NULL DEFAULT '',
|
||||
pending_action_json TEXT,
|
||||
candidate_id TEXT NOT NULL DEFAULT '',
|
||||
candidate_stage_id TEXT NOT NULL DEFAULT '',
|
||||
repair_required INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT,
|
||||
retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||
requirements_spec_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_document_path TEXT NOT NULL DEFAULT '',
|
||||
completion_target_path TEXT NOT NULL DEFAULT '',
|
||||
modeling_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_path TEXT NOT NULL DEFAULT '',
|
||||
feature_plan_hash TEXT NOT NULL DEFAULT '',
|
||||
feature_stage_id TEXT NOT NULL DEFAULT '',
|
||||
protocol_version TEXT NOT NULL CHECK(protocol_version = '{self.PROTOCOL_VERSION}'),
|
||||
request TEXT NOT NULL, phase TEXT NOT NULL, state_version INTEGER NOT NULL,
|
||||
active_revision TEXT NOT NULL DEFAULT '', repair_count INTEGER NOT NULL DEFAULT 0,
|
||||
last_error TEXT, retry_from_phase TEXT NOT NULL DEFAULT '',
|
||||
requirements_path TEXT NOT NULL DEFAULT '', authoring_path TEXT NOT NULL DEFAULT '',
|
||||
runtime_cdsl_path TEXT NOT NULL DEFAULT '', compile_audit_path TEXT NOT NULL DEFAULT '',
|
||||
diagnostics_path TEXT NOT NULL DEFAULT '', completion_path TEXT NOT NULL DEFAULT '',
|
||||
clarification_path TEXT NOT NULL DEFAULT '',
|
||||
requirements_contract_path TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS invocations (
|
||||
invocation_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
idempotency_key TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id, idempotency_key)
|
||||
invocation_id TEXT PRIMARY KEY, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
idempotency_key TEXT NOT NULL, status TEXT NOT NULL, result_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(task_id,idempotency_key)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ledger (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL,
|
||||
published_at TEXT
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
event_json TEXT NOT NULL, published_at TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS usage_records (
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
usage_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
usage_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
usage_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tool_audits (
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
audit_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
audit_id INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(task_id),
|
||||
audit_json TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS model_capabilities (
|
||||
provider_id TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
schema_hash TEXT NOT NULL,
|
||||
supported INTEGER NOT NULL,
|
||||
report_json TEXT NOT NULL,
|
||||
checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(provider_id, model_id, schema_hash)
|
||||
provider_id TEXT NOT NULL, model_id TEXT NOT NULL, schema_hash TEXT NOT NULL,
|
||||
supported INTEGER NOT NULL, report_json TEXT NOT NULL, checked_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY(provider_id,model_id,schema_hash)
|
||||
);
|
||||
"""
|
||||
)
|
||||
""")
|
||||
|
||||
def create_task(self, task_id: str, request: str) -> TaskState:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO tasks(task_id, protocol_version, request, phase, state_version) VALUES (?, '3.2', ?, ?, 0)",
|
||||
(task_id, request, TaskPhase.DRAFTING_REQUIREMENTS_DOCUMENT.value),
|
||||
"INSERT OR IGNORE INTO tasks(task_id,protocol_version,request,phase,state_version) VALUES (?,?,?,?,0)",
|
||||
(task_id, self.PROTOCOL_VERSION, request, TaskPhase.ANALYZING_REQUEST.value),
|
||||
)
|
||||
state = self.get_state(task_id)
|
||||
if state is None:
|
||||
raise RuntimeError("SQLite task insert was not visible")
|
||||
raise RuntimeError("Task was not persisted")
|
||||
return state
|
||||
|
||||
def get_state(self, task_id: str) -> TaskState | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id = ?", (task_id,)).fetchone()
|
||||
return self._state(row) if row is not None else None
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
return self._state(row) if row else None
|
||||
|
||||
def get_task_projection(self, task_id: str) -> dict[str, Any] | None:
|
||||
state = self.get_state(task_id)
|
||||
if state is None:
|
||||
return None
|
||||
events = self.ledger_events(task_id)
|
||||
revisions = [
|
||||
{
|
||||
"revision_id": item["revision_id"], "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED and item["revision_id"] == state.active_revision else "checkpoint",
|
||||
"cdsl_path": f"revisions/{item['revision_id']}/model.cdsl.json", "step_path": f"revisions/{item['revision_id']}/model.step", "glb_path": "" if item.get("preview_unavailable") else f"revisions/{item['revision_id']}/model.glb", "report_path": f"revisions/{item['revision_id']}/rebuild-report.json", "candidate_review_path": item.get("review_path", ""),
|
||||
}
|
||||
for item in events
|
||||
if item.get("event") in {"accepted", "feature_node_verified"} and isinstance(item.get("revision_id"), str)
|
||||
]
|
||||
frozen = next((item for item in reversed(events) if item.get("event") == "requirements_compiled"), {})
|
||||
verification_warnings = [
|
||||
str(item) for item in frozen.get("verification_warnings") or () if str(item)
|
||||
] if isinstance(frozen, dict) else []
|
||||
status_event = next((
|
||||
item for item in reversed(events)
|
||||
if item.get("event") in {
|
||||
"requirements_waiting_for_user", "waiting_retry", "call_budget_exhausted",
|
||||
"no_progress_limit",
|
||||
"candidate_runtime_execution_failure", "candidate_recovery_runtime_execution_failure",
|
||||
"failed_author_format", "runtime_contract_invalid",
|
||||
"completed_best_effort",
|
||||
}
|
||||
), {}) if state.phase in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER} else {}
|
||||
questions = [str(item) for item in status_event.get("questions") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
issues = [str(item) for item in status_event.get("issues") or () if str(item)] if isinstance(status_event, dict) else []
|
||||
# A later repair can end at authoring or compilation after an earlier
|
||||
# build already published an executable prefix. Its terminal event
|
||||
# intentionally contains diagnostics rather than a duplicate path
|
||||
# map, so keep the most recent event that actually delivered a
|
||||
# revision. Otherwise the completed projection loses the existing
|
||||
# STEP/GLB links exactly when best-effort publication matters most.
|
||||
result: dict[str, Any] = {}
|
||||
paths: dict[str, Any] = {}
|
||||
for item in reversed(events):
|
||||
if item.get("event") not in {
|
||||
"build_completed", "published_best_effort", "build_failed",
|
||||
}:
|
||||
continue
|
||||
candidate_paths = item.get("paths")
|
||||
if isinstance(candidate_paths, dict) and candidate_paths:
|
||||
result, paths = item, candidate_paths
|
||||
break
|
||||
return {
|
||||
"schema_version": "3.2",
|
||||
"task_id": state.task_id,
|
||||
"phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase),
|
||||
"state_version": state.version,
|
||||
"active_revision": state.active_revision,
|
||||
"current_revision": state.active_revision,
|
||||
"schema_version": self.PROTOCOL_VERSION, "task_id": task_id, "phase": state.phase.value,
|
||||
"lifecycle": self._lifecycle(state.phase), "state_version": state.version,
|
||||
"active_revision": state.active_revision, "current_revision": state.active_revision,
|
||||
"published_revision": state.active_revision if state.phase == TaskPhase.COMPLETED else "",
|
||||
"pending_action": self._pending_payload(state.pending_action),
|
||||
"active_candidate_id": state.candidate_id,
|
||||
"repair_required": state.repair_required,
|
||||
"repair_count": state.repair_count, "repair_budget": 2,
|
||||
"last_error": state.last_error.value if state.last_error else "",
|
||||
"retry_from_phase": state.retry_from_phase.value if state.retry_from_phase else "",
|
||||
"requirements_spec_path": state.requirements_spec_path,
|
||||
"requirements_document_path": state.requirements_document_path,
|
||||
"completion_target_path": state.completion_target_path,
|
||||
"modeling_plan_path": state.modeling_plan_path,
|
||||
"feature_plan_path": state.feature_plan_path,
|
||||
"feature_plan_hash": state.feature_plan_hash,
|
||||
"current_feature_node_id": state.pending_feature.node_id if state.pending_feature else "",
|
||||
"pending_feature": self._pending_payload(state.pending_feature),
|
||||
"feature_nodes": self._feature_node_projection(events),
|
||||
"clarification_path": state.clarification_path,
|
||||
"requirements_contract_path": state.requirements_contract_path,
|
||||
"verification_status": (
|
||||
"completed_with_risks" if state.phase == TaskPhase.COMPLETED and (verification_warnings or state.last_error == ErrorCode.BEST_EFFORT_COMPLETED)
|
||||
else "verified" if state.phase == TaskPhase.COMPLETED
|
||||
else "pending"
|
||||
),
|
||||
"verification_warnings": verification_warnings,
|
||||
"message": str(status_event.get("message") or "") if isinstance(status_event, dict) else "",
|
||||
"questions": questions,
|
||||
"issues": issues,
|
||||
"blocker_type": (
|
||||
"requirements_ambiguity" if state.phase == TaskPhase.WAITING_FOR_USER
|
||||
else str(status_event.get("event") or "") if isinstance(status_event, dict) else ""
|
||||
),
|
||||
"user_action_required": state.phase == TaskPhase.WAITING_FOR_USER and bool(questions),
|
||||
"action_ledger_summary": events[-12:],
|
||||
"revisions": revisions,
|
||||
"requirements_path": state.requirements_path, "authoring_path": state.authoring_path,
|
||||
"runtime_cdsl_path": state.runtime_cdsl_path, "compile_audit_path": state.compile_audit_path,
|
||||
"diagnostics_path": state.diagnostics_path, "completion_path": state.completion_path,
|
||||
"message": str(result.get("message") or ""),
|
||||
"revisions": ([{
|
||||
"revision_id": state.active_revision, "status": "success", "visibility": "final" if state.phase == TaskPhase.COMPLETED else "checkpoint",
|
||||
"cdsl_path": paths.get("model.cdsl.json", ""), "step_path": paths.get("model.step", ""),
|
||||
"glb_path": paths.get("model.glb", ""), "report_path": paths.get("rebuild-report.json", ""),
|
||||
}] if state.active_revision else []),
|
||||
"action_ledger_summary": events[-16:],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _feature_node_projection(events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Return ledger-backed execution evidence; API adds immutable plan fields."""
|
||||
nodes: dict[str, dict[str, Any]] = {}
|
||||
for event in events:
|
||||
node_id = str(event.get("node_id") or "")
|
||||
if not node_id:
|
||||
continue
|
||||
item = nodes.setdefault(node_id, {"node_id": node_id, "status": "pending", "attempt": 0})
|
||||
if event.get("event") == "feature_node_scheduled":
|
||||
item.update({"status": "running", "atomic_id": str(event.get("atomic_id") or ""), "priority": event.get("priority"), "claim_ids": event.get("claim_ids") or [], "depends_on": event.get("depends_on") or []})
|
||||
elif event.get("event") == "feature_node_failed":
|
||||
item.update({"status": "failed" if event.get("terminal") else "pending", "attempt": int(event.get("attempt") or 0), "failure_class": str(event.get("failure_class") or ""), "error": str(event.get("message") or "")})
|
||||
elif event.get("event") == "feature_node_verified":
|
||||
item.update({"status": "done", "revision_id": str(event.get("revision_id") or ""), "feature_id": str(event.get("feature_id") or ""), "evidence": event.get("claim_results") or []})
|
||||
elif event.get("event") == "feature_node_invalidated" and item.get("status") != "done":
|
||||
item["status"] = "invalidated"
|
||||
return list(nodes.values())
|
||||
|
||||
def ledger_events(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT sequence, event_json, created_at FROM ledger WHERE task_id = ? ORDER BY sequence", (task_id,)).fetchall()
|
||||
return [{"sequence": row["sequence"], "at": row["created_at"], **json.loads(row["event_json"])} for row in rows]
|
||||
rows = connection.execute("SELECT sequence,event_json,created_at FROM ledger WHERE task_id=? ORDER BY sequence", (task_id,)).fetchall()
|
||||
return [{"sequence": int(row["sequence"]), "at": str(row["created_at"]), **json.loads(row["event_json"])} for row in rows]
|
||||
|
||||
def invocation_records(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT invocation_id, idempotency_key, status, result_json, created_at, updated_at FROM invocations WHERE task_id = ? ORDER BY created_at, invocation_id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"invocation_id": str(row["invocation_id"]), "idempotency_key": str(row["idempotency_key"]),
|
||||
"status": str(row["status"]), "result": json.loads(row["result_json"]) if row["result_json"] else None,
|
||||
"created_at": str(row["created_at"]), "updated_at": str(row["updated_at"]),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def compare_and_swap(
|
||||
self,
|
||||
state: TaskState,
|
||||
*,
|
||||
events: list[dict[str, Any]] = (),
|
||||
invocation_id: str | None = None,
|
||||
invocation_result: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
def compare_and_swap(self, state: TaskState, *, events: list[dict[str, Any]] = (), invocation_id: str | None = None, invocation_result: dict[str, Any] | None = None) -> bool:
|
||||
if (invocation_id is None) != (invocation_result is None):
|
||||
raise ValueError("Invocation completion requires both invocation_id and invocation_result")
|
||||
previous_version = state.version - 1
|
||||
if previous_version < 0:
|
||||
raise ValueError("State version must advance exactly once")
|
||||
raise ValueError("Invocation completion requires both values")
|
||||
previous = state.version - 1
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
cursor = connection.execute(
|
||||
"""UPDATE tasks SET phase = ?, state_version = ?, active_revision = ?, pending_action_json = ?,
|
||||
candidate_id = ?, candidate_stage_id = ?, repair_required = ?, last_error = ?, retry_from_phase = ?, requirements_spec_path = ?,
|
||||
requirements_document_path = ?, completion_target_path = ?, modeling_plan_path = ?, feature_plan_path = ?, feature_plan_hash = ?, feature_stage_id = ?, clarification_path = ?, requirements_contract_path = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE task_id = ? AND state_version = ?""",
|
||||
(
|
||||
state.phase.value, state.version, state.active_revision,
|
||||
json.dumps(self._pending_payload(state.pending_action), ensure_ascii=True) if state.pending_action else None,
|
||||
state.candidate_id, state.candidate_stage_id,
|
||||
int(state.repair_required), state.last_error.value if state.last_error else None,
|
||||
state.retry_from_phase.value if state.retry_from_phase else "", state.requirements_spec_path,
|
||||
state.requirements_document_path, state.completion_target_path, state.modeling_plan_path,
|
||||
state.feature_plan_path, state.feature_plan_hash, state.feature_stage_id,
|
||||
state.clarification_path, state.requirements_contract_path,
|
||||
state.task_id, previous_version,
|
||||
),
|
||||
)
|
||||
cursor = connection.execute("""
|
||||
UPDATE tasks SET phase=?,state_version=?,active_revision=?,repair_count=?,last_error=?,retry_from_phase=?,
|
||||
requirements_path=?,authoring_path=?,runtime_cdsl_path=?,compile_audit_path=?,diagnostics_path=?,completion_path=?,clarification_path=?,updated_at=CURRENT_TIMESTAMP
|
||||
WHERE task_id=? AND state_version=?
|
||||
""", (
|
||||
state.phase.value, state.version, state.active_revision, state.repair_count,
|
||||
state.last_error.value if state.last_error else None, state.retry_from_phase.value if state.retry_from_phase else "",
|
||||
state.requirements_path, state.authoring_path, state.runtime_cdsl_path, state.compile_audit_path,
|
||||
state.diagnostics_path, state.completion_path, state.clarification_path, state.task_id, previous,
|
||||
))
|
||||
if cursor.rowcount != 1:
|
||||
connection.execute("ROLLBACK")
|
||||
return False
|
||||
for event in events:
|
||||
encoded = json.dumps(event, ensure_ascii=True, sort_keys=True)
|
||||
connection.execute("INSERT INTO ledger(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
|
||||
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (state.task_id, encoded))
|
||||
if invocation_id is not None and invocation_result is not None:
|
||||
finished = connection.execute(
|
||||
"""UPDATE invocations
|
||||
SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE invocation_id = ? AND task_id = ? AND status = 'processing'""",
|
||||
(json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id),
|
||||
)
|
||||
if finished.rowcount != 1:
|
||||
raise ValueError("Invocation is not an active record for this state transition")
|
||||
for item in events:
|
||||
encoded = json.dumps(item, ensure_ascii=True, sort_keys=True)
|
||||
connection.execute("INSERT INTO ledger(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
|
||||
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (state.task_id, encoded))
|
||||
if invocation_id is not None:
|
||||
updated = connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND task_id=? AND status='processing'", (json.dumps(invocation_result, ensure_ascii=True), invocation_id, state.task_id))
|
||||
if updated.rowcount != 1:
|
||||
raise ValueError("Invocation is not active")
|
||||
connection.execute("COMMIT")
|
||||
return True
|
||||
except Exception:
|
||||
@@ -310,144 +195,92 @@ class SqliteTaskRepository:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
existing = connection.execute("SELECT * FROM invocations WHERE task_id = ? AND idempotency_key = ?", (task_id, idempotency_key)).fetchone()
|
||||
if existing is not None:
|
||||
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND idempotency_key=?", (task_id, idempotency_key)).fetchone()
|
||||
if row is None:
|
||||
connection.execute("INSERT INTO invocations(invocation_id,task_id,idempotency_key,status) VALUES (?,?,?,'processing')", (invocation_id, task_id, idempotency_key))
|
||||
connection.execute("COMMIT")
|
||||
return self._invocation(existing)
|
||||
connection.execute("INSERT INTO invocations(invocation_id, task_id, idempotency_key, status) VALUES (?, ?, ?, 'processing')", (invocation_id, task_id, idempotency_key))
|
||||
return InvocationRecord(invocation_id, idempotency_key, "processing")
|
||||
connection.execute("COMMIT")
|
||||
return InvocationRecord(invocation_id, idempotency_key, "processing")
|
||||
return self._invocation(row)
|
||||
except Exception:
|
||||
connection.execute("ROLLBACK")
|
||||
raise
|
||||
|
||||
def get_invocation(self, task_id: str, invocation_id: str) -> InvocationRecord | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM invocations WHERE task_id = ? AND invocation_id = ?",
|
||||
(task_id, invocation_id),
|
||||
).fetchone()
|
||||
return self._invocation(row) if row is not None else None
|
||||
row = connection.execute("SELECT * FROM invocations WHERE task_id=? AND invocation_id=?", (task_id, invocation_id)).fetchone()
|
||||
return self._invocation(row) if row else None
|
||||
|
||||
def finish_invocation(self, invocation_id: str, result: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT status, result_json FROM invocations WHERE invocation_id = ?", (invocation_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ValueError("Unknown invocation")
|
||||
if str(row["status"]) == "finished":
|
||||
# A winning concurrent command has already recorded the only
|
||||
# durable result for this idempotency key. Never overwrite it.
|
||||
return
|
||||
cursor = connection.execute(
|
||||
"UPDATE invocations SET status = 'finished', result_json = ?, updated_at = CURRENT_TIMESTAMP WHERE invocation_id = ? AND status = 'processing'",
|
||||
(json.dumps(result, ensure_ascii=True), invocation_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise ValueError("Invocation could not be completed")
|
||||
connection.execute("UPDATE invocations SET status='finished',result_json=?,updated_at=CURRENT_TIMESTAMP WHERE invocation_id=? AND status='processing'", (json.dumps(result, ensure_ascii=True), invocation_id))
|
||||
|
||||
def append_outbox(self, task_id: str, event: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("INSERT INTO outbox(task_id, event_json) VALUES (?, ?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("INSERT INTO outbox(task_id,event_json) VALUES (?,?)", (task_id, json.dumps(event, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def pending_outbox(self, limit: int = 100, *, task_id: str | None = None) -> list[dict[str, Any]]:
|
||||
query = "SELECT event_id,task_id,event_json FROM outbox WHERE published_at IS NULL"
|
||||
args: tuple[Any, ...] = ()
|
||||
if task_id is not None:
|
||||
query += " AND task_id=?"
|
||||
args += (task_id,)
|
||||
query += " ORDER BY event_id LIMIT ?"
|
||||
args += (limit,)
|
||||
with self._lock, self._connection() as connection:
|
||||
if task_id is None:
|
||||
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL ORDER BY event_id LIMIT ?", (limit,)).fetchall()
|
||||
else:
|
||||
rows = connection.execute("SELECT event_id, task_id, event_json FROM outbox WHERE published_at IS NULL AND task_id = ? ORDER BY event_id LIMIT ?", (task_id, limit)).fetchall()
|
||||
return [{"event_id": row["event_id"], "task_id": row["task_id"], **json.loads(row["event_json"])} for row in rows]
|
||||
rows = connection.execute(query, args).fetchall()
|
||||
return [{"event_id": int(row["event_id"]), "task_id": str(row["task_id"]), **json.loads(row["event_json"])} for row in rows]
|
||||
|
||||
def mark_outbox_published(self, event_id: int) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("UPDATE outbox SET published_at = CURRENT_TIMESTAMP WHERE event_id = ? AND published_at IS NULL", (event_id,))
|
||||
connection.execute("UPDATE outbox SET published_at=CURRENT_TIMESTAMP WHERE event_id=? AND published_at IS NULL", (event_id,))
|
||||
|
||||
def record_usage(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("INSERT INTO usage_records(task_id, usage_json) VALUES (?, ?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("INSERT INTO usage_records(task_id,usage_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def record_tool_audit(self, task_id: str, payload: dict[str, Any]) -> None:
|
||||
"""Persist a diagnostic structured-output audit separately from usage."""
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute(
|
||||
"INSERT INTO tool_audits(task_id, audit_json) VALUES (?, ?)",
|
||||
(task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)),
|
||||
)
|
||||
connection.execute("INSERT INTO tool_audits(task_id,audit_json) VALUES (?,?)", (task_id, json.dumps(payload, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
def tool_audits(self, task_id: str) -> list[dict[str, Any]]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT audit_id, audit_json, created_at FROM tool_audits WHERE task_id = ? ORDER BY audit_id",
|
||||
(task_id,),
|
||||
).fetchall()
|
||||
return [
|
||||
{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])}
|
||||
for row in rows
|
||||
]
|
||||
rows = connection.execute("SELECT audit_id,audit_json,created_at FROM tool_audits WHERE task_id=? ORDER BY audit_id", (task_id,)).fetchall()
|
||||
return [{"audit_id": int(row["audit_id"]), "at": str(row["created_at"]), **json.loads(row["audit_json"])} for row in rows]
|
||||
|
||||
def usage_summary(self, task_id: str) -> dict[str, Any]:
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id = ? ORDER BY usage_id", (task_id,)).fetchall()
|
||||
values = [json.loads(row["usage_json"]) for row in rows]
|
||||
return {
|
||||
"calls": len(values),
|
||||
"prompt_tokens": sum(int(item.get("prompt_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||
"completion_tokens": sum(int(item.get("completion_tokens") or 0) for item in values if isinstance(item, dict)),
|
||||
"context_chars": sum(int(item.get("context_chars") or 0) for item in values if isinstance(item, dict)),
|
||||
"records": values,
|
||||
}
|
||||
rows = connection.execute("SELECT usage_json FROM usage_records WHERE task_id=? ORDER BY usage_id", (task_id,)).fetchall()
|
||||
return {"calls": len(rows), "records": [json.loads(row["usage_json"]) for row in rows]}
|
||||
|
||||
def running_task_ids(self) -> list[str]:
|
||||
phases = tuple(phase.value for phase in TaskPhase if phase not in {TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER, TaskPhase.WAITING_RETRY})
|
||||
placeholders = ", ".join("?" for _ in phases)
|
||||
terminal = tuple(item.value for item in (TaskPhase.COMPLETED, TaskPhase.FAILED, TaskPhase.CANCELLED, TaskPhase.WAITING_FOR_USER))
|
||||
with self._lock, self._connection() as connection:
|
||||
rows = connection.execute(f"SELECT task_id FROM tasks WHERE phase IN ({placeholders}) ORDER BY created_at", phases).fetchall()
|
||||
rows = connection.execute("SELECT task_id FROM tasks WHERE phase NOT IN (?,?,?,?) ORDER BY created_at", terminal).fetchall()
|
||||
return [str(row["task_id"]) for row in rows]
|
||||
|
||||
def model_capability(self, provider_id: str, model_id: str, schema_hash: str) -> dict[str, Any] | None:
|
||||
with self._lock, self._connection() as connection:
|
||||
row = connection.execute("SELECT supported, report_json, checked_at FROM model_capabilities WHERE provider_id = ? AND model_id = ? AND schema_hash = ?", (provider_id, model_id, schema_hash)).fetchone()
|
||||
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": row["checked_at"]} if row else None
|
||||
row = connection.execute("SELECT supported,report_json,checked_at FROM model_capabilities WHERE provider_id=? AND model_id=? AND schema_hash=?", (provider_id, model_id, schema_hash)).fetchone()
|
||||
return {"supported": bool(row["supported"]), "report": json.loads(row["report_json"]), "checked_at": str(row["checked_at"])} if row else None
|
||||
|
||||
def record_model_capability(self, provider_id: str, model_id: str, schema_hash: str, report: dict[str, Any]) -> None:
|
||||
with self._lock, self._connection() as connection:
|
||||
connection.execute("""INSERT INTO model_capabilities(provider_id, model_id, schema_hash, supported, report_json)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider_id, model_id, schema_hash) DO UPDATE SET supported = excluded.supported, report_json = excluded.report_json, checked_at = CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
|
||||
connection.execute("""INSERT INTO model_capabilities(provider_id,model_id,schema_hash,supported,report_json) VALUES (?,?,?,?,?)
|
||||
ON CONFLICT(provider_id,model_id,schema_hash) DO UPDATE SET supported=excluded.supported,report_json=excluded.report_json,checked_at=CURRENT_TIMESTAMP""", (provider_id, model_id, schema_hash, int(bool(report.get("supported"))), json.dumps(report, ensure_ascii=True, sort_keys=True)))
|
||||
|
||||
@staticmethod
|
||||
def _state(row: sqlite3.Row) -> TaskState:
|
||||
raw_pending = json.loads(row["pending_action_json"]) if row["pending_action_json"] else None
|
||||
pending = PendingAction(
|
||||
action_id=raw_pending["action_id"], working_head=raw_pending["working_head"], intent=raw_pending["intent"],
|
||||
requirement_ids=tuple(raw_pending["requirement_ids"]), atomic_id=raw_pending["atomic_id"],
|
||||
expected_change=raw_pending["expected_change"], contract_hash=raw_pending["contract_hash"], idempotency_key=raw_pending["idempotency_key"],
|
||||
node_id=str(raw_pending.get("node_id") or ""), plan_hash=str(raw_pending.get("plan_hash") or ""),
|
||||
claim_ids=tuple(raw_pending.get("claim_ids") or ()), depends_on_node_ids=tuple(raw_pending.get("depends_on_node_ids") or ()),
|
||||
) if isinstance(raw_pending, dict) else None
|
||||
return TaskState(
|
||||
task_id=str(row["task_id"]), phase=TaskPhase(str(row["phase"])), version=int(row["state_version"]),
|
||||
active_revision=str(row["active_revision"] or ""), pending_action=pending,
|
||||
candidate_id=str(row["candidate_id"] or ""), candidate_stage_id=str(row["candidate_stage_id"] or ""),
|
||||
repair_required=bool(row["repair_required"]),
|
||||
active_revision=str(row["active_revision"]), repair_count=int(row["repair_count"]),
|
||||
last_error=ErrorCode(str(row["last_error"])) if row["last_error"] else None,
|
||||
retry_from_phase=TaskPhase(str(row["retry_from_phase"])) if row["retry_from_phase"] else None,
|
||||
requirements_spec_path=str(row["requirements_spec_path"] or ""),
|
||||
requirements_document_path=str(row["requirements_document_path"] or ""),
|
||||
completion_target_path=str(row["completion_target_path"] or ""),
|
||||
modeling_plan_path=str(row["modeling_plan_path"] or ""),
|
||||
feature_plan_path=str(row["feature_plan_path"] or ""),
|
||||
feature_plan_hash=str(row["feature_plan_hash"] or ""),
|
||||
feature_stage_id=str(row["feature_stage_id"] or ""),
|
||||
clarification_path=str(row["clarification_path"] or ""),
|
||||
requirements_contract_path=str(row["requirements_contract_path"] or ""),
|
||||
requirements_path=str(row["requirements_path"]), authoring_path=str(row["authoring_path"]),
|
||||
runtime_cdsl_path=str(row["runtime_cdsl_path"]), compile_audit_path=str(row["compile_audit_path"]),
|
||||
diagnostics_path=str(row["diagnostics_path"]), completion_path=str(row["completion_path"]), clarification_path=str(row["clarification_path"]),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _pending_payload(pending: PendingAction | None) -> dict[str, Any] | None:
|
||||
if pending is None:
|
||||
return None
|
||||
return {"action_id": pending.action_id, "working_head": pending.working_head, "intent": pending.intent, "requirement_ids": list(pending.requirement_ids), "atomic_id": pending.atomic_id, "expected_change": pending.expected_change, "contract_hash": pending.contract_hash, "idempotency_key": pending.idempotency_key, "node_id": pending.node_id, "plan_hash": pending.plan_hash, "claim_ids": list(pending.claim_ids), "depends_on_node_ids": list(pending.depends_on_node_ids)}
|
||||
|
||||
@staticmethod
|
||||
def _invocation(row: sqlite3.Row) -> InvocationRecord:
|
||||
return InvocationRecord(str(row["invocation_id"]), str(row["idempotency_key"]), str(row["status"]), json.loads(row["result_json"]) if row["result_json"] else None)
|
||||
@@ -460,6 +293,6 @@ class SqliteTaskRepository:
|
||||
return "failed"
|
||||
if phase == TaskPhase.CANCELLED:
|
||||
return "cancelled"
|
||||
if phase in {TaskPhase.WAITING_RETRY, TaskPhase.WAITING_FOR_USER}:
|
||||
return phase.value.lower()
|
||||
if phase == TaskPhase.WAITING_FOR_USER:
|
||||
return "waiting_for_user"
|
||||
return "running"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The sole OpenAI-compatible structured-output adapter for protocol v3.
|
||||
"""The sole OpenAI-compatible structured-output adapter for Authoring CDSL.
|
||||
|
||||
Raw tool arguments are intentionally preserved. Callers must run their
|
||||
Pydantic/JSON-Schema canonical validator before causing any state transition.
|
||||
@@ -170,39 +170,12 @@ class StructuredModelGateway:
|
||||
|
||||
@staticmethod
|
||||
def _conformance_messages(tool: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Give capability probes the same selector fact production exposes.
|
||||
|
||||
A required opaque selector is not inferable from a generic request for
|
||||
a minimal example. Production fragment turns expose a topology token
|
||||
before the fragment tool, so the probe must do the same while still
|
||||
letting the provider generate every other schema field itself.
|
||||
"""
|
||||
"""Probe the two standalone structured documents used by this protocol."""
|
||||
function = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
||||
name = str(function.get("name") or "")
|
||||
parameters = function.get("parameters") if isinstance(function.get("parameters"), dict) else {}
|
||||
feature = parameters.get("properties", {}).get("feature") if isinstance(parameters.get("properties"), dict) else None
|
||||
feature_properties = feature.get("properties") if isinstance(feature, dict) and isinstance(feature.get("properties"), dict) else {}
|
||||
feature_required = feature.get("required") if isinstance(feature, dict) and isinstance(feature.get("required"), list) else []
|
||||
selector_required = "selector_tokens" in feature_required and isinstance(feature_properties.get("selector_tokens"), dict)
|
||||
instruction = "Return exactly the required function call with a minimal valid example. The active JSON Schema is authoritative."
|
||||
if selector_required:
|
||||
atomic_id = feature_properties.get("atomic_id", {}).get("const") if isinstance(feature_properties.get("atomic_id"), dict) else "..."
|
||||
instruction += (
|
||||
" The current topology exposes opaque selector token `sel_conformance`. "
|
||||
f"Use root shape {{\"feature\":{{\"atomic_id\":{json.dumps(atomic_id)},"
|
||||
"\"selector_tokens\":[\"sel_conformance\"],\"params\":{...}}}}. "
|
||||
"Because feature.selector_tokens is required, it is author input and must not be moved into params."
|
||||
)
|
||||
if name == "review_candidate":
|
||||
instruction += (
|
||||
" This is a candidate-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use accept and pass only where the schema permits them."
|
||||
)
|
||||
elif name == "review_final":
|
||||
instruction += (
|
||||
" This is a final-review probe: include every required top-level and nested field; "
|
||||
"in particular provide a verdict and claim_coverage. Use pass only where the schema permits it."
|
||||
)
|
||||
if name == "write_authoring_cdsl":
|
||||
instruction += " Return cad.author.v1 with document-local names only; do not include runtime IDs, stable selectors, snapshots, or selector tokens."
|
||||
return [{"role": "system", "content": instruction}]
|
||||
|
||||
def _provider_model(self, provider_id: str, model_id: str) -> tuple[ProviderConfig, ProviderModel]:
|
||||
@@ -256,7 +229,7 @@ class StructuredModelGateway:
|
||||
force_tool_name: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if len(tools) != 1 or not required_tool_name:
|
||||
raise StructuredModelError("The v3 protocol requires exactly one named tool per provider call.")
|
||||
raise StructuredModelError("The Authoring protocol requires exactly one named tool per provider call.")
|
||||
request_options = provider.request_options if include_reasoning else {}
|
||||
if provider.api_style == "responses":
|
||||
payload: dict[str, Any] = {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
"""Adapter exposing the pure verifier registry through the application port."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.cad_agent.domain.verifier_registry import VerifierRegistry
|
||||
|
||||
|
||||
class RegistryVerifierExecutor:
|
||||
def __init__(self, registry: VerifierRegistry) -> None:
|
||||
self.registry = registry
|
||||
|
||||
def evaluate(self, claims: list[dict[str, Any]], facts: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for claim in claims:
|
||||
result = self.registry.evaluate(str(claim["claim_kind"]), claim["expected"], facts)
|
||||
results.append({"claim_id": claim["claim_id"], "claim_kind": claim["claim_kind"], "deterministic": self.registry.definition(str(claim["claim_kind"])).deterministic, **result})
|
||||
return results
|
||||
Reference in New Issue
Block a user