main
John Lauer source at 0.1.25: the whole crate workspace, the flow, the skills and the docs f31cc25
"""The run manifest: one JSON file per run that carries the clock, the plan, the decisions,
the stage outcomes, the capture markers and the final summary. Every command reads and
writes it, so the comparison between engines is what the file says, not what anyone remembers."""
import json
import os
import time
from pathlib import Path

STAGES = ["place", "route", "pour", "gate", "land", "measure", "analyze-current", "analyze-thermal", "finish"]


def now():
    return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())


def parse(ts):
    return time.mktime(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ")) - time.timezone


class Run:
    def __init__(self, path):
        self.dir = Path(path)
        self.file = self.dir / "run.json"
        self.data = json.load(open(self.file, encoding="utf-8")) if self.file.is_file() else None

    def exists(self):
        return self.data is not None

    def save(self):
        self.dir.mkdir(parents=True, exist_ok=True)
        tmp = self.file.with_suffix(".tmp")
        json.dump(self.data, open(tmp, "w", encoding="utf-8"), indent=1)
        os.replace(tmp, self.file)

    @staticmethod
    def create(path, board, spec, engine, prompt_time, ai_thread, target=None, remote_board=None):
        r = Run(path)
        r.data = {
            "version": 1, "engine": engine, "aiThread": ai_thread, "board": str(board), "spec": spec,
            "target": target, "remoteBoard": remote_board,
            "clock": {"promptTime": prompt_time, "startTime": now(), "finishTime": None},
            "plan": None, "decisions": {}, "stages": {}, "captures": [], "markers": [], "boards": [str(board)],
            "outcomes": {}, "summary": None,
        }
        r.save()
        return r

    # -- stages ---------------------------------------------------------------------
    def stage_start(self, name, engine=None, note=None):
        st = self.data["stages"].setdefault(name, {"runs": []})
        st["runs"].append({"start": now(), "end": None, "engine": engine or self.engine_for(name), "note": note, "result": None})
        self.mark("stage-start:" + name)
        self.save()

    def stage_end(self, name, result=None, note=None):
        st = self.data["stages"].setdefault(name, {"runs": []})
        if not st["runs"] or st["runs"][-1]["end"] is not None:
            st["runs"].append({"start": now(), "end": None, "engine": self.engine_for(name), "note": None, "result": None})
        st["runs"][-1]["end"] = now()
        if result is not None:
            st["runs"][-1]["result"] = result
        if note:
            st["runs"][-1]["note"] = note
        self.mark("stage-end:" + name)
        self.save()

    def engine_for(self, name):
        return self.data["decisions"].get(name) or ((self.data.get("plan") or {}).get("stages", {}).get(name, {}) or {}).get("default", "binary")

    def mark(self, label):
        self.data["markers"].append({"t": now(), "label": label})

    def current_board(self):
        return self.data["boards"][-1]

    def add_board(self, path):
        self.data["boards"].append(str(path))
        self.save()

    # -- time -----------------------------------------------------------------------
    def elapsed_minutes(self):
        t0 = parse(self.data["clock"]["promptTime"])
        t1 = parse(self.data["clock"]["finishTime"]) if self.data["clock"]["finishTime"] else time.time()
        return round((t1 - t0) / 60.0, 1)

    def stage_minutes(self):
        out = {}
        for name, st in self.data["stages"].items():
            tot = 0.0
            for r in st["runs"]:
                if r["end"]:
                    tot += parse(r["end"]) - parse(r["start"])
            out[name] = round(tot / 60.0, 1)
        return out