mirror of
https://github.com/calesthio/OpenMontage.git
synced 2026-08-24 17:10:27 +08:00
backlot phase 0: artifact contract + gate hardening
- init_project() writes project.json marker + canonical workspace layout - write_checkpoint enforces approval gates: completed on a gated stage requires human_approved=True (GATE VIOLATION otherwise) - superseded checkpoints archived to projects/<id>/history/ (stage versioning, gate audit trail, replay) - BaseTool auto-instruments execute() -> projects/<id>/events.jsonl (start/finish/error, scene_id, cost) for the Backlot live board - assets stage now gates (human_approval_default: true) in all manifests - checkpoint-protocol + AGENT_GUIDE: manifest gate value is binding, awaiting_human + end-turn protocol, per-gate approval, canonical checkpoint location fixed to projects/<id>/ - gate reminder footer on all gating stage director skills - /backlot command files for Claude Code, Codex, Cursor, Copilot
This commit is contained in:
@@ -81,6 +81,15 @@ CHECKPOINT_SCHEMA_PATH = (
|
||||
/ "checkpoint.schema.json"
|
||||
)
|
||||
|
||||
# Canonical project root. Checkpoints, artifacts, and the project marker all
|
||||
# live under PROJECTS_DIR/<project_id>/ — this is the location the Backlot
|
||||
# board watches. Callers may still pass a different pipeline_dir (tests do),
|
||||
# but production runs should use the default.
|
||||
PROJECTS_DIR = Path(__file__).resolve().parent.parent / "projects"
|
||||
|
||||
PROJECT_MARKER_FILENAME = "project.json"
|
||||
HISTORY_DIRNAME = "history"
|
||||
|
||||
|
||||
class CheckpointValidationError(ValueError):
|
||||
"""Raised when a checkpoint or its canonical artifacts are invalid."""
|
||||
@@ -157,6 +166,108 @@ def _checkpoint_path(pipeline_dir: Path, project_id: str, stage: str) -> Path:
|
||||
return pipeline_dir / project_id / f"checkpoint_{stage}.json"
|
||||
|
||||
|
||||
def init_project(
|
||||
project_id: str,
|
||||
*,
|
||||
title: str,
|
||||
pipeline_type: str,
|
||||
pipeline_dir: Optional[Path] = None,
|
||||
style_playbook: Optional[str] = None,
|
||||
) -> Path:
|
||||
"""Initialize a project workspace with the canonical layout + marker file.
|
||||
|
||||
Creates projects/<project_id>/ with the standard subdirectories and writes
|
||||
project.json — the marker the Backlot board uses to render a project's
|
||||
identity and stage rail before the first checkpoint exists.
|
||||
|
||||
Idempotent: re-running preserves the original created_at and merges fields.
|
||||
Returns the project directory.
|
||||
"""
|
||||
base = pipeline_dir or PROJECTS_DIR
|
||||
project_dir = base / project_id
|
||||
for sub in (
|
||||
"artifacts",
|
||||
"assets/images",
|
||||
"assets/video",
|
||||
"assets/audio",
|
||||
"assets/music",
|
||||
"renders",
|
||||
):
|
||||
(project_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
marker_path = project_dir / PROJECT_MARKER_FILENAME
|
||||
marker: dict[str, Any] = {}
|
||||
if marker_path.exists():
|
||||
try:
|
||||
with open(marker_path) as f:
|
||||
marker = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
marker = {}
|
||||
|
||||
marker.setdefault("version", "1.0")
|
||||
marker.setdefault("created_at", datetime.now(timezone.utc).isoformat())
|
||||
marker["project_id"] = project_id
|
||||
marker["title"] = title
|
||||
marker["pipeline_type"] = pipeline_type
|
||||
if style_playbook is not None:
|
||||
marker["style_playbook"] = style_playbook
|
||||
|
||||
with open(marker_path, "w") as f:
|
||||
json.dump(marker, f, indent=2)
|
||||
|
||||
return project_dir
|
||||
|
||||
|
||||
def _stage_requires_approval(pipeline_type: Optional[str], stage: str) -> Optional[bool]:
|
||||
"""Read human_approval_default for a stage from its pipeline manifest.
|
||||
|
||||
Returns None when the manifest can't answer (unknown pipeline_type,
|
||||
stage not declared) — the caller then falls back to the value the
|
||||
agent passed in.
|
||||
"""
|
||||
if not pipeline_type or pipeline_type == "unknown":
|
||||
return None
|
||||
try:
|
||||
from lib.pipeline_loader import load_pipeline
|
||||
manifest = load_pipeline(pipeline_type)
|
||||
for stage_def in manifest.get("stages", []):
|
||||
if stage_def.get("name") == stage:
|
||||
return bool(stage_def.get("human_approval_default", False))
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _archive_superseded_checkpoint(path: Path, stage: str) -> None:
|
||||
"""Move an existing checkpoint into history/ before it is overwritten.
|
||||
|
||||
Preserves the full run record: stage re-runs (script v1 → v2) and gate
|
||||
transitions (awaiting_human → completed) remain reconstructable. Repeated
|
||||
in_progress refreshes are NOT archived — they are partial-progress
|
||||
heartbeats, not versions.
|
||||
"""
|
||||
if not path.exists():
|
||||
return
|
||||
try:
|
||||
with open(path) as f:
|
||||
existing = json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
existing = {}
|
||||
if existing.get("status") == "in_progress":
|
||||
return
|
||||
|
||||
stamp = str(existing.get("timestamp", ""))
|
||||
safe_stamp = "".join(c for c in stamp if c.isalnum()) or f"{path.stat().st_mtime_ns}"
|
||||
history_dir = path.parent / HISTORY_DIRNAME
|
||||
history_dir.mkdir(parents=True, exist_ok=True)
|
||||
target = history_dir / f"checkpoint_{stage}_{safe_stamp}.json"
|
||||
counter = 1
|
||||
while target.exists():
|
||||
target = history_dir / f"checkpoint_{stage}_{safe_stamp}_{counter}.json"
|
||||
counter += 1
|
||||
path.replace(target)
|
||||
|
||||
|
||||
def _decision_log_path(pipeline_dir: Path, project_id: str) -> Path:
|
||||
return pipeline_dir / project_id / "decision_log.json"
|
||||
|
||||
@@ -219,6 +330,26 @@ def write_checkpoint(
|
||||
f"Valid stages: {sorted(valid_stages)}"
|
||||
)
|
||||
|
||||
# --- Gate enforcement (GI-4) ---
|
||||
# The pipeline manifest is the binding source of truth for whether a stage
|
||||
# gates on human approval. A gated stage can only be written as
|
||||
# "completed" with explicit evidence of approval (human_approved=True).
|
||||
# Skipping a gate is a hard error, not a soft violation.
|
||||
manifest_gate = _stage_requires_approval(pipeline_type, stage)
|
||||
gated = manifest_gate if manifest_gate is not None else human_approval_required
|
||||
if gated:
|
||||
human_approval_required = True
|
||||
if status == "completed" and not human_approved:
|
||||
raise CheckpointValidationError(
|
||||
f"GATE VIOLATION: stage {stage!r} requires human approval "
|
||||
f"(human_approval_default: true in the {pipeline_type!r} manifest) "
|
||||
f"but status='completed' was written without human_approved=True. "
|
||||
f"Correct protocol: write status='awaiting_human', present the "
|
||||
f"artifact summary to the user, END YOUR TURN, and only after "
|
||||
f"the user approves re-write with status='completed', "
|
||||
f"human_approved=True."
|
||||
)
|
||||
|
||||
checkpoint = {
|
||||
"version": "1.0",
|
||||
"project_id": project_id,
|
||||
@@ -266,6 +397,10 @@ def write_checkpoint(
|
||||
|
||||
path = _checkpoint_path(pipeline_dir, project_id, stage)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Preserve run history: a superseded completed/awaiting_human checkpoint
|
||||
# moves to history/ instead of being destroyed (stage versioning, gate
|
||||
# audit trail, replay).
|
||||
_archive_superseded_checkpoint(path, stage)
|
||||
with open(path, "w") as f:
|
||||
json.dump(checkpoint, f, indent=2)
|
||||
|
||||
|
||||
111
lib/events.py
Normal file
111
lib/events.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Backlot event stream — append-only tool-event log per project.
|
||||
|
||||
Written by the BaseTool instrumentation layer (tools/base_tool.py) whenever a
|
||||
tool executes against a project directory; consumed by the Backlot board's
|
||||
watcher to power live activity and per-scene generating states.
|
||||
|
||||
Design rules:
|
||||
- Observability must never break production: every public function swallows
|
||||
its own errors. A failed event write is silently dropped.
|
||||
- Zero agent burden: project attribution is inferred from the tool's inputs
|
||||
(explicit ``project_dir`` or any path argument under ``projects/``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
PROJECTS_DIR = REPO_ROOT / "projects"
|
||||
|
||||
EVENTS_FILENAME = "events.jsonl"
|
||||
|
||||
_write_lock = threading.Lock()
|
||||
|
||||
# Input keys checked (in order) when inferring the project a tool call
|
||||
# belongs to. Explicit project keys win over path inference.
|
||||
_EXPLICIT_PROJECT_KEYS = ("project_dir", "project_path")
|
||||
_PATH_HINT_KEYS = (
|
||||
"output_path",
|
||||
"output_dir",
|
||||
"output_file",
|
||||
"input_path",
|
||||
"video_path",
|
||||
"audio_path",
|
||||
"image_path",
|
||||
"file_path",
|
||||
)
|
||||
|
||||
|
||||
def infer_project_dir(inputs: Any) -> Optional[Path]:
|
||||
"""Best-effort: which project directory does this tool call belong to?
|
||||
|
||||
Returns None when the call can't be attributed — the event is then
|
||||
simply not emitted (principle: never guess loudly, never fail).
|
||||
"""
|
||||
if not isinstance(inputs, dict):
|
||||
return None
|
||||
try:
|
||||
for key in _EXPLICIT_PROJECT_KEYS:
|
||||
value = inputs.get(key)
|
||||
if isinstance(value, (str, Path)) and str(value):
|
||||
p = Path(value)
|
||||
if p.is_dir():
|
||||
return p
|
||||
projects_root = PROJECTS_DIR.resolve()
|
||||
for key in _PATH_HINT_KEYS:
|
||||
value = inputs.get(key)
|
||||
if not isinstance(value, (str, Path)) or not str(value):
|
||||
continue
|
||||
try:
|
||||
resolved = Path(value).resolve()
|
||||
rel = resolved.relative_to(projects_root)
|
||||
except (ValueError, OSError):
|
||||
continue
|
||||
if rel.parts:
|
||||
return PROJECTS_DIR / rel.parts[0]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def emit_event(project_dir: Path | str, payload: dict[str, Any]) -> None:
|
||||
"""Append one event to the project's events.jsonl. Never raises."""
|
||||
try:
|
||||
entry = {"ts": datetime.now(timezone.utc).isoformat()}
|
||||
entry.update({k: v for k, v in payload.items() if v is not None})
|
||||
path = Path(project_dir) / EVENTS_FILENAME
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
line = json.dumps(entry, default=str)
|
||||
with _write_lock:
|
||||
with open(path, "a", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def read_events(project_dir: Path | str, limit: Optional[int] = None) -> list[dict[str, Any]]:
|
||||
"""Read events for a project (oldest first). Tolerates malformed lines."""
|
||||
path = Path(project_dir) / EVENTS_FILENAME
|
||||
if not path.exists():
|
||||
return []
|
||||
events: list[dict[str, Any]] = []
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
events.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
except OSError:
|
||||
return []
|
||||
if limit is not None:
|
||||
return events[-limit:]
|
||||
return events
|
||||
Reference in New Issue
Block a user