Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ee61830
Add design spec for the interactive lessons surface
nonrational Jul 11, 2026
37244c1
Add implementation plan for the interactive lessons surface
nonrational Jul 11, 2026
3fbc3f9
Add inline markdown renderer for the lessons generator
nonrational Jul 11, 2026
026cdfc
Fix renderInline: ASCII NUL sentinel and code-in-link-label handling
nonrational Jul 11, 2026
078273d
Add block markdown renderer for the lessons generator
nonrational Jul 11, 2026
19160b5
Fix renderMarkdown list handling: wrapped items and nested siblings
nonrational Jul 11, 2026
086a9a5
Add lesson parser for the fixed lesson template
nonrational Jul 11, 2026
1be64fb
Add glossary loading, validation, index, coverage, and term annotation
nonrational Jul 11, 2026
d22a4d0
Add lesson page template and themed shared assets
nonrational Jul 11, 2026
4eb9e88
Add browser-side drawer interaction for term annotations
nonrational Jul 11, 2026
fff4e41
Escape double quotes in the drawer's esc() helper
nonrational Jul 11, 2026
90bc5c1
Add index page generation from the lessons README
nonrational Jul 11, 2026
2b9e314
Unescape table-cell pipe escapes in README index takeaways
nonrational Jul 11, 2026
3768817
Wire generator orchestration, --check gate, justfile recipes, and gen…
nonrational Jul 11, 2026
3ebd523
Author the full glossary and fix the drawer scrim pointer-events
nonrational Jul 12, 2026
a091009
Add the Rust lessons source series (one file per merged PR)
nonrational Jul 12, 2026
c48af1e
Add design spec for the lessons-from-prs authoring skill
nonrational Jul 13, 2026
48995d5
Capture the lessons-generator landmines for later pickup
nonrational Jul 13, 2026
8b9ab49
Fix NUL bytes in the landmines doc (it fell into landmine #1)
nonrational Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions docs/lessons/16-supervised-app-with-logs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<!-- Lesson for PR #16. Teaches one concept grounded in the real diff. -->

# PR #16 — Tracer: supervised app with logs

> **Rust lesson:** To mutate state from an async task that outlives the function that spawned it, hand the task an `Arc<Mutex<T>>` clone — `Arc` gives shared ownership across threads, the `Mutex` makes the mutation safe.
> **Tags:** `Arc<Mutex<T>>` · `tokio::spawn`
> **Merged:** 2026-06-08 · +2369/−0 · [View PR](https://github.com/nonrational/adjacent/pull/16)

## The situation

This is the tracer-bullet PR that stood the project up: register a dev-server directory, boot it, capture its logs, and report whether it's running, stopped, or crashed. The core is a `Supervisor` that spawns a child process and then needs to know, later and asynchronously, when that child exits — and update its recorded state accordingly. The catch: the code that spawns the child returns immediately, but the exit can happen minutes later. Two different tasks end up touching the same state, and Rust will not let that happen by accident.

## The Rust idea

In a garbage-collected language you would capture `this` in a callback and mutate a field whenever the process exits. Nobody checks who else is touching that field at the same time.

Rust makes the sharing explicit, for two reasons:

1. **Lifetimes.** A task handed to `tokio::spawn` must be `'static` — it may outlive the function that created it, so it cannot borrow anything on that function's stack (including `&self`). It has to *own* what it uses.
2. **Threads.** Tokio's default runtime is multi-threaded, so the spawned task may run on a different worker thread than the one that spawned it. Anything it captures must be safe to send and share across threads (`Send + Sync`).

`Arc<Mutex<T>>` is the idiomatic answer to both:

- `Arc<T>` — an **a**tomically **r**eference-**c**ounted pointer. Cloning it doesn't copy the data; it bumps a refcount and hands back a second owner of the *same* `T`. Both owners are `'static`, so both can go into spawned tasks.
- `Mutex<T>` — guards the interior so only one task mutates at a time. `tokio::sync::Mutex::lock().await` *yields* the worker thread while waiting instead of blocking it, which is what you want inside async code.

The single-threaded shortcut `Rc<RefCell<T>>` won't compile here: `Rc` isn't `Send`, so `tokio::spawn` rejects it.

## In this PR

The supervisor holds all app state behind one shared, locked handle:

```rust
// crates/adj/src/supervisor.rs
#[derive(Default)]
pub struct Supervisor {
inner: Arc<Mutex<Inner>>,
}

#[derive(Default)]
struct Inner {
apps: HashMap<String, AppRuntime>,
}
```

`up()` spawns the child, records it as `Running`, then detaches a task to wait for the exit:

```rust
// crates/adj/src/supervisor.rs
pub async fn up(&self, app_dir: PathBuf, cfg: AppConfig) -> Result<u32> {
let name = cfg.name.clone();
let mut inner = self.inner.lock().await;
// ...
let mut child = command
.spawn()
.with_context(|| format!("spawning `{}`", cfg.cmd))?;

let pid = child.id().ok_or_else(|| anyhow!("spawned child has no pid"))?;

inner.apps.insert(
name.clone(),
AppRuntime { state: AppState::Running { pid }, intentional_stop: false },
);

// Detach the wait task so the supervisor can observe exit/crash without holding the lock.
let inner_handle = self.inner.clone();
let watch_name = name.clone();
tokio::spawn(async move {
let status = child.wait().await;
let mut guard = inner_handle.lock().await;
if let Some(rt) = guard.apps.get_mut(&watch_name) {
// ... record Stopped, or Crashed { exit_code }
}
});

Ok(pid)
}
```

The load-bearing lines are the two just before `tokio::spawn`:

- `let inner_handle = self.inner.clone();` clones the **`Arc`**, not the `Inner`. The new handle points at the same `Mutex<Inner>` the `Supervisor` still owns.
- `async move { ... }` moves `inner_handle`, `watch_name`, and `child` *into* the task. Now the task owns everything it touches — no borrow of `self`, so the `'static` requirement is satisfied.

When the child eventually exits, the task takes the lock (`inner_handle.lock().await`) and flips the app's `state` from `Running` to `Stopped` or `Crashed`. Meanwhile `state()`, `down()`, and the next `up()` all reach the same `HashMap` through the same `Mutex` — one writer at a time, no data race, enforced at compile time.

## Why it matters

Without `Arc`, you can't share the state at all: the spawned task can't borrow `self`, and moving `self.inner` into it would leave the `Supervisor` with nothing. Without the `Mutex`, two tasks writing the same `HashMap` is a data race — which in most languages is a heisenbug you find in production, and in Rust is a compile error you fix before lunch. The pattern is verbose on purpose: it forces you to name, up front, exactly which state is shared and how it's synchronized.

## Related lessons

- Later PRs reuse this exact `Arc<Mutex<...>>` foundation for trickier concurrency — the proxy's lazy-boot single-flight and the idle scanner's re-check-under-lock both build on the supervisor's `Inner` mutex. Those PRs are the place to teach the *variations* (single-flight, closing the request-vs-scanner race), not the base pattern taught here.

## Dig deeper

- [The Rust Book, ch. 16.3](https://doc.rust-lang.org/book/ch16-03-shared-state.html) — Shared-State Concurrency (`Mutex<T>` and `Arc<T>`)
87 changes: 87 additions & 0 deletions docs/lessons/17-inject-port-into-child-env.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<!-- Lesson for PR #17. Teaches one concept grounded in the real diff. -->

# PR #17 — Inject PORT into supervised processes

> **Rust lesson:** `tokio::process::Command` is a builder that inherits the parent process's environment by default; each `&mut self` method chains, and `.env(key, value)` layers one variable on top before `.spawn()` runs anything.
> **Tags:** `tokio::process::Command` · `builder-pattern` · `env-vars`
> **Merged:** 2026-06-08 · +322/−12 · [View PR](https://github.com/nonrational/adjacent/pull/17)

## The situation

A supervised dev server has to bind *somewhere*, and the daemon — not the app — picks the port (it allocates a free one via a `:0` bind probe). So the daemon must hand that number to the child before it starts. The classic Unix mechanism is an environment variable: inject `PORT=54321` into the child's environment, and the app reads `$PORT` and binds it. Some apps insist on a different name, so `adjacent.toml` gains a `port_env` knob to rename the injected variable.

## The Rust idea

Rust has no keyword arguments, no default parameters, and no function overloading. When a thing has many optional knobs and one final "go" step — spawning a process is the textbook case — the idiomatic shape is the **builder pattern**.

`Command::new("sh")` returns a mutable `Command`. Every configuration method (`.arg`, `.current_dir`, `.env`, `.stdout`, `.process_group`) takes `&mut self` and returns `&mut Self`. Returning the same mutable borrow is what lets calls chain, and because nothing is *moved*, you can also configure across several statements on the same binding. Nothing actually runs until a terminal method — here `.spawn()`.

The environment piece has one detail that trips people up: **a freshly built `Command` inherits the parent process's entire environment.** You start with a full copy of whatever the daemon itself was launched with. The env methods then edit that inherited set:

- `.env(key, value)` — insert or **override** one entry.
- `.envs(iter)` — the same, for many at once.
- `.env_remove(key)` — drop one inherited entry.
- `.env_clear()` — throw the whole inheritance away and start empty.

"Inherit, then override" is a precedence decision baked into the default: a later `.env` wins over whatever the child would have inherited under the same name.

## In this PR

The core is a handful of chained builder calls in `up()`. The port name is resolved first, then layered onto the command:

```rust
// crates/adj/src/supervisor.rs
let port_env = cfg.port_env.as_deref().unwrap_or("PORT");
let mut command = Command::new("sh");
command
.arg("-c")
.arg(&cfg.cmd)
.current_dir(&app_dir)
.env(port_env, port.to_string())
.stdin(Stdio::null())
.stdout(Stdio::from(log_file))
.stderr(Stdio::from(stderr_file));
```

- `cfg.port_env.as_deref().unwrap_or("PORT")` collapses `Option<String>` into a plain `&str`: `as_deref()` borrows the inner `String` as `&str` if present, and `unwrap_or("PORT")` supplies the default when it's `None`. One expression, one default, no branch.
- `.env(port_env, port.to_string())` layers exactly one variable onto the inherited environment. The value has to be an *owned* `String` (`port` is a `u16`), because `.env` keeps the bytes for the eventual spawn — a borrowed slice tied to this stack frame wouldn't outlive it.

The config field that feeds `port_env` is optional by construction:

```rust
// crates/adj/src/registry.rs
/// Override the env var name used to inject the assigned port.
/// When unset, Adjacent exports `PORT`. When set, it exports the named variable instead.
#[serde(default)]
pub port_env: Option<String>,
```

`#[serde(default)]` means an absent key deserializes to `None` instead of erroring — that's what makes `port_env` opt-in.

The load-bearing proof that inheritance is the default lives in the test harness:

```rust
// crates/adj/tests/tracer.rs
// Scrub port-related env vars from the parent shell so the daemon (and any child it
// spawns) starts from a known-clean slate. Without this, `PORT=3000 cargo test` would
// pass `PORT` through to the supervised child and silently break the rename test.
c.env_remove("PORT");
c.env_remove("BIND_PORT");
```

Read that comment as a demonstration of the semantics: because `Command` copies the parent's environment and `.env` only touches the keys you name, a stray `PORT` in the test runner's own environment flows straight through to the child. The `rename` test asserts the child sees `BIND_PORT` set and `PORT` *unset* — which is only true if the parent isn't leaking a `PORT` of its own.

## Why it matters

In a language where you mutate a shared, global `process.env`, injecting a variable is order-dependent and easy to leak between spawns. The `Command` builder gives you an isolated, per-child edit list assembled on a `&mut Command`, so the process can't be spawned half-configured — there's no window where the child exists but `PORT` hasn't been set yet, because `.spawn()` is the last thing you call.

The trap the builder's default hides is the inheritance itself. It's convenient (the child gets `PATH`, `HOME`, and the rest for free) but it's also a silent channel: if the daemon happened to run with `PORT` in its own environment, every child would inherit it, and injection would be masking rather than setting. That's exactly why the test scrubs `PORT`/`BIND_PORT` before starting — not paranoia, but a direct consequence of "inherit, then override" being the default you signed up for.

## Related lessons

- PR #16 stands up the `Supervisor` and the `up()` method this `.env()` call lives inside — the `Arc<Mutex<Inner>>` state and the wait-for-exit task are the foundation this port injection slots into.
- PR #76 generalizes this single-variable mechanic into a whole daemon-owned `ADJ_*` namespace (`ADJ_NAME`, `ADJ_HOST`, `ADJ_URL`, …) injected *after* `env_file` and `[env]` so the daemon's values win — that's where the full env-layering-and-precedence story gets taught. #17 is the one-variable version; #76 is the layered namespace.

## Dig deeper

- [`std::process::Command`](https://doc.rust-lang.org/std/process/struct.Command.html) — tokio's `Command` mirrors this API. The docs for `.env`, `.envs`, `.env_remove`, and `.env_clear` spell out the inherit-then-edit model, and the struct-level intro is a clean example of the builder pattern.
101 changes: 101 additions & 0 deletions docs/lessons/19-optional-fields-with-serde.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<!-- Lesson for PR #19. Teaches one concept grounded in the real diff. -->

# PR #19 — Env loading: [env] table and env_file pointer

> **Rust lesson:** `Option<T>` is how a struct field says "this may be absent" — and serde's derive fills a missing `Option` field with `None` for you, while `#[serde(default)]` is what saves any *non*-`Option` field from erroring when its key is missing.
> **Tags:** `option` · `serde` · `deserialize` · `config`
> **Merged:** 2026-06-08 · +509/−1 · [View PR](https://github.com/nonrational/adjacent/pull/19)

## The situation

`adjacent.toml` grew two new, entirely optional fields: an `[env]` table of committed-safe `KEY = "value"` pairs, and `env_file`, a pointer to a dotenv file. Most apps set neither. The config struct has to parse a TOML file where these keys are usually just *not there* — and it must not treat "absent" as an error. That is a modeling question before it is a parsing one: how does a Rust struct field say "maybe nothing"?

## The Rust idea

Rust has no `null`. A `String` field always holds a real string; there is no back-door "empty" value that means "unset." When absence is a legitimate state, you say so in the type with `Option<T>` — an enum with exactly two variants:

```rust
enum Option<T> {
None, // absent
Some(T), // present, carrying a T
}
```

The payoff is that the compiler forces every reader to handle both cases before they can touch the inner `T`. "I forgot the field might be missing" stops being a runtime surprise and becomes a compile error.

serde bridges this to the data on disk. When you `#[derive(Deserialize)]`, serde generates code that walks the TOML and fills each struct field. Its default stance is strict: **a field whose key is missing from the input is an error** ("missing field `port`"). That is usually what you want — a required field that silently vanished is a bug.

Two things soften that strictness, and they are easy to conflate:

- `#[serde(default)]` — "if this key is missing, use the type's `Default` instead of erroring." This is what makes a field genuinely optional at the data level.
- `Option<T>` — a *special case* serde already knows about. A missing `Option<T>` field deserializes to `None` on its own, without `#[serde(default)]`, because serde special-cases the "no value" path for options.

So for the `Option` fields in this PR, the `#[serde(default)]` attribute is technically redundant — they would parse to `None` regardless. It is kept for consistency with the existing `port_env` field above them, and because the intent reads clearly at a glance. Drop `Option` for a required-but-defaulted field (say `port: u64` defaulting to `0`) and the attribute stops being optional itself: without it, a missing `port` is a hard error.

## In this PR

The whole change to the config type is four lines of field declarations:

```rust
// crates/adj/src/registry.rs
pub struct AppConfig {
pub name: String,
pub cmd: String,
#[serde(default)]
pub port_env: Option<String>,
/// Committed-safe environment variables merged into the spawned process env.
/// On conflict with `env_file`, this table wins. PORT injection always wins over both.
#[serde(default)]
pub env: Option<BTreeMap<String, String>>,
/// Path to a dotenv-format file, resolved relative to the registered app directory.
/// Missing files are a startup error.
#[serde(default)]
pub env_file: Option<String>,
}
```

`name` and `cmd` are bare `String` — required, and a manifest missing either one is rightly rejected. The two new fields are `Option`, so `None` is the normal, no-error resting state.

Notice `env` is `Option<BTreeMap<String, String>>`. A TOML `[env]` table maps straight onto a map type, and serde recurses: it deserializes each `KEY = "value"` entry into a `(String, String)` pair. `BTreeMap` (not `HashMap`) keeps the keys sorted, so iterating it later to set env vars is deterministic — the same manifest always applies its overrides in the same order.

The consumer side then reads these as ordinary `Option`s, and the `if let Some(...)` is the compiler-enforced "handle both cases":

```rust
// crates/adj/src/supervisor.rs
// Resolve env layers before any port allocation so a missing `env_file` or unreadable
// file fails fast with a clear error (and doesn't leak a port reservation).
let env_file_values = if let Some(rel) = cfg.env_file.as_deref() {
let resolved = app_dir.join(rel);
Some(load_env_file(&resolved)?)
} else {
None
};
```

```rust
// crates/adj/src/supervisor.rs
if let Some(values) = &cfg.env {
for (k, v) in values {
command.env(k, v);
}
}
command.env(port_env, port.to_string());
```

`None` means "no `[env]` table, do nothing"; `Some(values)` unwraps the map so you can iterate it. There is no way to iterate the map without first proving it exists — that is `Option` doing its job.

One honest footnote: `env_file` points at a *dotenv* file, which is not TOML, so serde doesn't parse its contents. The PR hand-rolls a tiny `parse_dotenv` in `crates/adj/src/env.rs`. serde's reach ends at the `adjacent.toml` boundary; the file it points to is parsed by hand.

## Why it matters

In a language with `null`, "this field is optional" and "I forgot to set this field" look identical at the type level — both are `null` — and the difference only shows up as a `NullPointerException` at 2am. Rust splits them: absence is `Option`, and the compiler won't let you read through it without checking. Pair that with serde's default strictness and you get a config loader where *required* fields are enforced for free (a missing `cmd` is rejected at parse time) and *optional* fields are spelled out deliberately, one `Option` at a time. The trap this avoids is the silent empty-string or zero-value that a laxer parser would hand you for a field the user never wrote.

## Related lessons

- PR #48 stays in this same `AppConfig` neighborhood but on the `Result` side — not swallowing a parse `Err` when a *present* field is malformed. #19 is about a field being absent; #48 is about a field being present-but-wrong.
- PR #22 covers the mirror-image direction, serde `Serialize` for the JSON DTOs; PR #75 covers *writing* an `adjacent.toml`. This lesson stays strictly on the `Deserialize` + `Option` reading side.

## Dig deeper

- [The Rust Book, ch. 6.1](https://doc.rust-lang.org/book/ch06-01-defining-an-enum.html#the-option-enum-and-its-advantages-over-null-values) — the `Option` enum and its advantages over null.
- [serde: field attributes](https://serde.rs/field-attrs.html) — `#[serde(default)]` and how missing fields are handled.
Loading