Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/nornir

.nornir/funnel-demo.md

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, CLI nornir 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
  1. On-disk fake repos (run_demo): --size N tiny but real git repos near the git/ root. Each is a Cargo.toml (path-deps on every lower-indexed sibling → a layered, acyclic dep graph), a src/lib.rs, and a real git init + add + commit (idempotent, best-effort via the system git). A manifest git/.nornir-funnel-demo.json lists exactly what was created.

  2. In-warehouse funnel DAG (inject_repo_dag): one idea tagged source = "funnel:demo" + one Active plan whose nodes are the fake repos and whose edges are the path-deps (node j → node i for each j < 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):

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:

  1. no lost updates — node/edge counts == initial + committed delta;
  2. 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);
  3. frontier correctness — every Ready node has all deps Done, no Pending node has all deps Done (promote complete);
  4. reader snapshots always replay valid + node count is monotonic;
  5. 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