nightshift/nightshift/artifacts.py
2026-05-17 13:34:19 -07:00

170 lines
7.0 KiB
Python

"""Artifact storage for NightShift runs."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import shutil
import re
from .config import NightShiftConfig
from .errors import ArtifactError, SafetyError
from .safety import resolve_inside_root, resolve_project_root, safe_artifact_path
from .tasks import Task
@dataclass(frozen=True)
class TaskArtifactPaths:
task_id: str
directory: Path
task_snapshot: Path
class ArtifactStore:
"""Create and write the durable artifact tree for one run."""
def __init__(self, project_root: str | Path, artifact_dir: str | Path, run_id: str | None = None) -> None:
try:
self.project_root = resolve_project_root(project_root)
self.artifact_root = resolve_inside_root(
self.project_root, artifact_dir, "artifact directory"
)
except SafetyError as exc:
raise ArtifactError(str(exc)) from exc
self.run_id = _safe_artifact_segment(run_id or default_run_id(), "run id")
self.run_dir = self._artifact_path("runs", self.run_id)
self.tasks_dir = self.run_dir / "tasks"
self.project_context_path = self.artifact_root / "project-context.md"
self.project_context_chart_path = self.artifact_root / "project-context-chart.md"
self.run_summary_path = self.run_dir / "run-summary.md"
self.config_snapshot_path = self.run_dir / "config.snapshot.yaml"
self.run_log_path = self.run_dir / "run.log"
self.aggregate_log_path = self.artifact_root / "nightshift.log"
@classmethod
def from_config(cls, config: NightShiftConfig, run_id: str | None = None) -> "ArtifactStore":
return cls(config.project.root, config.project.artifact_dir, run_id=run_id)
def initialize_run(self) -> None:
"""Create the base artifact tree for this run."""
self.artifact_root.mkdir(parents=True, exist_ok=True)
self.tasks_dir.mkdir(parents=True, exist_ok=True)
if not self.project_context_path.exists():
self.project_context_path.write_text("# Project Context\n\n", encoding="utf-8")
if not self.run_summary_path.exists():
self.run_summary_path.write_text("# Run Summary\n\n", encoding="utf-8")
def write_config_snapshot(self, config_path: str | Path) -> Path:
"""Copy the input config into the run artifact directory."""
self.initialize_run()
source = Path(config_path).resolve()
try:
source.relative_to(self.project_root)
except ValueError as exc:
raise ArtifactError(f"Artifact error: config path is outside project root: {source}") from exc
if not source.exists():
raise ArtifactError(f"Artifact error: config path does not exist: {source}")
shutil.copyfile(source, self.config_snapshot_path)
return self.config_snapshot_path
def write_prompt_snapshots(self, prompt_paths: dict[str, Path]) -> list[Path]:
"""Copy agent prompt files into the run artifact directory."""
self.initialize_run()
prompts_dir = self.run_dir / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
written: list[Path] = []
for agent_id, prompt_path in sorted(prompt_paths.items()):
source = prompt_path.resolve()
try:
source.relative_to(self.project_root)
except ValueError as exc:
raise ArtifactError(
f"Artifact error: prompt path is outside project root: {source}"
) from exc
if not source.exists():
raise ArtifactError(f"Artifact error: prompt path does not exist: {source}")
target = prompts_dir / f"{_safe_artifact_segment(agent_id, 'agent id')}.md"
shutil.copyfile(source, target)
written.append(target)
return written
def write_run_metadata(self, content: str, filename: str = "run-metadata.md") -> Path:
self.initialize_run()
path = self.run_dir / filename
path.write_text(content, encoding="utf-8")
return path
def create_task_dir(self, task_id: str) -> TaskArtifactPaths:
"""Create the artifact directory for one task."""
self.initialize_run()
safe_task_id = _safe_artifact_segment(task_id, "task id")
task_dir = self._artifact_path("runs", self.run_id, "tasks", safe_task_id)
task_dir.mkdir(parents=True, exist_ok=True)
return TaskArtifactPaths(
task_id=safe_task_id,
directory=task_dir,
task_snapshot=task_dir / "task.md",
)
def write_task_snapshot(self, task: Task) -> Path:
paths = self.create_task_dir(task.id)
paths.task_snapshot.write_text(task.raw_markdown, encoding="utf-8")
return paths.task_snapshot
def write_stage_output(self, task_id: str, filename: str, content: str) -> Path:
"""Write a stage artifact under a task directory."""
task_dir = self.create_task_dir(task_id).directory
output_path = self._task_artifact_path(task_dir, filename)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content, encoding="utf-8")
return output_path
def write_command_output(self, task_id: str, filename: str, content: str) -> Path:
return self.write_stage_output(task_id, filename, content)
def write_final_task_notes(self, task_id: str, content: str, filename: str = "final-notes.md") -> Path:
return self.write_stage_output(task_id, filename, content)
def _artifact_path(self, *parts: str | Path) -> Path:
try:
return safe_artifact_path(self.project_root, self.artifact_root, *parts)
except SafetyError as exc:
raise ArtifactError(str(exc)) from exc
def _task_artifact_path(self, task_dir: Path, filename: str) -> Path:
candidate = Path(filename)
if candidate.is_absolute():
raise ArtifactError(f"Artifact error: stage output filename must be relative: {filename}")
resolved = (task_dir / candidate).resolve()
try:
resolved.relative_to(task_dir.resolve())
except ValueError as exc:
raise ArtifactError(f"Artifact error: stage output escapes task directory: {filename}") from exc
return resolved
def default_run_id(now: datetime | None = None) -> str:
"""Return a filesystem-friendly UTC run id."""
value = now or datetime.now(timezone.utc)
return value.strftime("%Y%m%dT%H%M%S.%fZ")
def _safe_artifact_segment(value: str, context: str) -> str:
if not isinstance(value, str) or not value:
raise ArtifactError(f"Artifact error: {context} must be a non-empty string.")
if not re.fullmatch(r"[A-Za-z0-9_.-]+", value):
raise ArtifactError(
f"Artifact error: {context} contains unsafe characters: {value}"
)
if value in {".", ".."}:
raise ArtifactError(f"Artifact error: {context} cannot be '{value}'.")
return value