Skip to content

Commit 81250ab

Browse files
sturleseclaude
andauthored
fix(demo): refuse to seed over a directory that is not a previous demo (#20)
`flightdeck demo --dir` pointed at an existing org silently overwrote flightdeck.yaml/models.yaml/usecases.yaml, rmtree'd workflows/ and deleted the run store and the audit ledger — the whole evidence trail. seed() now refuses any non-empty target that is not a previous demo (recognized by the demo org name in flightdeck.yaml); the CLI maps the refusal to exit 2. Re-seeding a demo dir in place keeps working. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1fad006 commit 81250ab

4 files changed

Lines changed: 92 additions & 4 deletions

File tree

‎src/flightdeck/cli.py‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from flightdeck import __version__, scaffold
2727
from flightdeck import backlog as backlog_mod
2828
from flightdeck.config import ConfigError, Org, load_org
29-
from flightdeck.demo import seed
29+
from flightdeck.demo import DemoSeedError, seed
3030
from flightdeck.feedback import FeedbackError, record_feedback
3131
from flightdeck.integrations import slack
3232
from flightdeck.integrations.slack import SlackError
@@ -108,7 +108,11 @@ def demo(
108108
html: Annotated[Path | None, typer.Option(help="Where to write the dashboard.")] = None,
109109
) -> None:
110110
"""Seed a 13-week fictional org (offline, deterministic) and report on it."""
111-
summary = seed(dir)
111+
try:
112+
summary = seed(dir)
113+
except DemoSeedError as exc:
114+
err.print(f"[red]{exc}[/red]")
115+
raise typer.Exit(2) from None
112116
org = _org(dir)
113117
with Store(org.db_path) as store:
114118
ledger = Ledger(org.ledger_path)

‎src/flightdeck/demo.py‎

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
from pathlib import Path
2727
from random import Random
2828

29+
import yaml
30+
2931
from flightdeck.config import load_org
3032
from flightdeck.ledger import Ledger
3133
from flightdeck.runner import record # the same evidence path real runs take
@@ -116,6 +118,12 @@ class _Profile:
116118
)
117119

118120

121+
class DemoSeedError(Exception):
122+
"""Seeding would destroy files that are not a previous flightdeck demo. The
123+
CLI maps this to a usage error (exit 2); callers that mean it pass a fresh
124+
directory."""
125+
126+
119127
@dataclass
120128
class DemoSummary:
121129
root: Path
@@ -139,10 +147,36 @@ def _business_moment(rng: Random, week_start: date, max_day: int = 4) -> datetim
139147
)
140148

141149

150+
def _refuse_to_destroy(root: Path, source) -> None:
151+
"""Allow seeding only into a new/empty directory or a previous demo org,
152+
recognized by the demo org's name in ``flightdeck.yaml``. Re-seeding a demo
153+
in place is routine (the dir accumulates dashboards and runtime state);
154+
silently wiping a REAL org's workflows, store and audit ledger is not."""
155+
if not root.exists() or not any(root.iterdir()):
156+
return
157+
demo_name = yaml.safe_load((source / "flightdeck.yaml").read_text(encoding="utf-8"))["name"]
158+
org_file = root / "flightdeck.yaml"
159+
if org_file.is_file():
160+
try:
161+
existing = yaml.safe_load(org_file.read_text(encoding="utf-8"))
162+
except yaml.YAMLError:
163+
existing = None
164+
if isinstance(existing, dict) and existing.get("name") == demo_name:
165+
return
166+
raise DemoSeedError(
167+
f"refusing to seed the demo into {root}: the directory is not empty and is not a "
168+
f"previous flightdeck demo — seeding would overwrite the org files and delete "
169+
f"workflows/, the run store and the audit ledger. Pass a new or empty --dir."
170+
)
171+
172+
142173
def seed(target: Path | str, weeks: int = WEEKS, rng_seed: int = SEED) -> DemoSummary:
143-
"""Create the demo org at ``target`` and seed its store and ledger."""
174+
"""Create the demo org at ``target`` and seed its store and ledger. ``target``
175+
must be new, empty, or a previous demo: seeding overwrites the org files and
176+
deletes workflows/, the store and the ledger (see ``_refuse_to_destroy``)."""
144177
root = Path(target)
145178
source = files("flightdeck") / "demo_org"
179+
_refuse_to_destroy(root, source)
146180
root.mkdir(parents=True, exist_ok=True)
147181
for item in ("flightdeck.yaml", "models.yaml", "usecases.yaml"):
148182
(root / item).write_text((source / item).read_text(encoding="utf-8"), encoding="utf-8")

‎tests/test_backlog_demo.py‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1+
import pytest
2+
13
from flightdeck.backlog import ranked, score
24
from flightdeck.config import load_org
3-
from flightdeck.demo import seed
5+
from flightdeck.demo import DemoSeedError, seed
46
from flightdeck.ledger import Ledger
57
from flightdeck.metrics import build_report
68
from flightdeck.store import Store
9+
from tests.conftest import write_org
710

811

912
def test_demo_seeds_a_full_believable_program(tmp_path):
@@ -38,6 +41,44 @@ def test_demo_is_deterministic_for_a_given_day(tmp_path):
3841
)
3942

4043

44+
def test_demo_refuses_to_seed_over_a_real_org(tmp_path):
45+
# Pointing --dir at a REAL org must refuse loudly and touch nothing: seeding
46+
# overwrites the org files and deletes workflows/, the store and the ledger.
47+
root = write_org(tmp_path / "org") # TestCo, with its own workflows
48+
workflow = root / "workflows" / "support-reply.yaml"
49+
before = workflow.read_text(encoding="utf-8")
50+
51+
with pytest.raises(DemoSeedError, match="refusing to seed"):
52+
seed(root)
53+
54+
assert workflow.read_text(encoding="utf-8") == before # nothing deleted or rewritten
55+
assert "TestCo" in (root / "flightdeck.yaml").read_text(encoding="utf-8")
56+
57+
58+
def test_demo_refuses_a_nonempty_non_org_directory(tmp_path):
59+
# No flightdeck.yaml at all (say, a project root): still refuse — seeding
60+
# would delete an unrelated workflows/ directory without warning.
61+
target = tmp_path / "project"
62+
(target / "workflows").mkdir(parents=True)
63+
(target / "workflows" / "deploy.yaml").write_text("keep me", encoding="utf-8")
64+
65+
with pytest.raises(DemoSeedError):
66+
seed(target)
67+
68+
assert (target / "workflows" / "deploy.yaml").read_text(encoding="utf-8") == "keep me"
69+
70+
71+
def test_demo_reseeds_its_own_directory(tmp_path):
72+
# A previous demo (recognized by the demo org name) refreshes in place — the
73+
# dir legitimately accumulates dashboards and runtime state between runs.
74+
target = tmp_path / "demo"
75+
seed(target)
76+
(target / "dashboard.html").write_text("<html>", encoding="utf-8")
77+
78+
summary = seed(target)
79+
assert summary.runs_completed > 800
80+
81+
4182
def test_backlog_ranking_orders_by_score(tmp_path):
4283
org = load_org(seed(tmp_path / "demo").root)
4384
scored = ranked(org)

‎tests/test_cli_and_html.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,15 @@ def test_init_refuses_to_overwrite(tmp_path):
4343
assert result.exit_code == 2
4444

4545

46+
def test_demo_refuses_a_real_org_with_exit_2(tmp_path):
47+
root = _init(tmp_path)
48+
result = invoke("demo", "--dir", str(root))
49+
assert result.exit_code == 2
50+
assert "refusing to seed" in result.output
51+
org = load_org(root) # the org still loads: nothing was overwritten
52+
assert "meeting-minutes" in org.workflows
53+
54+
4655
def test_run_feedback_report_loop_offline(tmp_path):
4756
root = _init(tmp_path)
4857

0 commit comments

Comments
 (0)