mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-12 20:16:29 +03:00
188 lines
5.9 KiB
Python
188 lines
5.9 KiB
Python
"""Project manifest — load, save, validate, and atomically write project.json.
|
|
|
|
Uses the atomic write utility (tempfile + os.rename) to ensure that
|
|
project.json is never partially written. A process crash during a write
|
|
leaves the previous valid manifest (or no manifest) but never a corrupted one.
|
|
|
|
Key guarantees:
|
|
- Loads project manifests as typed dicts with validation.
|
|
- Saves project manifests atomically via atomic_write_json.
|
|
- Detects corrupted manifests (invalid JSON) and raises InvalidConfigError
|
|
with exit code 4.
|
|
- Detects missing required fields in manifest and treats as corruption.
|
|
- Provides helpers to create new project manifests with proper defaults.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
from binary_analysis.domain.enums import ProjectState
|
|
from binary_analysis.domain.errors import InvalidConfigError
|
|
from binary_analysis.projects.atomic import atomic_write_json
|
|
|
|
# Required top-level fields in project.json
|
|
_REQUIRED_FIELDS: tuple[str, ...] = (
|
|
"id",
|
|
"name",
|
|
"state",
|
|
"created_at",
|
|
"workspace_version",
|
|
"binary_count",
|
|
"is_stale",
|
|
)
|
|
|
|
# Current workspace format version
|
|
_WORKSPACE_VERSION = "1"
|
|
|
|
# Manifest filename within a project workspace
|
|
MANIFEST_FILENAME = "project.json"
|
|
|
|
|
|
def create_manifest(
|
|
project_name: str,
|
|
project_id: UUID | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Create a new project manifest dict with default values.
|
|
|
|
The manifest is in the CREATED state, with a new UUID and current timestamp.
|
|
|
|
Args:
|
|
project_name: The project name.
|
|
project_id: Optional UUID; auto-generated if not provided.
|
|
|
|
Returns:
|
|
A dict representing the project manifest, ready to be saved.
|
|
"""
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
if project_id is None:
|
|
project_id = uuid4()
|
|
|
|
return {
|
|
"id": str(project_id),
|
|
"name": project_name,
|
|
"state": ProjectState.CREATED.value,
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
"workspace_version": _WORKSPACE_VERSION,
|
|
"binary_count": 0,
|
|
"is_stale": False,
|
|
"lock": None,
|
|
"description": None,
|
|
"max_binary_size_bytes": None,
|
|
}
|
|
|
|
|
|
def save_manifest(project_path: str, manifest: dict[str, Any]) -> None:
|
|
"""Atomically save a project manifest to project.json.
|
|
|
|
Uses tempfile + os.rename to guarantee the file is never partially
|
|
written. If the process crashes mid-write, the previous valid manifest
|
|
(or no file) is left intact.
|
|
|
|
Args:
|
|
project_path: Absolute path to the project workspace directory.
|
|
manifest: The manifest dict to save.
|
|
|
|
Raises:
|
|
ValueError: If the manifest is missing required fields.
|
|
"""
|
|
_validate_manifest(manifest)
|
|
manifest_path = f"{project_path}/{MANIFEST_FILENAME}"
|
|
atomic_write_json(manifest_path, manifest)
|
|
|
|
|
|
def load_manifest(project_path: str) -> dict[str, Any]:
|
|
"""Load a project manifest from project.json.
|
|
|
|
Reads and validates the manifest. If the file is missing, raises
|
|
FileNotFoundError. If the JSON is invalid, raises InvalidConfigError
|
|
(exit code 4) with a diagnostic explaining the corruption.
|
|
If required fields are missing, raises InvalidConfigError.
|
|
|
|
Args:
|
|
project_path: Absolute path to the project workspace directory.
|
|
|
|
Returns:
|
|
The parsed and validated manifest dict.
|
|
|
|
Raises:
|
|
FileNotFoundError: If project.json does not exist.
|
|
InvalidConfigError: If the manifest is corrupted (invalid JSON or
|
|
missing required fields). Exit code 4.
|
|
"""
|
|
manifest_path = f"{project_path}/{MANIFEST_FILENAME}"
|
|
|
|
try:
|
|
with open(manifest_path, encoding="utf-8") as f:
|
|
raw_text = f.read()
|
|
except FileNotFoundError:
|
|
raise FileNotFoundError(f"Project manifest not found: {manifest_path}") from None
|
|
|
|
# Parse JSON — detect corruption
|
|
try:
|
|
manifest = json.loads(raw_text)
|
|
except json.JSONDecodeError as e:
|
|
raise InvalidConfigError(
|
|
f"Corrupted project manifest at {manifest_path}: invalid JSON. "
|
|
f"Parse error: {e.msg} at line {e.lineno}, column {e.colno}. "
|
|
f"The file must be repaired or the project workspace re-created."
|
|
) from e
|
|
|
|
if not isinstance(manifest, dict):
|
|
raise InvalidConfigError(
|
|
f"Corrupted project manifest at {manifest_path}: "
|
|
f"expected a JSON object, got {type(manifest).__name__}."
|
|
)
|
|
|
|
# Validate required fields
|
|
_validate_manifest(manifest)
|
|
|
|
return manifest
|
|
|
|
|
|
def _validate_manifest(manifest: dict[str, Any]) -> None:
|
|
"""Validate that a manifest dict has all required fields.
|
|
|
|
Args:
|
|
manifest: The manifest dict to validate.
|
|
|
|
Raises:
|
|
InvalidConfigError: If required fields are missing.
|
|
"""
|
|
missing = [field for field in _REQUIRED_FIELDS if field not in manifest]
|
|
if missing:
|
|
raise InvalidConfigError(
|
|
f"Corrupted project manifest: missing required fields: {', '.join(missing)}."
|
|
)
|
|
|
|
|
|
def update_manifest_field(
|
|
project_path: str,
|
|
updates: dict[str, Any],
|
|
) -> dict[str, Any]:
|
|
"""Load, update fields, and atomically save a project manifest.
|
|
|
|
This is a convenience for state transitions and field updates.
|
|
Automatically updates the `updated_at` timestamp.
|
|
|
|
Args:
|
|
project_path: Absolute path to the project workspace directory.
|
|
updates: Dict of field names to new values.
|
|
|
|
Returns:
|
|
The updated manifest dict (post-save).
|
|
|
|
Raises:
|
|
FileNotFoundError: If the project doesn't exist.
|
|
InvalidConfigError: If the current or updated manifest is corrupted.
|
|
"""
|
|
manifest = load_manifest(project_path)
|
|
manifest.update(updates)
|
|
manifest["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
save_manifest(project_path, manifest)
|
|
return manifest
|