chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
"""Shared helpers for CLI-Anything preview bundles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import mimetypes
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Optional
|
||||
|
||||
PROTOCOL_VERSION = "preview-bundle/v1"
|
||||
TRAJECTORY_PROTOCOL_VERSION = "preview-trajectory/v1"
|
||||
|
||||
|
||||
def _slug(value: str) -> str:
|
||||
text = (value or "preview").strip().lower()
|
||||
text = re.sub(r"[^a-z0-9]+", "-", text)
|
||||
text = re.sub(r"-{2,}", "-", text).strip("-")
|
||||
return text or "preview"
|
||||
|
||||
|
||||
def _json_dumps(data: Any) -> str:
|
||||
return json.dumps(
|
||||
data,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def hash_data(data: Any) -> str:
|
||||
return hashlib.sha256(_json_dumps(data).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def fingerprint_data(data: Any) -> str:
|
||||
return f"sha256:{hash_data(data)}"
|
||||
|
||||
|
||||
def fingerprint_file(path: str) -> str:
|
||||
resolved = os.path.abspath(path)
|
||||
stat = os.stat(resolved)
|
||||
return fingerprint_data(
|
||||
{
|
||||
"path": resolved,
|
||||
"size": stat.st_size,
|
||||
"mtime_ns": stat.st_mtime_ns,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def bundle_root(
|
||||
software: str,
|
||||
recipe: str,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Path:
|
||||
if root_dir:
|
||||
base = Path(root_dir).expanduser().resolve()
|
||||
elif project_path:
|
||||
base = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews"
|
||||
else:
|
||||
base = Path.home() / ".cli-anything" / "previews"
|
||||
return base / _slug(software) / _slug(recipe)
|
||||
|
||||
|
||||
def build_cache_key(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
source_fingerprint: str,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
harness_version: Optional[str] = None,
|
||||
protocol_version: str = PROTOCOL_VERSION,
|
||||
) -> str:
|
||||
return fingerprint_data(
|
||||
{
|
||||
"protocol_version": protocol_version,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"bundle_kind": bundle_kind,
|
||||
"source_fingerprint": source_fingerprint,
|
||||
"options": options or {},
|
||||
"harness_version": harness_version or "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _iter_manifests(search_root: Path) -> Iterable[Path]:
|
||||
if not search_root.exists():
|
||||
return []
|
||||
return sorted(search_root.rglob("manifest.json"), reverse=True)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Dict[str, Any]:
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
|
||||
|
||||
def find_cached_manifest(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
cache_key: str,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
root = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir)
|
||||
for manifest_path in _iter_manifests(root):
|
||||
try:
|
||||
manifest = _load_json(manifest_path)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if (
|
||||
manifest.get("protocol_version") == PROTOCOL_VERSION
|
||||
and manifest.get("software") == software
|
||||
and manifest.get("recipe") == recipe
|
||||
and manifest.get("bundle_kind") == bundle_kind
|
||||
and manifest.get("status") in {"ok", "partial"}
|
||||
and manifest.get("cache_key") == cache_key
|
||||
):
|
||||
manifest["_manifest_path"] = str(manifest_path.resolve())
|
||||
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
|
||||
manifest["_summary_path"] = str(
|
||||
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
|
||||
)
|
||||
return manifest
|
||||
return None
|
||||
|
||||
|
||||
def find_latest_manifest(
|
||||
software: str,
|
||||
recipe: Optional[str] = None,
|
||||
bundle_kind: Optional[str] = None,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
if root_dir:
|
||||
search_root = Path(root_dir).expanduser().resolve() / _slug(software)
|
||||
elif project_path:
|
||||
search_root = Path(project_path).expanduser().resolve().parent / ".cli-anything" / "previews" / _slug(software)
|
||||
else:
|
||||
search_root = Path.home() / ".cli-anything" / "previews" / _slug(software)
|
||||
if recipe:
|
||||
search_root = search_root / _slug(recipe)
|
||||
for manifest_path in _iter_manifests(search_root):
|
||||
try:
|
||||
manifest = _load_json(manifest_path)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if manifest.get("software") != software:
|
||||
continue
|
||||
if recipe and manifest.get("recipe") != recipe:
|
||||
continue
|
||||
if bundle_kind and manifest.get("bundle_kind") != bundle_kind:
|
||||
continue
|
||||
if manifest.get("status") not in {"ok", "partial"}:
|
||||
continue
|
||||
manifest["_manifest_path"] = str(manifest_path.resolve())
|
||||
manifest["_bundle_dir"] = str(manifest_path.parent.resolve())
|
||||
manifest["_summary_path"] = str(
|
||||
(manifest_path.parent / manifest.get("summary_path", "summary.json")).resolve()
|
||||
)
|
||||
return manifest
|
||||
return None
|
||||
|
||||
|
||||
def prepare_bundle(
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_kind: str,
|
||||
source_fingerprint: str,
|
||||
options: Optional[Dict[str, Any]] = None,
|
||||
harness_version: Optional[str] = None,
|
||||
project_path: Optional[str] = None,
|
||||
root_dir: Optional[str] = None,
|
||||
force: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
cache_key = build_cache_key(
|
||||
software=software,
|
||||
recipe=recipe,
|
||||
bundle_kind=bundle_kind,
|
||||
source_fingerprint=source_fingerprint,
|
||||
options=options or {},
|
||||
harness_version=harness_version,
|
||||
)
|
||||
if not force:
|
||||
cached = find_cached_manifest(
|
||||
software=software,
|
||||
recipe=recipe,
|
||||
bundle_kind=bundle_kind,
|
||||
cache_key=cache_key,
|
||||
project_path=project_path,
|
||||
root_dir=root_dir,
|
||||
)
|
||||
if cached:
|
||||
return {
|
||||
"cached": True,
|
||||
"cache_key": cache_key,
|
||||
"bundle_id": cached.get("bundle_id"),
|
||||
"bundle_dir": cached["_bundle_dir"],
|
||||
"manifest_path": cached["_manifest_path"],
|
||||
"summary_path": os.path.join(cached["_bundle_dir"], cached.get("summary_path", "summary.json")),
|
||||
"manifest": cached,
|
||||
}
|
||||
|
||||
now = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
bundle_id = f"{now}_{cache_key.split(':', 1)[-1][:8]}_{_slug(recipe)}"
|
||||
out_dir = bundle_root(software, recipe, project_path=project_path, root_dir=root_dir) / bundle_id
|
||||
artifacts_dir = out_dir / "artifacts"
|
||||
artifacts_dir.mkdir(parents=True, exist_ok=False)
|
||||
return {
|
||||
"cached": False,
|
||||
"cache_key": cache_key,
|
||||
"bundle_id": bundle_id,
|
||||
"bundle_dir": str(out_dir.resolve()),
|
||||
"artifacts_dir": str(artifacts_dir.resolve()),
|
||||
"manifest_path": str((out_dir / "manifest.json").resolve()),
|
||||
"summary_path": str((out_dir / "summary.json").resolve()),
|
||||
}
|
||||
|
||||
|
||||
def artifact_record(
|
||||
bundle_dir: str,
|
||||
path: str,
|
||||
artifact_id: str,
|
||||
role: str,
|
||||
kind: str,
|
||||
label: str,
|
||||
media_type: Optional[str] = None,
|
||||
**extra: Any,
|
||||
) -> Dict[str, Any]:
|
||||
bundle_path = Path(bundle_dir).resolve()
|
||||
file_path = Path(path).resolve()
|
||||
rel_path = file_path.relative_to(bundle_path).as_posix()
|
||||
record: Dict[str, Any] = {
|
||||
"artifact_id": artifact_id,
|
||||
"role": role,
|
||||
"kind": kind,
|
||||
"label": label,
|
||||
"media_type": media_type or mimetypes.guess_type(str(file_path))[0] or "application/octet-stream",
|
||||
"path": rel_path,
|
||||
}
|
||||
if file_path.exists():
|
||||
record["bytes"] = file_path.stat().st_size
|
||||
record.update({k: v for k, v in extra.items() if v is not None})
|
||||
return record
|
||||
|
||||
|
||||
def write_json(path: str, data: Any) -> str:
|
||||
output_path = Path(path).resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(data, fh, indent=2, ensure_ascii=False, default=str)
|
||||
fh.write("\n")
|
||||
return str(output_path)
|
||||
|
||||
|
||||
def finalize_bundle(
|
||||
bundle_dir: str,
|
||||
bundle_id: str,
|
||||
bundle_kind: str,
|
||||
software: str,
|
||||
recipe: str,
|
||||
source: Dict[str, Any],
|
||||
artifacts: list[Dict[str, Any]],
|
||||
summary: Dict[str, Any],
|
||||
cache_key: str,
|
||||
generator: Dict[str, Any],
|
||||
status: str = "ok",
|
||||
warnings: Optional[list[str]] = None,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
metrics: Optional[Dict[str, Any]] = None,
|
||||
labels: Optional[list[str]] = None,
|
||||
source_bundles: Optional[list[Dict[str, Any]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
bundle_path = Path(bundle_dir).resolve()
|
||||
summary_rel = "summary.json"
|
||||
summary_path = write_json(str(bundle_path / summary_rel), summary)
|
||||
manifest = {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"bundle_id": bundle_id,
|
||||
"bundle_kind": bundle_kind,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"status": status,
|
||||
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"cache_key": cache_key,
|
||||
"generator": generator,
|
||||
"source": source,
|
||||
"summary_path": summary_rel,
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
if warnings:
|
||||
manifest["warnings"] = warnings
|
||||
if context:
|
||||
manifest["context"] = context
|
||||
if metrics:
|
||||
manifest["metrics"] = metrics
|
||||
if labels:
|
||||
manifest["labels"] = labels
|
||||
if source_bundles:
|
||||
manifest["source_bundles"] = source_bundles
|
||||
manifest_path = write_json(str(bundle_path / "manifest.json"), manifest)
|
||||
manifest["_manifest_path"] = manifest_path
|
||||
manifest["_bundle_dir"] = str(bundle_path)
|
||||
manifest["_summary_path"] = summary_path
|
||||
return manifest
|
||||
|
||||
|
||||
def _clean_none_fields(data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return {key: value for key, value in data.items() if value is not None}
|
||||
|
||||
|
||||
def live_trajectory_path(session_dir: str | Path) -> Path:
|
||||
return Path(session_dir).expanduser().resolve() / "trajectory.json"
|
||||
|
||||
|
||||
def load_live_trajectory(session_dir: str | Path) -> Dict[str, Any]:
|
||||
trajectory_path = live_trajectory_path(session_dir)
|
||||
if not trajectory_path.is_file():
|
||||
return {}
|
||||
return _load_json(trajectory_path)
|
||||
|
||||
|
||||
def summarize_trajectory(trajectory: Dict[str, Any], *, recent_steps: int = 3) -> Dict[str, Any]:
|
||||
steps = list(trajectory.get("steps") or [])
|
||||
latest = steps[-1] if steps else {}
|
||||
recent = steps[-max(1, int(recent_steps)):] if steps else []
|
||||
return _clean_none_fields(
|
||||
{
|
||||
"protocol_version": trajectory.get("protocol_version"),
|
||||
"software": trajectory.get("software"),
|
||||
"recipe": trajectory.get("recipe"),
|
||||
"step_count": trajectory.get("step_count", len(steps)),
|
||||
"current_step_id": trajectory.get("current_step_id"),
|
||||
"latest_command": latest.get("command"),
|
||||
"latest_publish_reason": latest.get("publish_reason"),
|
||||
"latest_bundle_id": latest.get("bundle_id"),
|
||||
"recent_steps": [
|
||||
_clean_none_fields(
|
||||
{
|
||||
"step_id": item.get("step_id"),
|
||||
"step_index": item.get("step_index"),
|
||||
"bundle_id": item.get("bundle_id"),
|
||||
"publish_reason": item.get("publish_reason"),
|
||||
"command": item.get("command"),
|
||||
"command_finished_at": item.get("command_finished_at"),
|
||||
"status": item.get("status"),
|
||||
"cached": item.get("cached"),
|
||||
}
|
||||
)
|
||||
for item in recent
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def build_live_history_item(
|
||||
bundle_manifest: Dict[str, Any],
|
||||
*,
|
||||
step_id: Optional[str] = None,
|
||||
step_index: Optional[int] = None,
|
||||
publish_reason: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
command_started_at: Optional[str] = None,
|
||||
command_finished_at: Optional[str] = None,
|
||||
source_fingerprint: Optional[str] = None,
|
||||
stage_label: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
source = bundle_manifest.get("source") or {}
|
||||
resolved_command = command or (bundle_manifest.get("generator") or {}).get("command")
|
||||
resolved_fingerprint = (
|
||||
source_fingerprint
|
||||
or source.get("project_fingerprint")
|
||||
or source.get("capture_fingerprint")
|
||||
)
|
||||
created_at = bundle_manifest.get("created_at")
|
||||
return _clean_none_fields(
|
||||
{
|
||||
"step_id": step_id,
|
||||
"step_index": step_index,
|
||||
"bundle_id": bundle_manifest.get("bundle_id"),
|
||||
"bundle_dir": bundle_manifest.get("_bundle_dir"),
|
||||
"manifest_path": bundle_manifest.get("_manifest_path"),
|
||||
"summary_path": bundle_manifest.get("_summary_path"),
|
||||
"created_at": created_at,
|
||||
"status": bundle_manifest.get("status"),
|
||||
"cached": bool(bundle_manifest.get("cached")),
|
||||
"publish_reason": publish_reason,
|
||||
"command": resolved_command,
|
||||
"command_started_at": command_started_at or created_at,
|
||||
"command_finished_at": command_finished_at or created_at,
|
||||
"source_fingerprint": resolved_fingerprint,
|
||||
"stage_label": stage_label,
|
||||
"note": note,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def append_live_trajectory(
|
||||
session_dir: str | Path,
|
||||
*,
|
||||
software: str,
|
||||
recipe: str,
|
||||
bundle_manifest: Dict[str, Any],
|
||||
publish_reason: str,
|
||||
project_path: Optional[str] = None,
|
||||
project_name: Optional[str] = None,
|
||||
session_name: Optional[str] = None,
|
||||
command: Optional[str] = None,
|
||||
command_started_at: Optional[str] = None,
|
||||
command_finished_at: Optional[str] = None,
|
||||
source_fingerprint: Optional[str] = None,
|
||||
stage_label: Optional[str] = None,
|
||||
note: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
session_path = Path(session_dir).expanduser().resolve()
|
||||
existing = load_live_trajectory(session_path)
|
||||
steps = list(existing.get("steps") or [])
|
||||
finished_at = (
|
||||
command_finished_at
|
||||
or bundle_manifest.get("created_at")
|
||||
or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
)
|
||||
started_at = command_started_at or finished_at
|
||||
step_index = len(steps) + 1
|
||||
step_id = f"step-{step_index:04d}"
|
||||
step = build_live_history_item(
|
||||
bundle_manifest,
|
||||
step_id=step_id,
|
||||
step_index=step_index,
|
||||
publish_reason=publish_reason,
|
||||
command=command,
|
||||
command_started_at=started_at,
|
||||
command_finished_at=finished_at,
|
||||
source_fingerprint=source_fingerprint,
|
||||
stage_label=stage_label,
|
||||
note=note,
|
||||
)
|
||||
steps.append(step)
|
||||
|
||||
trajectory: Dict[str, Any] = dict(existing)
|
||||
trajectory.update(
|
||||
_clean_none_fields(
|
||||
{
|
||||
"protocol_version": TRAJECTORY_PROTOCOL_VERSION,
|
||||
"software": software,
|
||||
"recipe": recipe,
|
||||
"session_name": session_name or session_path.name,
|
||||
"project_path": project_path,
|
||||
"project_name": project_name,
|
||||
"created_at": existing.get("created_at", finished_at),
|
||||
"updated_at": finished_at,
|
||||
"step_count": len(steps),
|
||||
"current_step_id": step_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
trajectory["steps"] = steps
|
||||
trajectory_path = write_json(str(live_trajectory_path(session_path)), trajectory)
|
||||
trajectory["_trajectory_path"] = trajectory_path
|
||||
trajectory["latest_step"] = step
|
||||
return trajectory
|
||||
Reference in New Issue
Block a user