Funnel demo — design + guide (E1)
LAW 2 doc for the funnel demo feature: a one-command way to fill an empty funnel with a realistic, fake idea→plan DAG and matching on-disk git repos, plus a precise, disk-only nuke. Source:
src/funnel/demo.rs, CLInornir funnel demo|nuke, viz Funnel-tab demo panel.
Why
The funnel (DAG 2) is event-sourced and usually empty in a fresh workspace, so the viz Funnel tab and the nornir funnel CLI have nothing to show and nothing to stress. E1 gives both a deterministic demo generator so a reviewer can, in one command/click, see a real layered DAG render and exercise the single-writer concurrency model — and a safe nuke that cleans up the disk artefacts without ever touching the durable funnel history.
Two mirrored halves
on disk (git/) in warehouse (funnel_events)
├─ funnel_fake_1 ├─ node n-1 (repo 1)
│ Cargo.toml + src/lib.rs + .git ├─ node n-2 (repo 2)
├─ funnel_fake_2 (path-dep → fake_1) └─ node n-3 (repo 3)
├─ funnel_fake_3 (path-dep → fake_1, edges: n-1 → n-2 → n-3 ; n-1 → n-3
│ fake_2)
└─ .nornir-funnel-demo.json (manifest)
edges: fake_1 → fake_2 → fake_3
on disk ⋯⋯ mirrors ⋯⋯▶ warehouse
-
On-disk fake repos (
run_demo):--size Ntiny but real git repos near thegit/root. Each is aCargo.toml(path-deps on every lower-indexed sibling → a layered, acyclic dep graph), asrc/lib.rs, and a realgit init+ add + commit (idempotent, best-effort via the systemgit). A manifestgit/.nornir-funnel-demo.jsonlists exactly what was created. -
In-warehouse funnel DAG (
inject_repo_dag): one idea taggedsource = "funnel:demo"+ one Active plan whose nodes are the fake repos and whose edges are the path-deps (node j → node ifor eachj < i). Mirrors the on-disk graph, so the viz Funnel tab renders the same DAG the repos form.
Layout
--size |
container | repos |
1 |
git/funnel_fake/ |
git/funnel_fake/funnel1_fake_project/ |
N>1 |
git/funnel{N}_fake_projects/ |
git/funnel{N}_fake_projects/funnel_fake_{i}/ (i=1..N) |
git-root resolution
resolve_git_root(funnel_root) reuses the warehouse ../git/ sibling convention (the timeline already looks up ../git/<member>): from a funnel root shaped <ws>/.nornir/warehouse it walks to <ws>, then takes the sibling <ws>/../git. Fallback: <workspace_root>/../git.
Determinism
build_dep_graph_spec(plans, nodes_per_plan, layers, seed) builds a layered acyclic DAG with a dependency-free splitmix64 PRNG. Edges only ever go lower-layer → higher-layer, so the graph is acyclic by construction and the expected node/edge/ready set is computable in tests (golden, data-only). Same seed → identical DAG. inject_demo(store, size) is the test-friendly wrapper (1 plan, size*3 nodes, 3 layers, seed 0xF1).
Idempotency
Both injectors key off the funnel:demo source tag: if a demo idea already exists, the warehouse half is a no-op (no duplicate plan). The disk half overwrites files in place and leaves an existing .git alone, so reruns are safe.
Nuke is disk-only and manifest-safe
nuke_disk(funnel_root) reads git/.nornir-funnel-demo.json and deletes exactly the repos + (empty) container + the manifest — never a heuristic rm over a name glob. A manually-created dir matching the naming scheme is left alone unless the manifest lists it. The warehouse funnel data is never touched: after a nuke the plan still appears in nornir funnel show and the viz Funnel tab (the event log is append-only history).
CLI
nornir funnel demo --size 3 # 3 fake repos + injected funnel DAG
nornir funnel show # lists the demo plan
nornir funnel nuke # prints what it WOULD delete (no --yes → refuses)
nornir funnel nuke --yes # deletes the fake repos on disk; funnel data kept
demo opens its own store inside run_demo (it must create repos first, then inject); nuke never opens the store (disk-only). Both are handled before the shared store-open in run_funnel, so they don't collide on the redb lock.
Viz (Funnel tab)
A demo control strip in the top controls (local mode only; remote → disabled with a tooltip, since the warehouse lives on the server):
🌱 Run demo+ asizeDragValue→funnel::demo::run_demo, then the tab re-reads the warehouse and the DAG renders.🗑 Nuke diskwith a two-click confirm guard: first click arms a red⚠ Confirm nuke (disk only)button (+✖ cancel); the second click callsnuke_disk. A persistent caption reads "Deletes fake repos on disk only — warehouse funnel data is kept."- Outcomes are pushed to the ActionLog (
🐞trail) and surfaced instate_json.
LAW 6 — readable state
FunnelTabState::state_json() emits the selected plan, per-plan nodes/edges/ready_count, and a demo block (available, size, confirm_pending, status). It is wired into app.rs::state_json() under a new "funnel" key — the Funnel tab contributed nothing to the headless state dump before E1.
Concurrency model (the primary E1 deliverable)
The funnel is single-writer: IcebergWarehouse::open() takes redb's exclusive lock per <root>/catalog.redb, so exactly one writable Store exists per root/process. tests/funnel_concurrency.rs models this faithfully with a shared Arc<Mutex<Store>> hammered by N=16 worker threads (add nodes/edges, flip statuses, topo_ready) across sizes small → very-large (~1500 nodes), plus reader threads taking lock-tolerant open_read_only snapshots. After the storm it reopens from disk and asserts:
- no lost updates — node/edge counts == initial + committed delta;
- final status map matches the recorded sequence (events ordered by a strictly-increasing microsecond ts — the log persists ts at µs resolution, so each racing event gets a distinct µs and last-writer-wins is well-defined);
- frontier correctness — every Ready node has all deps Done, no Pending node has all deps Done (promote complete);
- reader snapshots always replay valid + node count is monotonic;
- a wall-clock watchdog fails the test on deadlock.
Tests
| test | asserts |
tests/funnel_demo.rs |
inject → reopen → exact golden nodes/edges/ready set; idempotency |
tests/funnel_concurrency.rs |
the 5 concurrency invariants above (small/medium; --ignored very-large) |
tests/funnel_viz.rs |
state_json["funnel"]["plans"] after demo; confirm-guard arms without deleting; nuke removes disk repos but plans persist |
src/funnel/demo.rs unit tests |
spec is layer-monotone, deterministic, always has a root |