Add lighthouse showcase pack (v0.1 runnable, v0.2 draft); fix manual-check cycle and blocked-step feedback (RBT-WP-0004-T05)
Some checks failed
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Work Records / validate (push) Has been cancelled

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 10:36:29 +02:00
parent f9fb9008bf
commit b47c948a7e
8 changed files with 387 additions and 12 deletions

View file

@ -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>/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 <repo-root> # validate every pack

View file

@ -79,7 +79,12 @@ class App:
if msg:
out.append(f"<p class=bad>{e(msg)}</p>")
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"<p class=eyebrow>{done_n} of {total} steps done</p>"
f"<progress max={total} value={done_n} style='width:100%'></progress>")
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"<form method=post action='{url}/start'>{self.hidden()}<button>Start</button></form>"
act = f"<form method=post action='{url}/start'>{self.hidden()}<button>Start this step</button></form>"
elif state == "todo":
act = f"<p class=note>Waiting for: {e(waiting_for)}</p>"
elif state == "running":
ver = s["verify"]
if ver.get("expect", "manual") == "manual":
act = (f"<form method=post action='{url}/verify'>{self.hidden()}<label><input type=checkbox name=ok value=1> "
f"I confirm: {e(ver['done_when'])}</label> <button>Record check</button></form>")
act = (f"<form method=post action='{url}/verify'>{self.hidden()}"
f"<p>Is this true? <b>{e(ver['done_when'])}</b></p>"
"<button name=ok value=1>Yes, it is</button> <button name=ok value=0>Not yet</button></form>")
else:
act = (f"<form method=post action='{url}/verify'>{self.hidden()}<input type=hidden name=yes value=1>"
f"<p class=note>Runs: <code>{e(R.render(ver['command'], r.data['params'], p))}</code></p>"
f"<button>Run check</button></form>")
if v:
act += f"<p>{'✔ passed' if v['passed'] else '✘ failed'}</p>"
if not v:
act = "<p class=note>1. Run the command, then check &nbsp;→&nbsp; 2. Mark done to lock it in</p>" + act
elif v["passed"]:
act += "<p><b>✔ Check passed.</b> Press <b>Mark done</b> to lock it in; the next step opens after that.</p>"
else:
act += "<p class=bad>✘ Check not passed. Fix the cause, then check again, or mark the step failed.</p>"
if v and v["passed"]:
ev = "".join(f"<label>{e(n)} <input name='e_{e(n)}' required></label> " for n in s.get("evidence", []))
act += f"<form method=post action='{url}/done'>{self.hidden()}{ev}<button>Mark done</button></form>"
@ -106,10 +118,11 @@ class App:
"<button>Mark failed</button></form>")
elif state == "failed":
act = f"<p class=bad>Failed: {e(st.get('note', ''))}</p><form method=post action='{url}/retry'>{self.hidden()}<button>Retry</button></form>"
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"<p class=note>Rollback: {e(s['rollback'])}</p>" if s.get("rollback") else ""
out.append(f"<div class='step {state}'><span class=pill>{state}</span> <b>{e(s['title'])}</b> "
out.append(f"<div class='step {state}'><span class=pill>{state}</span> <b>{n}. {e(s['title'])}</b> "
f"<span class=note>[{e(s['owner'])}] {e(s.get('risk', 'read-only'))}</span>{block}"
f"<p class=note>Done when: {e(s['verify']['done_when'])}</p>{rb}{act}</div>")
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", ""))

View file

@ -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}

View file

@ -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}

69
tests/test_showcase.py Normal file
View file

@ -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 /'})

View file

@ -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

View file

@ -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.

View file

@ -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.