From b47c948a7e572c3d280f619f96caad57c79db337 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 29 Sep 2026 10:36:29 +0200 Subject: [PATCH] Add lighthouse showcase pack (v0.1 runnable, v0.2 draft); fix manual-check cycle and blocked-step feedback (RBT-WP-0004-T05) 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 --- README.md | 3 +- rtut/ui.py | 31 ++-- .../showcase-lighthouse/pack.v0.2.draft.yaml | 147 ++++++++++++++++++ runbooks/showcase-lighthouse/pack.yaml | 102 ++++++++++++ tests/test_showcase.py | 69 ++++++++ tests/test_ui.py | 28 ++++ ...WP-0002-runbook-pack-spec-and-validator.md | 15 ++ workplans/RBT-WP-0004-local-runbook-ui.md | 4 +- 8 files changed, 387 insertions(+), 12 deletions(-) create mode 100644 runbooks/showcase-lighthouse/pack.v0.2.draft.yaml create mode 100644 runbooks/showcase-lighthouse/pack.yaml create mode 100644 tests/test_showcase.py diff --git a/README.md b/README.md index dfd9d45..6767de1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,8 @@ can guide through actual IT administration and operation tasks. - **Intent:** `INTENT.md` · **Scope:** `SCOPE.md` - **Pack specification:** `docs/pack-spec.md`, schema in `schema/` - **Packs live where the procedure lives:** `runbooks//pack.yaml` in the owning repo. - Examples: `net-kingdom/runbooks/`, and `runbooks/example-hello/` here. + Examples: `net-kingdom/runbooks/`, and here `runbooks/example-hello/` and + `runbooks/showcase-lighthouse/` (a harmless story to try the UI on). ```bash python3 -m rtut validate # validate every pack diff --git a/rtut/ui.py b/rtut/ui.py index 9c6f772..f208b1d 100644 --- a/rtut/ui.py +++ b/rtut/ui.py @@ -79,7 +79,12 @@ class App: if msg: out.append(f"

{e(msg)}

") blocked = False - for s in d["steps"]: + waiting_for = "" + total = len(d["steps"]) + done_n = sum(1 for x in d["steps"] if r.state(x["id"])["state"] in ("done", "skipped")) + out.append(f"

{done_n} of {total} steps done

" + f"") + for n, s in enumerate(d["steps"], 1): st = r.state(s["id"]); state = st["state"] act = "" cmd = R.command_for(r, s["id"]) @@ -87,18 +92,25 @@ class App: url = f"/run/{i}/{e(rid)}/{e(s['id'])}" v = st.get("verification") if state == "todo" and not blocked: - act = f"
{self.hidden()}
" + act = f"
{self.hidden()}
" + elif state == "todo": + act = f"

Waiting for: {e(waiting_for)}

" elif state == "running": ver = s["verify"] if ver.get("expect", "manual") == "manual": - act = (f"
{self.hidden()}
") + act = (f"
{self.hidden()}" + f"

Is this true? {e(ver['done_when'])}

" + "
") else: act = (f"
{self.hidden()}" f"

Runs: {e(R.render(ver['command'], r.data['params'], p))}

" f"
") - if v: - act += f"

{'✔ passed' if v['passed'] else '✘ failed'}

" + if not v: + act = "

1. Run the command, then check  →  2. Mark done to lock it in

" + act + elif v["passed"]: + act += "

✔ Check passed. Press Mark done to lock it in; the next step opens after that.

" + else: + act += "

✘ Check not passed. Fix the cause, then check again, or mark the step failed.

" if v and v["passed"]: ev = "".join(f" " for n in s.get("evidence", [])) act += f"
{self.hidden()}{ev}
" @@ -106,10 +118,11 @@ class App: "") elif state == "failed": act = f"

Failed: {e(st.get('note', ''))}

{self.hidden()}
" - if state not in ("done", "skipped"): + if state not in ("done", "skipped") and not blocked: blocked = True + waiting_for = f"step {n}, {s['title']}" rb = f"

Rollback: {e(s['rollback'])}

" if s.get("rollback") else "" - out.append(f"
{state} {e(s['title'])} " + out.append(f"
{state} {n}. {e(s['title'])} " f"[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}{block}" f"

Done when: {e(s['verify']['done_when'])}

{rb}{act}
") if r.complete: @@ -167,7 +180,7 @@ def make_handler(app: App, port_holder: list): p = app.packs[i]; r = R.load(p, rid) if action == "start": R.start(r, sid) elif action == "verify": - R.verify(r, sid, confirm=bool(form.get("yes")), manual_ok=bool(form.get("ok")) if not form.get("yes") else None) + R.verify(r, sid, confirm=bool(form.get("yes")), manual_ok=(form.get("ok") == "1") if not form.get("yes") else None) elif action == "done": R.complete(r, sid, {k[2:]: v for k, v in form.items() if k.startswith("e_")}) elif action == "fail": R.fail(r, sid, form.get("note", "")) diff --git a/runbooks/showcase-lighthouse/pack.v0.2.draft.yaml b/runbooks/showcase-lighthouse/pack.v0.2.draft.yaml new file mode 100644 index 0000000..376dd0e --- /dev/null +++ b/runbooks/showcase-lighthouse/pack.v0.2.draft.yaml @@ -0,0 +1,147 @@ +# DRAFT - not loaded by the engine. Written against docs/pack-spec-v0.2-proposal.md. +# Purpose: an acceptance test for the proposed model. If the model cannot express +# this story without special cases, the proposal must change. When v0.2 is adopted +# this file becomes pack.yaml (and the v0.1 file is retired or kept as a +# compatibility fixture). +spec: runbook-pack/v0.2 +id: rtut.showcase-lighthouse +title: "Showcase: a night at the lighthouse" +owner: runbook-tutorials +outcome: You have played a short interactive story that touches every feature of the runbook engine, using only harmless echo, printf and a scratch file under /tmp/rtut-. +exercise_status: unexercised +engine: native +confirm_phrase: I understand this cannot be undone + +framing: # shown first; things to keep in mind, not gates + - This showcase never handles a secret. Datapoints hold references and facts, never secret values. + - Only /tmp/rtut-* is ever written or removed. + - Several things below can be done in any order; the guide recommends one. + +actors: + - id: keeper + role: Lighthouse keeper + description: Runs the lamp and the signals. Does the everyday work. + privileges: [light-lamp, sound-signal, write-logbook] + - id: harbourmaster + role: Harbour master + description: Approves the irreversible closing of the night's log. + privileges: [close-log, waive-lamp-check] + +datapoints: + - {id: keeper_name, label: Keeper's name, type: string, pattern: "[A-Za-z]+", default: Ada, dimensions: {actor: keeper, kind: identity}, ask_in: dusk} + - {id: lamp_fuel, label: Lamp fuel, type: enum, values: [whale-oil, paraffin, electric], default: paraffin, dimensions: {actor: keeper, kind: equipment}, ask_in: dusk} + - {id: weather, label: Tonight's weather, type: enum, values: [clear, fog, storm], default: fog, dimensions: {kind: condition}, ask_in: dusk} + - {id: motto, label: Motto, type: text, default: Steady light for those at sea, dimensions: {actor: keeper, kind: identity}, ask_in: dusk} + - {id: logdir, label: Scratch folder, type: path, pattern: "/tmp/rtut-[a-z0-9-]+", default: /tmp/rtut-lighthouse, dimensions: {kind: location, subsystem: logbook}, ask_in: dusk} + - {id: lamp_lit, label: Lamp is lit, type: bool, dimensions: {actor: keeper, kind: state}, produced_by: light-lamp, confirmed_by: lamp-check} + +stages: + - id: dusk + title: Dusk - prepare + goal: The lamp is lit and the weather is known before dark. + steps: # no requires among these: they are parallel; list order is the recommended order + - id: read-the-sign + title: Read the poem over the door + actor: keeper + owner: the-lighthouse + command: printf '%s\n' 'Tower of stone, and a patient flame,' 'you keep no name, yet call each name' 'across the dark, across the foam:' 'come home, come home, come home.' + reversible: true + verify: {done_when: You read the poem, expect: manual} + - id: light-lamp + title: Light the lamp + actor: keeper + owner: the-lighthouse + uses: [keeper_name, lamp_fuel] + produces: [lamp_lit] + command: echo "{{keeper_name}} lights the {{lamp_fuel}} lamp." + reversible: true + verify: {done_when: The lamp is lit, expect: manual} + - id: read-the-sky + title: Read the sky + actor: keeper + owner: the-lighthouse + uses: [weather] + command: echo "Weather tonight - {{weather}}." + reversible: true + verify: {done_when: You know the weather, command: 'echo "Weather tonight - {{weather}}."', expect: output-contains, contains: "{{weather}}"} + gates: + - id: lamp-check + title: The lamp burns steadily + goal: safe-navigation # quality objective; a canon standard id where one exists + blocking: true + checks: + - {done_when: The light is visible from the water, expect: manual} + - {datapoints_ok: [lamp_lit]} + waivable: {by: harbourmaster, needs_reason: true} + + - id: night + title: Night watch - give warning + goal: Ships are warned in a way that suits the weather. + requires: [gate:lamp-check] + steps: + - id: warn + title: Warn the ships + actor: keeper + owner: the-lighthouse + uses: [keeper_name, weather] + one_of: # alternatives: any one satisfies "warn"; the guide recommends by weather + - id: sound-horn + title: Sound the fog horn + recommended_when: {weather: [fog]} + command: echo "{{keeper_name}} sounds the horn through the {{weather}} night." + reversible: true + verify: {done_when: The horn has sounded, expect: manual} + - id: wave-flags + title: Signal with flags + recommended_when: {weather: [clear]} + command: echo "{{keeper_name}} signals with flags in the {{weather}} night." + reversible: true + verify: {done_when: The flags were seen, expect: manual} + - id: shelter + title: Bar the door and wait it out + recommended_when: {weather: [storm]} + command: echo "{{keeper_name}} bars the door. Nobody is warned tonight." + reversible: true + verify: {done_when: The door is barred, expect: manual} + - id: write-logbook + title: Write tonight's entry + actor: keeper + owner: the-lighthouse + requires: [warn] + uses: [keeper_name, weather, lamp_fuel, motto, logdir] + produces: [logbook_file] + command: mkdir -p {{logdir}} && echo "Night watch by {{keeper_name}} - {{weather}}, {{lamp_fuel}} lamp. {{motto}}." >> {{logdir}}/logbook.txt + reversible: true + rollback: rm -rf {{logdir}} + verify: {done_when: The logbook exists, command: 'test -s {{logdir}}/logbook.txt', expect: exit-0} + + - id: dawn + title: Dawn - close the night + goal: The night is on record and the scratch space is clean. + requires: [night] + steps: + - id: close-log + title: Close the log for good + actor: harbourmaster + owner: the-lighthouse + uses: [logdir] + command: cat {{logdir}}/logbook.txt && rm -rf {{logdir}} + reversible: false # engine demands the typed confirm_phrase; no renderer can skip it + verify: {done_when: The scratch folder is gone, command: 'test ! -e {{logdir}}', expect: exit-0} + gates: + - id: night-on-record + title: Nothing left behind + goal: clean-handover + blocking: true + checks: + - {done_when: No files remain under the scratch folder, command: 'test ! -e {{logdir}}', expect: exit-0} + +artefacts: + - id: logbook_file + title: The night's logbook entry + location: {datapoint: logdir, path: logbook.txt} # a reference, never a value + produced_by: write-logbook + lives_until: close-log + +ownership: + - {concern: The story, owner: runbook-tutorials} diff --git a/runbooks/showcase-lighthouse/pack.yaml b/runbooks/showcase-lighthouse/pack.yaml new file mode 100644 index 0000000..66878c9 --- /dev/null +++ b/runbooks/showcase-lighthouse/pack.yaml @@ -0,0 +1,102 @@ +spec: runbook-pack/v0.1 +id: rtut.showcase-lighthouse +title: "Showcase: a night at the lighthouse" +owner: runbook-tutorials +outcome: You have played a short interactive story that touches every feature of the runbook engine, using only harmless echo, printf and a scratch file. Nothing outside /tmp/rtut-* is touched. +exercise_status: unexercised +engine: native +parameters: + - {id: keeper, label: Your keeper's name, type: string, default: Ada, pattern: "[A-Za-z]+", help: One word. Used in the story.} + - {id: lamp, label: Lamp fuel, type: enum, values: [whale-oil, paraffin, electric], default: paraffin, help: "Changes only the story text."} + - {id: weather, label: Tonight's weather, type: enum, values: [clear, fog, storm], default: fog} + - {id: signal, label: Warning signal, type: enum, values: [horn, flags], default: horn, help: "In fog the horn carries; in a storm, flags are lost. Choose well."} + - {id: motto, label: The keeper's motto, type: text, default: Steady light for those at sea, help: A short phrase. Spaces are fine; quotes and shell symbols are not.} + - {id: logdir, label: Scratch folder for the logbook, type: path, default: /tmp/rtut-lighthouse, pattern: "/tmp/rtut-[a-z0-9-]+", help: Must start with /tmp/rtut-. Removed at the end of the story.} +prerequisites: + - {text: "A shell with echo, printf, cat and test (any Linux or macOS terminal)", owner: workstation} + - {text: "Nothing else. No network, no accounts, no secrets", owner: nobody} +steps: + - id: read-the-sign + title: Read the poem over the door + owner: the-lighthouse + description: A read-only step with a manual check. The engine cannot know whether you read it, so it asks you. + command: | + printf '%s\n' 'Tower of stone, and a patient flame,' 'you keep no name, yet call each name' 'across the dark, across the foam:' 'come home, come home, come home.' + verify: + done_when: You read the poem and know what the light is for + expect: manual + - id: meet-the-keeper + title: Meet the keeper + owner: the-lighthouse + description: Parameters appear inside commands. Change the name in the parameter form and this line changes. + command: echo "{{keeper}} climbs the stairs. Motto on the wall - {{motto}}." + verify: + done_when: The line names your keeper + command: echo "{{keeper}} climbs the stairs. Motto on the wall - {{motto}}." + expect: output-contains + contains: "{{keeper}} climbs" + - id: light-the-lamp + title: Light the lamp + owner: the-lighthouse + command: echo "{{keeper}} lights the {{lamp}} lamp." + verify: + done_when: The lamp is lit + command: echo "{{keeper}} lights the {{lamp}} lamp." + expect: output-contains + contains: "lights the {{lamp}} lamp" + - id: read-the-sky + title: Read the sky + owner: the-lighthouse + command: echo "Weather tonight - {{weather}}." + verify: + done_when: You know tonight's weather + command: echo "Weather tonight - {{weather}}." + expect: output-contains + contains: "{{weather}}" + - id: sound-the-signal + title: Sound the warning + owner: the-lighthouse + description: In a fuller runbook this would be a choice between alternatives. In v0.1 the choice is a parameter. + command: echo "{{keeper}} works the {{signal}} through the {{weather}} night." + verify: + done_when: The signal is given + command: echo "{{keeper}} works the {{signal}} through the {{weather}} night." + expect: output-contains + contains: "works the {{signal}}" + - id: write-the-logbook + title: Write tonight's entry in the logbook + owner: the-lighthouse + description: The only step that changes anything, and only a file under /tmp/rtut-. Reversible, so it needs a confirm, not a typed phrase. + command: mkdir -p {{logdir}} && echo "Night watch by {{keeper}} - {{weather}}, {{lamp}} lamp, signal {{signal}}. {{motto}}." >> {{logdir}}/logbook.txt + risk: changes-state + rollback: rm -rf {{logdir}} + verify: + done_when: The logbook exists and is not empty + command: test -s {{logdir}}/logbook.txt + expect: exit-0 + evidence: [logbook_lines] + - id: read-the-logbook + title: Read the logbook back + owner: the-lighthouse + command: cat {{logdir}}/logbook.txt + verify: + done_when: The entry names your keeper + command: cat {{logdir}}/logbook.txt + expect: output-contains + contains: "{{keeper}}" + - id: dawn + title: Put out the lamp and tidy up + owner: the-lighthouse + description: Removes the scratch folder. A run in which every step is done can offer to mark this pack exercised. + command: rm -rf {{logdir}} + risk: changes-state + rollback: Repeat write-the-logbook to rebuild the entry. + verify: + done_when: The scratch folder is gone + command: test ! -e {{logdir}} + expect: exit-0 +threat_checks: + - This showcase never handles a secret. Parameters are never secret in any runbook; do not type one here to see what happens. + - Only /tmp/rtut-* is ever written or removed; the folder pattern is enforced by the parameter. +ownership: + - {concern: The story, owner: runbook-tutorials} diff --git a/tests/test_showcase.py b/tests/test_showcase.py new file mode 100644 index 0000000..58f9224 --- /dev/null +++ b/tests/test_showcase.py @@ -0,0 +1,69 @@ +from pathlib import Path +import shutil +import subprocess + +import pytest +import yaml + +from rtut import pack as P, run as R + +ROOT = Path(__file__).resolve().parent.parent +SRC = ROOT / "runbooks/showcase-lighthouse" + + +@pytest.fixture +def pk(tmp_path): + d = tmp_path / "runbooks" / "showcase"; d.mkdir(parents=True) + shutil.copy(SRC / "pack.yaml", d / "pack.yaml") + return P.load(d / "pack.yaml") + + +def test_showcase_is_valid(): + assert P.load(SRC / "pack.yaml").ok + + +def test_v02_draft_is_wellformed_and_self_consistent(): + d = yaml.safe_load((SRC / "pack.v0.2.draft.yaml").read_text()) + assert d["spec"] == "runbook-pack/v0.2" + actors = {a["id"] for a in d["actors"]} + dps = {x["id"] for x in d["datapoints"]} + steps = {} + for st in d["stages"]: + for s in st["steps"]: + steps[s["id"]] = s + assert s["actor"] in actors + for u in s.get("uses", []): + assert u in dps, u + gates = {g["id"] for st in d["stages"] for g in st.get("gates", [])} + stages = {st["id"] for st in d["stages"]} + for st in d["stages"]: + for r in st.get("requires", []): + assert r.removeprefix("gate:") in gates | stages + assert any(s.get("reversible") is False for s in steps.values()) + assert any("one_of" in s for s in steps.values()) + assert not any(k.startswith("secret") for x in d["datapoints"] for k in x) + + +def test_scripted_playthrough(pk): + r = R.create(pk, "tester", {"keeper": "Ada", "weather": "storm", "motto": "Light on"}) + for s in pk.data["steps"]: + sid = s["id"] + R.start(r, sid) + cmd = R.command_for(r, sid) # the operator runs the command... + if cmd: + subprocess.run(cmd, shell=True, check=True, capture_output=True) + v = s["verify"] # ...then the engine verifies the result + res = R.verify(r, sid, confirm=True, manual_ok=True if v["expect"] == "manual" else None) + assert res["passed"], sid + R.complete(r, sid, {n: "1" for n in s.get("evidence", [])}) + assert r.complete + assert not Path(r.data["params"]["logdir"]).exists() + old, new = R.exercise_update(r) + assert "exercised" in new + + +def test_bad_logdir_rejected(pk): + with pytest.raises(R.RunError): + R.create(pk, "tester", {"logdir": "/etc"}) + with pytest.raises(R.RunError): + R.create(pk, "tester", {"motto": 'x"; rm -rf /'}) diff --git a/tests/test_ui.py b/tests/test_ui.py index 80b554a..22401af 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -66,3 +66,31 @@ def test_secret_in_note_refused(srv): req(srv, "POST", loc + "/ping/start", {"_t": t}) st, _, body = req(srv, "POST", loc + "/ping/fail", {"_t": t, "note": "hvs.ABCDEFGHIJKLMN"}) assert st == 400 and "secret-looking" in body + + +def test_manual_check_cycle_and_blocked_reason(srv): + """Yes/Not-yet buttons, 'Mark done' to lock in, numbering, and why later steps are blocked.""" + import shutil + t = srv.app.token + # use the showcase pack, which starts with a manual step + d = Path(srv.app.packs[0].path).parents[2] / "runbooks" / "show"; d.mkdir(parents=True) + shutil.copy(ROOT / "runbooks/showcase-lighthouse/pack.yaml", d / "pack.yaml") + from rtut import pack as P + srv.app.packs.append(P.load(d / "pack.yaml")) + i = len(srv.app.packs) - 1 + _, loc, _ = req(srv, "POST", f"/run/new/{i}", {"_t": t, "operator": "bernd"}) + _, _, body = req(srv, "GET", loc) + assert "0 of 8 steps done" in body and "1. Read the poem" in body + assert "Waiting for: step 1" in body # blocked steps explain themselves + req(srv, "POST", loc + "/read-the-sign/start", {"_t": t}) + _, _, body = req(srv, "GET", loc) + assert "Yes, it is" in body and "Not yet" in body and "/read-the-sign/done" not in body + req(srv, "POST", loc + "/read-the-sign/verify", {"_t": t, "ok": "0"}) + _, _, body = req(srv, "GET", loc) + assert "Check not passed" in body and "/read-the-sign/done" not in body + req(srv, "POST", loc + "/read-the-sign/verify", {"_t": t, "ok": "1"}) + _, _, body = req(srv, "GET", loc) + assert "Check passed" in body and "/read-the-sign/done" in body + req(srv, "POST", loc + "/read-the-sign/done", {"_t": t}) + _, _, body = req(srv, "GET", loc) + assert "1 of 8 steps done" in body and "Start this step" in body diff --git a/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md b/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md index 88b6899..8dca9fd 100644 --- a/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md +++ b/workplans/RBT-WP-0002-runbook-pack-spec-and-validator.md @@ -95,6 +95,21 @@ state_hub_task_id: "dae5a6fe-9f1f-553a-8b73-f47ab6d91b59" Port the four validating packs to v0.2 where it helps, and add v0.2 test packs with alternatives, gates and actors. +## Showcase pack as acceptance test + +```task +id: RBT-WP-0002-T07 +status: progress +priority: medium +``` + +`runbooks/showcase-lighthouse/` is a harmless interactive-fiction runbook (poem, story, +scratch logbook under /tmp/rtut-*). `pack.yaml` (v0.1) runs today and is the test bed for +UI refinement. `pack.v0.2.draft.yaml` expresses the same story in the proposed v0.2 model +(stages, parallel steps, alternatives, gates, actors, datapoints, artefacts, irreversible +confirmation). The draft is checked for internal consistency by tests. When v0.2 is adopted +the validator must accept it unchanged, or the proposal changes. + ## Acceptance Criteria - Every pack in this repo and in `net-kingdom` validates. diff --git a/workplans/RBT-WP-0004-local-runbook-ui.md b/workplans/RBT-WP-0004-local-runbook-ui.md index ad42fbf..432683f 100644 --- a/workplans/RBT-WP-0004-local-runbook-ui.md +++ b/workplans/RBT-WP-0004-local-runbook-ui.md @@ -73,12 +73,12 @@ diff from RBT-WP-0003-T04. ```task id: RBT-WP-0004-T05 -status: todo +status: done priority: high state_hub_task_id: "d635f972-0377-5581-a8fe-c01ef89559cd" ``` -Defects found in review: a ticked manual check shows "passed" while the step stays +Done 2026-09-29 against the lighthouse showcase (tested over HTTP; a browser pass by Bernd is still the real check). Defects found in review: a ticked manual check shows "passed" while the step stays `running`, and unticking changes nothing; blocked steps show no Start button and no reason. Make the cycle explicit (check, record, mark done), and show what a blocked step is waiting for.