From f8ce4717a86644cb61290c27f80179e2c375be9c Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 29 Sep 2026 14:12:21 +0200 Subject: [PATCH] Projects have their own name, description and goal; the template is shown only as origin Co-Authored-By: Claude Sonnet 5.5 Assistant: claude-code Assistant-Model: sonnet Assistant-Process: 295952@bnt-lap001 Assistant-Session: e93f64ad-516c-46eb-9666-aad8d300c477 --- docs/maturity-and-projects.md | 7 ++++--- projects/trial-1/project.yaml | 30 ++++++++++++++++++++++++++++++ rtut/__main__.py | 7 +++++-- rtut/project.py | 27 +++++++++++++++++++++------ rtut/ui_projects.py | 23 ++++++++++++++++++----- schema/project.schema.json | 4 ++++ tests/test_projects.py | 18 ++++++++++++++++-- tests/test_ui_projects.py | 33 +++++++++++++++++++++++++++------ 8 files changed, 125 insertions(+), 24 deletions(-) create mode 100644 projects/trial-1/project.yaml diff --git a/docs/maturity-and-projects.md b/docs/maturity-and-projects.md index 3ee03d8..e0e95e4 100644 --- a/docs/maturity-and-projects.md +++ b/docs/maturity-and-projects.md @@ -161,9 +161,10 @@ member status `not-applied | applying | applied | stale | failed`). ### Templates -A **project template** (`spec: project-template/v0.1`) is a project skeleton: goal, members, attribute sets and attributes that -still need values. Instantiating a template creates a project file, records which template it came from, and shows which required -attributes are still needed. Templates make a set of runbooks reusable as a product. +A **project template** (`spec: project-template/v0.1`) is a project skeleton: members, attribute sets and attributes that +still need values. Instantiating a template creates a project file with **its own name, description and goal**, records which template it +came from, and shows which required attributes are still needed. Nothing of the template's own title, goal or description is copied into the +project: a project is not a copy of its template, it only remembers where it came from. Templates make a set of runbooks reusable as a product. ### Modes diff --git a/projects/trial-1/project.yaml b/projects/trial-1/project.yaml new file mode 100644 index 0000000..5c1977a --- /dev/null +++ b/projects/trial-1/project.yaml @@ -0,0 +1,30 @@ +spec: project/v0.1 +id: trial-1 +title: A watch at a lighthouse +goal: The night watch is played through and its logbook entry is written and removed + again. +attribute_sets: +- harbour-defaults +targets: + north-light: + title: The northern light + attributes: + weather: fog + south-light: + title: The southern light + attributes: + weather: clear +runbooks: +- pack: rtut.showcase-lighthouse + target: north-light +- pack: rtut.example-hello + after: + - rtut.showcase-lighthouse +environment: rehearsal +template: lighthouse-watch +attributes: + logdir: + requirement: optional + note: Scratch folder; must start with /tmp/rtut- + value: /tmp/rtut-trial-1 + keeper: Dana diff --git a/rtut/__main__.py b/rtut/__main__.py index 281ceea..cdb59f7 100644 --- a/rtut/__main__.py +++ b/rtut/__main__.py @@ -78,8 +78,8 @@ def _project(a) -> int: 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)) + path = J.instantiate(reg, a.name, a.extra, a.environment, dict(kv.split("=", 1) for kv in a.set), + title=a.title, description=a.description, goal=a.goal) print(path); return 0 d = reg.projects.get(a.name) if d is None: @@ -154,6 +154,9 @@ def main(argv: list[str] | None = None) -> int: 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("--title", default=None, help="new: the project's own name (default: its id)") + sp.add_argument("--description", default="", help="new: what the project is") + sp.add_argument("--goal", default="", help="new: what should be true when it is done") 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") diff --git a/rtut/project.py b/rtut/project.py index 3b82fb2..57c2b0f 100644 --- a/rtut/project.py +++ b/rtut/project.py @@ -212,8 +212,13 @@ def start_run(reg: Registry, doc: Doc, pack_id: str, operator: str, target: str # ---- 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//project.yaml from a template. Required attributes may stay unset; `state()` lists them.""" + values: dict[str, str] | None = None, title: str | None = None, root: Path | None = None, + description: str = "", goal: str = "") -> Path: + """Write projects//project.yaml from a template. Required attributes may stay unset; `state()` lists them. + + The project has its own name, description and goal. Nothing of the template's own title, goal or description + is copied: the project only records which template it came from. + """ t = reg.templates.get(template_id) if t is None or not t.ok: raise ValueError(f"no valid template {template_id}") @@ -221,7 +226,11 @@ def instantiate(reg: Registry, template_id: str, project_id: str, environment: s 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")} + from .pack import SECRET_RE + for label, text in (("title", title or ""), ("description", description), ("goal", goal)): + if SECRET_RE.search(text): + raise ValueError(f"the {label} looks like a secret; projects are never secret") + d = {k: v for k, v in t.data.items() if k not in ("spec", "id", "title", "goal", "description")} attrs = normalize(d.get("attributes")) for k, v in (values or {}).items(): attrs.setdefault(k, {"value": None, "requirement": None}) @@ -230,9 +239,15 @@ def instantiate(reg: Registry, template_id: str, project_id: str, environment: s 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 len((title or project_id).strip()) < 3: + raise ValueError("the project name needs at least 3 characters") + project = {"spec": "project/v0.1", "id": project_id, "title": (title or project_id).strip()} + if description.strip(): + project["description"] = description.strip() + if goal.strip(): + project["goal"] = goal.strip() + project.update({k: v for k, v in d.items() if k not in ("attributes", "environment")}) + project.update({"environment": environment, "template": template_id}) if out_attrs: project["attributes"] = out_attrs home = Path(root) if root else t.path.parent.parent diff --git a/rtut/ui_projects.py b/rtut/ui_projects.py index 249043f..344b3db 100644 --- a/rtut/ui_projects.py +++ b/rtut/ui_projects.py @@ -68,8 +68,10 @@ def index_sections(app) -> str: continue errs = J.validate(reg, d) st = pill("invalid") if errs else pill(J.state(reg, d)["state"]) - prj.append(f"
  • {e(d.data['title'])} {pill(d.data['environment'])} {st} " - f"{e(d.data.get('goal', ''))}
  • ") + origin = (f" from template {e(d.data['template'])}" + if d.data.get("template") else "") + desc = f"
    {e(d.data['description'])}" if d.data.get("description") else "" + prj.append(f"
  • {e(d.data['title'])} {pill(d.data['environment'])} {st}{origin}{desc}
  • ") tpl = [f"
  • {e(d.data['title'])} " f"{e(d.data.get('goal', ''))}
  • " if d.ok else f"
  • {e(tid)}: invalid
  • " for tid, d in reg.templates.items()] @@ -113,9 +115,11 @@ def project_page(app, pid: str, msg: str = "") -> bytes | None: return page(d.get("title", pid), "".join(out)) st = J.state(reg, doc) out.append(f"

    {pill(d['environment'])} {pill(st['state'])}" - f"{' from template ' + e(d['template']) + '' if d.get('template') else ''}

    ") + f"{' from template ' + e(d['template']) + '' if d.get('template') else ''}

    ") + if d.get("description"): + out.append(f"

    {e(d['description'])}

    ") if d.get("goal"): - out.append(f"

    {e(d['goal'])}

    ") + out.append(f"

    Goal: {e(d['goal'])}

    ") # what is still needed needs: dict[str, list[str]] = {} for n in st["needs"]: @@ -204,6 +208,12 @@ def template_page(app, tid: str, msg: str = "") -> bytes | None: envs = "".join(f"" for x in R.ENVIRONMENTS) out.append("

    Start a project from this template

    " f"
    {app.hidden()}" + "

    " + "

    " + "

    " "

    " f"

    " @@ -238,7 +248,10 @@ def create_from_template(app, tid: str, form: dict) -> str: if SECRET_RE.search(v): raise ValueError(f"{name}: value looks like a secret; attributes are never secret") pid = form.get("project_id", "").strip() - J.instantiate(reg, tid, pid, form.get("environment", ""), values, root=doc.path.parent.parent) + if len(form.get("title", "").strip()) < 3: + raise ValueError("give the project a name of at least 3 characters") + J.instantiate(reg, tid, pid, form.get("environment", ""), values, title=form.get("title", ""), + root=doc.path.parent.parent, description=form.get("description", ""), goal=form.get("goal", "")) reg = app.registry() created = reg.projects[pid] errs = J.validate(reg, created) diff --git a/schema/project.schema.json b/schema/project.schema.json index c0b7743..0f00d43 100644 --- a/schema/project.schema.json +++ b/schema/project.schema.json @@ -99,6 +99,10 @@ } } } + }, + "description": { + "type": "string", + "description": "What this project is, in its own words. Not inherited from a template." } }, "allOf": [ diff --git a/tests/test_projects.py b/tests/test_projects.py index a966229..3cbadef 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -197,10 +197,13 @@ def test_runs_of_other_projects_do_not_count(repo): def test_instantiate_template_records_origin_merges_values_and_lists_needs(repo): reg = Registry([repo]) - out = J.instantiate(reg, "lighthouse-watch", "harbour-north", "rehearsal", {"keeper": "Ada", "logdir": "/tmp/rtut-harbour"}) + out = J.instantiate(reg, "lighthouse-watch", "harbour-north", "rehearsal", {"keeper": "Ada", "logdir": "/tmp/rtut-harbour"}, + title="North harbour watch", description="Our own harbour", goal="Both lights keep watch") made = yaml.safe_load(out.read_text()) assert made["spec"] == "project/v0.1" and made["template"] == "lighthouse-watch" and made["environment"] == "rehearsal" - assert made["attributes"]["keeper"] == "Ada" and made["goal"].startswith("The night watch") + assert made["attributes"]["keeper"] == "Ada" + assert made["title"] == "North harbour watch" and made["description"] == "Our own harbour" and made["goal"] == "Both lights keep watch" + assert "The night watch is played through" not in out.read_text() and "A watch at a lighthouse" not in out.read_text() # nothing of the template's text is copied assert set(made["targets"]) == {"north-light", "south-light"} reg = Registry([repo]) assert reg.projects["harbour-north"].ok and J.validate(reg, reg.projects["harbour-north"]) == [] @@ -235,3 +238,14 @@ def test_set_attributes_validates_against_the_consuming_runbooks(repo): def test_attribute_notes_survive_resolution(repo): reg = Registry([repo]) assert "Who keeps this light" in fold(set_layers(reg, "harbour-defaults"))["keeper"]["note"] + + +def test_project_without_a_given_name_uses_its_id_never_the_templates_title(repo): + reg = Registry([repo]) + out = J.instantiate(reg, "lighthouse-watch", "plain-one", "staging") + made = yaml.safe_load(out.read_text()) + assert made["title"] == "plain-one" and "goal" not in made and "description" not in made + with pytest.raises(ValueError, match="3 characters"): + J.instantiate(reg, "lighthouse-watch", "ab", "staging") + with pytest.raises(ValueError, match="secret"): + J.instantiate(reg, "lighthouse-watch", "leaky-one", "staging", description="token hvs.ABCDEFGHIJKLMN") diff --git a/tests/test_ui_projects.py b/tests/test_ui_projects.py index e14cb1c..53b2f96 100644 --- a/tests/test_ui_projects.py +++ b/tests/test_ui_projects.py @@ -50,7 +50,8 @@ def req(s, method, path, form=None, host="127.0.0.1"): def make_project(s, pid="harbour", host=None): t = s.app.token - form = {"_t": t, "project_id": pid, "environment": "rehearsal", "a_keeper": "Ada", "a_logdir": f"/tmp/rtut-ui-{pid}"} + form = {"_t": t, "project_id": pid, "environment": "rehearsal", "a_keeper": "Ada", "a_logdir": f"/tmp/rtut-ui-{pid}", + "title": "Harbour watch", "description": "Our own harbour, two lights.", "goal": "Both lights keep watch tonight"} if host: form["a_host"] = host return req(s, "POST", "/template/lighthouse-watch/new", form) @@ -68,6 +69,7 @@ def test_template_page_shows_attributes_with_requirement_and_notes(psrv): assert st == 200 and "A watch at a lighthouse" in body assert "name='a_keeper'" in body and "Who keeps this light" in body and ">required<" in body assert "name='a_weather'" not in body # weather is set per target, so it is not a template question + assert "name=title" in body and "name=description" in body and "name=goal" in body # the project's own identity assert "Create project" in body and "" in body and "" in body assert req(psrv, "GET", "/template/nope")[0] == 404 @@ -78,9 +80,11 @@ def test_create_project_from_template_and_view_it(psrv): made = yaml.safe_load((psrv.repo / "projects/harbour/project.yaml").read_text()) assert made["template"] == "lighthouse-watch" and made["environment"] == "rehearsal" and made["attributes"]["keeper"] == "Ada" _, _, body = req(psrv, "GET", "/project/harbour") - assert "A watch at a lighthouse" in body and "from template lighthouse-watch" in body + assert "

    Harbour watch

    " in body and "Our own harbour, two lights." in body and "Both lights keep watch tonight" in body + assert "from template lighthouse-watch" in body # origin is shown, as a link + assert "A watch at a lighthouse" not in body # the template's title... + assert "The night watch is played through" not in body # ...and its description are not assert ">planned<" in body and ">rehearsal<" in body - assert "The night watch is played through" in body # the goal assert "target:north-light" in body # provenance of the fog weather assert "Still needed" in body and "name='a_host'" in body # hello needs a host assert "cannot be run in this UI yet" in body # the v0.2 member @@ -159,12 +163,12 @@ def test_csrf_host_and_escaping(psrv): def test_bad_project_id_and_duplicate_are_refused(psrv): t = psrv.app.token - st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "Bad Id!", "environment": "rehearsal"}) + st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "project_id": "Bad Id!", "environment": "rehearsal"}) assert st == 400 and "project id" in body assert make_project(psrv)[0] == 303 - st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "harbour", "environment": "rehearsal"}) + st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "project_id": "harbour", "environment": "rehearsal"}) assert st == 400 and "already exists" in body - st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "other", "environment": "prod"}) + st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "title": "Some name", "project_id": "other", "environment": "prod"}) assert st == 400 and "environment" in body @@ -175,3 +179,20 @@ def test_v02_run_shows_a_guard_instead_of_crashing(psrv): i = psrv.app.pack_index("rtut.showcase-lighthouse-v2") st, _, body = req(psrv, "GET", f"/run/{i}/{r.data['run_id']}") assert st == 200 and "cannot walk v0.2 runs yet" in body + + +def test_front_page_lists_projects_by_their_own_name_and_shows_the_template_as_origin(psrv): + make_project(psrv) + _, _, body = req(psrv, "GET", "/") + projects = body.split("

    Project templates

    ")[0] + assert "Harbour watch" in projects and "Our own harbour, two lights." in projects + assert "from template lighthouse-watch" in projects + assert "The night watch is played through" not in projects and "A watch at a lighthouse" not in projects + assert "A watch at a lighthouse" in body.split("

    Project templates

    ")[1] # the template keeps its own listing + + +def test_creating_a_project_needs_a_name(psrv): + t = psrv.app.token + st, _, body = req(psrv, "POST", "/template/lighthouse-watch/new", {"_t": t, "project_id": "nameless", "environment": "rehearsal", "title": "x"}) + assert st == 400 and "name" in body + assert not (psrv.repo / "projects/nameless").exists()