Maturity from run history, pack revision digests, attribute sets, projects and templates (RBT-WP-0008)
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Work Records / validate (push) Has been cancelled

Also fixes run lists and scores mixing runs of different packs in one repository.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

Assistant: claude-code
Assistant-Model: sonnet
Assistant-Process: 295952@bnt-lap001
Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477
This commit is contained in:
tegwick 2026-09-29 12:36:34 +02:00
parent 2f60d47746
commit 0203bf553c
19 changed files with 1311 additions and 33 deletions

View file

@ -65,6 +65,59 @@ def _run(pk, a) -> int:
return 1
def _project(a) -> int:
from . import project as J
from .attrs import Registry
reg = Registry([Path(a.root)])
try:
if a.action == "list":
for pid, d in reg.projects.items():
st = J.state(reg, d)["state"] if d.ok else "invalid"
print(f"{pid}\t{d.data.get('environment', '?')}\t{st}\t{d.data.get('title', '')}")
for tid, d in reg.templates.items():
print(f"{tid}\ttemplate\t-\t{d.data.get('title', '')}")
return 0
if a.action == "new":
path = J.instantiate(reg, a.name, a.extra, a.environment,
dict(kv.split("=", 1) for kv in a.set))
print(path); return 0
d = reg.projects.get(a.name)
if d is None:
print(f"error: no project {a.name}", file=sys.stderr); return 1
errs = J.validate(reg, d)
if a.action == "validate":
print(("ok " if not errs else "FAIL ") + d.id)
for e in errs:
print(" -", e)
return 1 if errs else 0
if errs:
print("\n".join(errs), file=sys.stderr); return 1
if a.action == "status":
st = J.state(reg, d)
print(f"{d.id}: {st['state']} [{d.data['environment']}] {d.data.get('goal', '')}")
for m in st["members"]:
print(f" {m['status']:12} {m['pack']}" + (f" @{m['target']}" if m["target"] else ""))
print(" next:", ", ".join(st["next"]) or "-")
for n in st["needs"]:
print(f" needs: {n['attribute']} ({n['pack']})")
return 0
if a.action == "attrs":
member = next(m for m in d.data["runbooks"] if m["pack"] == a.extra)
res = J.member_attributes(reg, d.data, member)
for r in res["rows"]:
print(f"{r['state']:4} {r['requirement']:9} {r['id']} = {r['value']!r} <- {r['source'] or 'runbook default' if 'default' in r else r['source'] or '-'}"
+ (" (modified)" if r["modified"] else "") + (f" ERROR: {r['error']}" if r.get("error") else ""))
if res["unused"]:
print("unused attributes:", ", ".join(res["unused"]))
return 0
if a.action == "run":
r = J.start_run(reg, d, a.extra, a.operator, a.target, a.mode)
print(r.data["run_id"]); return 0
except Exception as e: # user-facing command: report the problem, do not print a trace
print(f"error: {e}", file=sys.stderr); return 1
return 0
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(prog="rtut")
sub = ap.add_subparsers(dest="cmd", required=True)
@ -89,6 +142,20 @@ def main(argv: list[str] | None = None) -> int:
sp = sub.add_parser("ui", help="serve the local UI on 127.0.0.1")
sp.add_argument("roots", nargs="*", default=["."])
sp.add_argument("--port", type=int, default=8877)
sp = sub.add_parser("maturity", help="derived maturity of a pack at its current revision")
sp.add_argument("manifest")
sp = sub.add_parser("review", help="record that you reviewed the pack at its current revision")
sp.add_argument("manifest"); sp.add_argument("--by", required=True); sp.add_argument("--note", default="")
sp = sub.add_parser("project", help="projects: list, validate, status, attrs, new (from a template), run")
sp.add_argument("action", choices=["list", "validate", "status", "attrs", "new", "run"])
sp.add_argument("root")
sp.add_argument("name", nargs="?", help="project id (new: template id)")
sp.add_argument("extra", nargs="?", help="attrs/run: pack id; new: the new project id")
sp.add_argument("--environment", "-e", default="")
sp.add_argument("--set", "-s", action="append", default=[], help="new: attribute=value")
sp.add_argument("--operator", "-o", default="")
sp.add_argument("--target", default=None)
sp.add_argument("--mode", default="manual", choices=["manual", "auto-confirm"])
sp = sub.add_parser("score", help="how often each runbook was tried and how it went")
sp.add_argument("root", nargs="?", default=".")
sp = sub.add_parser("feedback", help="list the latest feedback of every run of a pack")
@ -109,6 +176,24 @@ def main(argv: list[str] | None = None) -> int:
except KeyboardInterrupt:
pass
return 0
if a.cmd == "project":
return _project(a)
if a.cmd in ("maturity", "review"):
from . import maturity as M
pk = P.load(Path(a.manifest))
if not pk.ok:
print("\n".join(pk.errors), file=sys.stderr); return 1
if a.cmd == "review":
try:
M.add_review(pk, a.by, a.note)
except M.ReviewError as e:
print(f"error: {e}", file=sys.stderr); return 1
print("review recorded for revision", P.digest(pk)); return 0
m = M.maturity(pk); ev = m["evidence"]
print(f"{pk.id}: {m['level']}" + (f" (was {m['stale_from']})" if m["stale_from"] else "") + f" revision {m['digest']}")
print(f" reviewed: {ev['reviewed']} clean runs: {ev['clean_runs']} (rehearsal {ev['rehearsal_runs']}) "
f"environments: {', '.join(ev['environments']) or '-'} operators: {', '.join(ev['operators']) or '-'}")
return 0
if a.cmd == "score":
from . import run as R
for p in _packs(a.root):

142
rtut/attrs.py Normal file
View file

@ -0,0 +1,142 @@
"""Attribute sets: named, composable, non-secret attribute collections with layered resolution.
An attribute has a *requirement* (required | optional), declared, and a *state*, derived with the same
words as run datapoints: nil (no value), set, err (invalid for a consuming runbook). `modified` marks a
value that a later layer replaced. Every value keeps its source, so a wrong value is traceable.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
import yaml
from jsonschema import Draft202012Validator
from . import pack as P
from .pack import SECRET_RE
SCHEMA_DIR = Path(__file__).resolve().parent.parent / "schema"
def _schema_errors(data: dict, schema_file: str) -> list[str]:
v = Draft202012Validator(json.loads((SCHEMA_DIR / schema_file).read_text()))
return [f"{'/'.join(map(str, e.absolute_path)) or '<root>'}: {e.message}"
for e in sorted(v.iter_errors(data), key=lambda e: list(map(str, e.absolute_path)))]
def normalize(attrs: dict | None) -> dict[str, dict]:
"""scalar or {value, requirement, note} -> {value, requirement, note}"""
out = {}
for k, v in (attrs or {}).items():
out[k] = dict(v) if isinstance(v, dict) else {"value": v}
out[k].setdefault("value", None)
out[k].setdefault("requirement", None)
return out
@dataclass
class Doc:
path: Path
data: dict
errors: list[str] = field(default_factory=list)
@property
def id(self) -> str:
return str(self.data.get("id", self.path.stem))
@property
def ok(self) -> bool:
return not self.errors
def _load(path: Path, schema_file: str) -> Doc:
try:
data = yaml.safe_load(Path(path).read_text())
except yaml.YAMLError as e:
return Doc(Path(path), {}, [f"invalid YAML: {e}"])
if not isinstance(data, dict):
return Doc(Path(path), {}, ["must be a mapping"])
errs = _schema_errors(data, schema_file)
if not errs and SECRET_RE.search(json.dumps(data)):
errs.append("contains secret-looking marker")
return Doc(Path(path), data, errs)
def load_set(path: Path) -> Doc:
return _load(path, "attribute-set.schema.json")
class Registry:
"""Everything found under one or more repository roots."""
def __init__(self, roots: list[Path]):
self.roots = [Path(r) for r in roots]
self.sets: dict[str, Doc] = {}
self.projects: dict[str, Doc] = {}
self.templates: dict[str, Doc] = {}
self.packs: dict[str, P.Pack] = {}
for r in self.roots:
for f in sorted(r.glob("attribute-sets/*.yaml")):
d = load_set(f); self.sets[d.id] = d
for f in sorted(r.glob("projects/*/project.yaml")):
d = _load(f, "project.schema.json"); self.projects[d.id] = d
for f in sorted(r.glob("project-templates/*.yaml")):
d = _load(f, "project.schema.json"); self.templates[d.id] = d
for f in P.discover(r):
pk = P.load(f); self.packs[pk.id] = pk
def set_layers(reg: Registry, set_id: str, _seen: tuple = ()) -> list[tuple[str, dict]]:
"""Layers of an attribute set, its `extends` first. Raises on unknown sets and cycles."""
if set_id in _seen:
raise ValueError("attribute set cycle: " + " -> ".join(_seen + (set_id,)))
doc = reg.sets.get(set_id)
if doc is None:
raise ValueError(f"unknown attribute set {set_id}")
if not doc.ok:
raise ValueError(f"attribute set {set_id} is invalid: {doc.errors[0]}")
layers: list[tuple[str, dict]] = []
for base in doc.data.get("extends", []):
layers += set_layers(reg, base, _seen + (set_id,))
layers.append((f"set:{set_id}", normalize(doc.data["attributes"])))
return layers
def fold(layers: list[tuple[str, dict]]) -> dict[str, dict]:
"""Later layers win. Keeps provenance and marks a value replaced by a later layer as modified."""
out: dict[str, dict] = {}
for source, attrs in layers:
for name, a in attrs.items():
cur = out.setdefault(name, {"value": None, "requirement": None, "source": None, "modified": False,
"history": []})
if a.get("requirement"):
cur["requirement"] = a["requirement"]
if a.get("value") is not None:
if cur["value"] is not None and cur["value"] != a["value"]:
cur["modified"] = True
cur["value"] = a["value"]
cur["source"] = source
cur["history"].append({"source": source, "value": a["value"]})
for cur in out.values():
cur["state"] = "set" if cur["value"] is not None else "nil"
return out
def check_value(pack: P.Pack, name: str, value) -> str | None:
"""None if the value is valid for the pack's datapoint/parameter `name`, else the reason."""
defs = {d["id"]: d for d in (pack.data.get("datapoints") or pack.data.get("parameters") or [])}
d = defs.get(name)
if d is None:
return None
v = str(value).lower() if isinstance(value, bool) else str(value)
import re
if d["type"] == "int" and not re.fullmatch(r"-?\d+", v):
return "not an integer"
if d["type"] == "bool" and v not in ("true", "false"):
return "must be true or false"
try:
P.render("{{%s}}" % name, {name: v}, pack)
except (ValueError, KeyError) as e:
return str(e)
return None

83
rtut/maturity.py Normal file
View file

@ -0,0 +1,83 @@
"""Runbook maturity: earned from run history, independent of any one environment, bound to a pack revision.
draft valid, no review and no clean rehearsal at the current revision
reviewed a review is recorded for the current revision
rehearsed at least one clean run in a rehearsal environment at the current revision
proven reviewed, rehearsed, and clean in at least two environments by at least two operators
stale nothing holds at the current revision, but a higher level was reached at an earlier one
A run is *clean* when it succeeded with no deviation (no override, waiver or push-through).
Runs without an environment context are not attributable and never count.
"""
from __future__ import annotations
from datetime import datetime, timezone
import yaml
from . import run as R
from .pack import REVIEW_FILE, Pack, digest
LEVELS = ("draft", "reviewed", "rehearsed", "proven")
class ReviewError(Exception):
pass
def _reviews(pack: Pack) -> list[dict]:
f = pack.path.parent / REVIEW_FILE
if not f.exists():
return []
return list((yaml.safe_load(f.read_text()) or {}).get("reviews", []))
def add_review(pack: Pack, by: str, note: str = "") -> dict:
"""Record that `by` reviewed the pack at its current revision."""
if not by.strip() or len(by.split()) != 1:
raise ReviewError("reviewer must be one word (name or handle)")
try:
R._clean(by, "reviewer"); R._clean(note, "review note")
except R.RunError as e:
raise ReviewError(str(e)) from e
entry = {"by": by.strip(), "at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"digest": digest(pack), "note": note.strip()}
revs = _reviews(pack) + [entry]
(pack.path.parent / REVIEW_FILE).write_text(yaml.safe_dump({"reviews": revs}, sort_keys=False))
return entry
def _env_key(ctx: dict) -> str:
return f"{ctx.get('project', '-')}/{ctx.get('target', '-')}"
def _evidence(pack: Pack, dig: str, runs: list[R.Run]) -> dict:
clean = [r for r in runs if r.data.get("pack_digest") == dig and r.data.get("outcome") == "succeeded"
and not r.data.get("deviations") and r.data.get("context", {}).get("environment")]
reviewed = any(rv.get("digest") == dig for rv in _reviews(pack))
rehearsal = [r for r in clean if r.data["context"]["environment"] == "rehearsal"]
envs = {_env_key(r.data["context"]) for r in clean}
ops = {r.data["operator"] for r in clean}
proven = reviewed and bool(rehearsal) and len(envs) >= 2 and len(ops) >= 2
level = "proven" if proven else "rehearsed" if rehearsal else "reviewed" if reviewed else "draft"
return {"level": level, "reviewed": reviewed, "clean_runs": len(clean), "rehearsal_runs": len(rehearsal),
"environments": sorted(envs), "operators": sorted(ops)}
def maturity(pack: Pack) -> dict:
"""{'level', 'stale_from', 'digest', 'evidence'} for the pack's current revision."""
runs = [R.load(pack, rid) for rid in R.list_runs(pack)]
dig = digest(pack)
now = _evidence(pack, dig, runs)
stale_from = None
if now["level"] == "draft":
# a higher level reached at an earlier revision means the maturity has expired
older = {r.data.get("pack_digest") for r in runs} | {rv.get("digest") for rv in _reviews(pack)}
best = "draft"
for d in older - {dig, None}:
lv = _evidence(pack, d, runs)["level"]
if LEVELS.index(lv) > LEVELS.index(best):
best = lv
if best != "draft":
stale_from = best
return {"level": "stale" if stale_from else now["level"], "stale_from": stale_from, "digest": dig, "evidence": now}

View file

@ -135,3 +135,25 @@ def render(command: str, values: dict[str, str], pack: Pack) -> str:
return val
return PLACEHOLDER.sub(sub, command)
REVIEW_FILE = "REVIEWS.yaml"
def digest(pack: "Pack") -> str:
"""Revision of a pack: hash of every file in its directory, except review records and local state.
`exercise_status:` lines are ignored, so recording an exercise never changes the revision.
"""
import hashlib
h = hashlib.sha256()
root = pack.path.resolve().parent
for f in sorted(root.rglob("*")):
rel = f.relative_to(root)
if not f.is_file() or f.name == REVIEW_FILE or ".local" in rel.parts or "__pycache__" in rel.parts:
continue
data = f.read_bytes()
if f.name == "pack.yaml" or f.name.startswith("pack."):
data = b"\n".join(x for x in data.split(b"\n") if not x.startswith(b"exercise_status:"))
h.update(str(rel).encode() + b"\0" + data + b"\0")
return h.hexdigest()[:16]

244
rtut/project.py Normal file
View file

@ -0,0 +1,244 @@
"""Projects: runbooks that share an environment, with layered attributes, derived state and templates."""
from __future__ import annotations
import re
from pathlib import Path
import yaml
from . import maturity as _m # noqa: F401 (kept importable for callers)
from . import run as R
from .attrs import Doc, Registry, check_value, fold, normalize, set_layers
from .pack import digest
PROJECT_STATES = ("planned", "applying", "applied", "stale")
# ---- validation --------------------------------------------------------------------------------------
def validate(reg: Registry, doc: Doc) -> list[str]:
"""Structural errors plus references that must resolve in the registry."""
if not doc.ok:
return list(doc.errors)
d, errs = doc.data, []
for s in d.get("attribute_sets", []):
try:
set_layers(reg, s)
except ValueError as e:
errs.append(str(e))
targets = d.get("targets", {})
for tid, t in targets.items():
for s in t.get("attribute_sets", []):
try:
set_layers(reg, s)
except ValueError as e:
errs.append(f"target {tid}: {e}")
packs = [m["pack"] for m in d["runbooks"]]
keys = [(m["pack"], m.get("target")) for m in d["runbooks"]]
if len(keys) != len(set(keys)):
errs.append("a runbook appears twice for the same target")
for m in d["runbooks"]:
if m["pack"] not in reg.packs:
errs.append(f"runbook {m['pack']} not found")
elif not reg.packs[m["pack"]].ok:
errs.append(f"runbook {m['pack']} is invalid")
if m.get("target") and m["target"] not in targets:
errs.append(f"runbook {m['pack']}: unknown target {m['target']}")
for a in m.get("after", []):
if a not in packs:
errs.append(f"runbook {m['pack']}: after names {a}, which is not a member")
# acyclic member order
deps = {m["pack"]: set(m.get("after", [])) for m in d["runbooks"]}
state: dict[str, int] = {}
def visit(n: str, path: list[str]) -> None:
if state.get(n) == 1:
errs.append("member order cycle: " + " -> ".join(path[path.index(n):] + [n])); return
if state.get(n) == 2 or n not in deps:
return
state[n] = 1
for x in sorted(deps[n]):
visit(x, path + [n])
state[n] = 2
for n in sorted(deps):
visit(n, [])
return sorted(set(errs))
# ---- attribute resolution --------------------------------------------------------------------------
def _member(d: dict, pack_id: str, target: str | None = None) -> dict:
for m in d["runbooks"]:
if m["pack"] == pack_id and (target is None or m.get("target") == target):
return m
raise KeyError(pack_id)
def layers_for(reg: Registry, d: dict, member: dict) -> list:
layers: list = []
for s in d.get("attribute_sets", []):
layers += set_layers(reg, s)
layers.append(("project", normalize(d.get("attributes"))))
t = d.get("targets", {}).get(member.get("target") or "", None)
if t:
for s in t.get("attribute_sets", []):
layers += set_layers(reg, s)
layers.append((f"target:{member['target']}", normalize(t.get("attributes"))))
layers.append((f"member:{member['pack']}", normalize(member.get("attributes"))))
return layers
def member_attributes(reg: Registry, d: dict, member: dict) -> dict:
"""What the member runbook's inputs look like in this project.
{'rows': [{id, label, value, state, requirement, source, modified, error?}], 'unused': [names]}
state: nil (no value), set, err (invalid for the runbook). requirement: required unless the attribute
says optional or the runbook itself has a default.
"""
pack = reg.packs[member["pack"]]
folded = fold(layers_for(reg, d, member))
defs = [x for x in (pack.data.get("datapoints") or pack.data.get("parameters") or []) if not x.get("produced_by")]
rows = []
for x in defs:
a = folded.get(x["id"], {"value": None, "requirement": None, "source": None, "modified": False})
value = a["value"]
row = {"id": x["id"], "label": x.get("label", x["id"]), "value": value, "source": a["source"],
"modified": a["modified"], "requirement": a["requirement"] or ("optional" if "default" in x else "required")}
if value is None:
row["state"] = "nil"
if "default" in x:
row["default"] = x["default"]
else:
err = check_value(pack, x["id"], value)
row["state"] = "err" if err else "set"
if err:
row["error"] = err
rows.append(row)
known = {x["id"] for x in defs}
return {"rows": rows, "unused": sorted(n for n in folded if n not in known)}
def missing_required(reg: Registry, d: dict) -> list[dict]:
"""Attributes some member still needs: required, no value, no runbook default."""
out = []
for m in d["runbooks"]:
if m["pack"] not in reg.packs:
continue
for r in member_attributes(reg, d, m)["rows"]:
if r["state"] == "nil" and r["requirement"] == "required" and "default" not in r:
out.append({"pack": m["pack"], "attribute": r["id"], "label": r["label"]})
return out
def member_values(reg: Registry, d: dict, member: dict) -> dict[str, str]:
"""Values to hand to a run: every set attribute (invalid ones are left out and reported as err)."""
return {r["id"]: (str(r["value"]).lower() if isinstance(r["value"], bool) else str(r["value"]))
for r in member_attributes(reg, d, member)["rows"] if r["state"] == "set"}
# ---- state ---------------------------------------------------------------------------------------------
def _latest_run(pack, project_id: str, target: str | None):
best = None
for rid in R.list_runs(pack):
r = R.load(pack, rid)
ctx = r.data.get("context", {})
if ctx.get("project") == project_id and ctx.get("target") == target:
if best is None or r.data["created"] >= best.data["created"]:
best = r
return best
def member_status(reg: Registry, d: dict, member: dict) -> dict:
"""not-applied | applying | applied | stale | failed, from the latest run at the current pack revision."""
pack = reg.packs[member["pack"]]
r = _latest_run(pack, d["id"], member.get("target"))
if r is None:
return {"pack": member["pack"], "target": member.get("target"), "status": "not-applied", "run": None}
eff = R.effective_outcome(r)
current = r.data.get("pack_digest") == digest(pack)
if eff == "succeeded":
status = "applied" if current else "stale"
elif eff in ("failed", "abandoned"):
status = "failed"
else:
status = "applying"
return {"pack": member["pack"], "target": member.get("target"), "status": status, "run": r.data["run_id"],
"deviations": len(r.data.get("deviations", [])), "current_revision": current}
def state(reg: Registry, doc: Doc) -> dict:
"""Project state: planned | applying | applied | stale, with per-member status and what can start next."""
d = doc.data
members = [member_status(reg, d, m) for m in d["runbooks"]]
st = [m["status"] for m in members]
if "stale" in st:
project = "stale"
elif all(x == "applied" for x in st):
project = "applied"
elif all(x == "not-applied" for x in st):
project = "planned"
else:
project = "applying"
applied = {m["pack"] for m in members if m["status"] == "applied"}
nxt = [m["pack"] for m, spec in zip(members, d["runbooks"])
if m["status"] in ("not-applied", "failed", "stale") and set(spec.get("after", [])) <= applied]
return {"state": project, "members": members, "next": nxt, "needs": missing_required(reg, d)}
# ---- runs from a project ------------------------------------------------------------------------------------
def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str | None = None, mode: str = "manual"):
"""Create a run of a member runbook with the project's attributes as defaults and the project as context."""
d = doc.data
if d["spec"] != "project/v0.1":
raise R.RunError("a template is not run; instantiate it into a project first")
errs = validate(reg, doc)
if errs:
raise R.RunError(f"project {doc.id} is invalid: {errs[0]}")
member = _member(d, pack_id, target)
pack = reg.packs[pack_id]
ctx = {"project": doc.id, "environment": d["environment"], "target": member.get("target")}
values = member_values(reg, d, member)
if pack.spec == "runbook-pack/v0.2":
from . import run2
return run2.create(pack, operator, values, mode=mode, context=ctx)
if mode != "manual":
raise R.RunError("v0.1 packs run in manual mode only")
return R.create(pack, operator, values, ctx)
# ---- templates -----------------------------------------------------------------------------------------------
def instantiate(reg: Registry, template_id: str, project_id: str, environment: str,
values: dict[str, str] | None = None, title: str | None = None, root: Path | None = None) -> Path:
"""Write projects/<id>/project.yaml from a template. Required attributes may stay unset; `state()` lists them."""
t = reg.templates.get(template_id)
if t is None or not t.ok:
raise ValueError(f"no valid template {template_id}")
if not re.fullmatch(r"[a-z0-9]+([.-][a-z0-9]+)*", project_id):
raise ValueError("project id must be lower-case letters, digits, dots and hyphens")
if environment not in R.ENVIRONMENTS:
raise ValueError(f"environment must be one of {', '.join(R.ENVIRONMENTS)}")
d = {k: v for k, v in t.data.items() if k not in ("spec", "id", "title")}
attrs = normalize(d.get("attributes"))
for k, v in (values or {}).items():
attrs.setdefault(k, {"value": None, "requirement": None})
attrs[k]["value"] = v
out_attrs = {}
for k, a in attrs.items():
out_attrs[k] = a["value"] if a.get("value") is not None and not a.get("requirement") and not a.get("note") else \
{kk: vv for kk, vv in a.items() if vv is not None}
project = {"spec": "project/v0.1", "id": project_id, "title": title or t.data["title"],
**{k: v for k, v in d.items() if k not in ("attributes", "environment")}, "environment": environment,
"template": template_id}
if out_attrs:
project["attributes"] = out_attrs
home = Path(root) if root else t.path.parent.parent
target = home / "projects" / project_id / "project.yaml"
if target.exists():
raise ValueError(f"{target} already exists")
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text(yaml.safe_dump(project, sort_keys=False))
return target

View file

@ -11,7 +11,7 @@ from datetime import date, datetime, timezone
from pathlib import Path
from .legacy import repo_root
from .pack import PLACEHOLDER, SECRET_RE, Pack, render
from .pack import PLACEHOLDER, SECRET_RE, Pack, digest, render
STATES = ("todo", "running", "done", "failed", "skipped")
VERIFY_TIMEOUT = 60
@ -22,7 +22,7 @@ class RunError(Exception):
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
return datetime.now(timezone.utc).isoformat(timespec="microseconds") # sortable even within one second
def _clean(value: str, what: str) -> str:
@ -69,12 +69,23 @@ def _writable(run: Run) -> None:
raise RunError(f"run is finished (outcome: {run.data['outcome']}); start a new run")
def create(pack: Pack, operator: str, params: dict[str, str] | None = None) -> Run:
ENVIRONMENTS = ("rehearsal", "staging", "production")
def _context(ctx: dict | None) -> dict:
"""Where a run applies: project id, target and environment class. Non-secret; all optional."""
ctx = dict(ctx or {})
if ctx.get("environment") and ctx["environment"] not in ENVIRONMENTS:
raise RunError(f"environment must be one of {', '.join(ENVIRONMENTS)}")
return {k: _clean(str(v), f"context {k}") for k, v in ctx.items() if v and k in ("project", "target", "environment")}
def create(pack: Pack, operator: str, params: dict[str, str] | None = None, context: dict | None = None) -> Run:
if not pack.ok or pack.data.get("engine") != "native":
raise RunError("only valid native packs can be run")
if pack.spec == "runbook-pack/v0.2":
from . import run2
return run2.create(pack, operator, params)
return run2.create(pack, operator, params, context=context)
if pack.spec != "runbook-pack/v0.1":
raise RunError(f"{pack.spec} packs cannot be run")
operator = _clean(operator.strip(), "operator")
@ -102,6 +113,8 @@ def create(pack: Pack, operator: str, params: dict[str, str] | None = None) -> R
"created": _now(),
"params": resolved,
"steps": {s["id"]: {"state": "todo"} for s in pack.data["steps"]},
"pack_digest": digest(pack),
"context": _context(context),
}
run = Run(pack, data)
run.save()
@ -116,8 +129,18 @@ def load(pack: Pack, run_id: str) -> Run:
def list_runs(pack: Pack) -> list[str]:
"""Run ids of this pack. All packs of a repository share one run folder, so filter by pack id."""
d = runs_dir(pack)
return sorted(p.stem for p in d.glob("*.json")) if d.exists() else []
if not d.exists():
return []
out = []
for f in sorted(d.glob("*.json")):
try:
if json.loads(f.read_text()).get("pack_id") == pack.id:
out.append(f.stem)
except (OSError, ValueError):
continue
return out
def _order(run: Run, step_id: str) -> None:

View file

@ -13,8 +13,8 @@ import uuid
from datetime import datetime, timezone
from .flow import Flow
from .pack import Pack, render
from .run import Run, RunError, _clean, _now, runs_dir # noqa: F401 (re-exported for callers)
from .pack import Pack, digest, render
from .run import Run, RunError, _clean, _context, _now, runs_dir # noqa: F401 (re-exported for callers)
DEFAULT_PHRASE = "I understand this cannot be undone"
VERIFY_TIMEOUT = 60
@ -84,7 +84,7 @@ def _confirm_datapoints(run: Run, by: str, how: str) -> None:
# ---- creation -------------------------------------------------------------------------------------
def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode: str = "manual",
preauthorize: list[str] | None = None) -> Run:
preauthorize: list[str] | None = None, context: dict | None = None) -> Run:
if not pack.ok or pack.data.get("engine") != "native" or pack.spec != "runbook-pack/v0.2":
raise RunError("only valid native runbook-pack/v0.2 packs can be run here")
if mode not in ("manual", "auto-confirm"):
@ -104,6 +104,7 @@ def create(pack: Pack, operator: str, values: dict[str, str] | None = None, mode
"spec": "runbook-run/v0.2", "run_id": time.strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:6],
"pack_id": pack.id, "pack_path": str(pack.path), "operator": operator, "created": _now(),
"mode": mode, "params": {}, "deviations": [],
"pack_digest": digest(pack), "context": _context(context),
"auto_confirm": {"preauthorized": pre, "authorized_by": operator if pre else None},
"datapoints": {i: {"state": "nil"} for i in fl.datapoints},
"steps": {i: {"state": "todo"} for i in fl.steps},