--- id: local-dev title: The whole stack runs on one machine, with traffic that looks real status: continuous crates: [didbot-swarm, didbot-serve] dependsOn: [] exitCriterion: > None. A development workflow is never finished; it is worked whenever something adjacent to it is open. --- # local-dev A terminal per script, each finding the others by default, and a swarm that drives the stack with simulated agents. This is what makes every other epic workable without a deployment. - [ ] **A runner for [scripts/ci.sh](../scripts/ci.sh).** `.tangled/workflows/ci.yml` runs it on a push to `main` and on each pull request round. Tangled's CI is spindle, and a spindle is self-hosted: one has to be attached to this repository from its settings before the workflow runs at all. - [ ] **Keep [docs/running-locally.md](../docs/running-locally.md) true.** - [ ] **Say where to run it, and what each posture costs.** Server and agents on one laptop is the development posture and the weakest: one compromise takes the keys, the zone credential and the operator's own logins. Server in the cloud with agents on a laptop moves the keys off the machine running capable code. Agents in cloud sandboxes as well makes browser isolation moot and unlocks instance-identity attestation. ## The inner loop, measured A one-crate incremental rebuild of the development server is about two seconds. A full `dev-pds.sh` cycle after a change to `didbot-pds` is about fourteen, of which four is running that half's tests and the rest is compiling and linking. So the compile is not where the time goes. What the build costs is *disk*, and that is what fills a machine: see the dev profile under Done. ## Done - [x] **The development server keeps its accounts by default.** It ran with no `--data` directory, so every restart took the accounts of whatever was talking to it: an agent that signed in this morning was signed in to a server that no longer knew it, and the symptom arrived far away from the one banner line that had said so at boot. `scripts/dev-pds.sh` now defaults to `$XDG_STATE_HOME/didbot/pds`, beside the configuration and the corpus this project already keeps under those directories. Per user rather than per checkout. The agents on a machine address one server on one port, and a second checkout on that port is refused by `pds.lock` before it can write — which is the existing single-writer refusal doing the job it was built for, not a new one. `DIDBOT_PDS_DATA=` is how a run asks for a clean slate, which is why `scripts/dev-profile.sh` spells that one default `${NAME-value}` rather than `${NAME:-value}`: the second treats an emptied variable as an unset one and hands the default straight back. **Correction, not new work:** three items here were ticked against code that has left this repository — `didbot-replay`'s firehose capture and playback (no such binary, crate or script exists now), a canvas mock, and the index/query/web dev scripts. The index, the query service and the canvas moved to vibescrobble.com; the replay tool went with them. They are unticked rather than left standing. The surviving scripts are described accurately below. - [x] **The Merkle search tree corpus is cached per machine, not per checkout.** It is 72MB of immutable upstream test data that varies with nothing, and it used to live in each checkout's own `.cache/`: this machine carried two copies within a day of the suite landing, and every new worktree failed four tests until somebody fetched one. It now sits at `$XDG_CACHE_HOME/didbot/mst-test-suite`, beside the configuration and state this project already keeps under the same directories, and a clone left in a checkout is moved rather than re-downloaded. One clone shared by every checkout has a cost worth naming: a plain run that fast-forwarded it would move the corpus under whatever else is reading it. So a corpus already there is left alone and reported, and `--update` is what changes it. - [x] **A test that failed about once in twelve suite runs, found and fixed.** `a_proof_missing_a_node_does_not_verify` asserted that a record's path through the Merkle search tree was more than one node deep. A record key is a TID and a tree's shape comes from the hashes of its keys, so that depth varies run to run: it held nineteen times in twenty and failed the whole suite the other time. Nothing in the test needed the depth. It was measured at two failures in twenty-five runs before, and none in thirty after. It took three sightings to catch, because the first two were seen through a `cargo test` whose output had been sent to `/dev/null` — which is its own lesson about checking exit statuses without keeping what produced them. - [x] **Watch modes, and the one component that refuses.** `--watch` on any `dev-*.sh` reruns the whole script — rebuild, that half's tests, banner and all — whenever anything under `crates/` moves. It reruns the script rather than restarting the binary because the tests are the step most likely to have something to say about the change that triggered them. The personal data server watches too, now that it keeps its state by default. It refuses under `DIDBOT_PDS_DATA=`: the others hold nothing and a restart costs the build, while a server in memory rebuilds every account, record and name hold on every save, so a watch there factory resets the deployment and the first symptom is sessions whose accounts have silently stopped existing. It says so and names the two ways to mean it. Polling rather than `inotifywait`, which is not on every machine this runs on, and a second of latency against a rebuild that takes fourteen is not worth a dependency that has to be installed first. Two things had to be got right and were not, first time. The stop has to tolerate its own signal: these scripts run under `set -e`, and a `wait` on a process the loop just killed reports the signal, which took the watcher down with the thing it was restarting — a watcher that worked exactly once. And the pidfile's cleanup trap was dead code from the day it was written, because `exec` replaces the shell and takes the trap with it; a stale pidfile is harmless and is now documented as such rather than pretended about. - [x] **A `--data` directory says what wrote it.** Serde fills a defaulted field and ignores an unknown one, so a log entry can parse and mean something else, and the deployment starts up looking healthy. A `pds.layout` stamp is checked before the log is read, and a directory this binary would misread is refused by name. A build reads the layout it writes and the one before it, through that layout's reader rather than a converter — see [deploy](deploy.md) — and refuses anything older. In development the answer to a refusal is to delete the directory. The stamp is only worth something if somebody bumps it, so the durability suite pins the on-disk shape against the number — the variants read out of the source, and the fields read off a log the test writes and decodes by hand rather than with the crate's own reader. A field rename fails it with the instruction to bump. - [x] **A dev profile that stops generating what nobody reads.** Debug information was most of what a build produced and almost none of it was used: nothing here is debugged by stepping, and a backtrace needs only line tables. Measured by building the development server from cold into a target directory of its own under each setting, so neither answer was polluted by the other's artefacts — 1.1 GB to 827 MB of target, 106 MB to 43 MB of binary, 59.6 s to 47.2 s of build. The workspace gains more than that share, because the facade crate's test binaries link every crate in it and there is one per suite. The saving arrives on the next `cargo clean`. Cargo keeps artefacts it has superseded, so a directory that has been built in for a while holds both shapes and briefly grows; the worktree this was written in had reached 31 GB and rebuilt from clean, whole workspace and every test binary, into 3.4 GB. - [x] `dev-pds.sh` stops a previous run by pidfile rather than by command-line pattern. `pkill -f "didbot-pds --port ${PORT}"` matched anything whose arguments contained that string, including another developer's server and the shell running the script — which happened while this was being written. `scripts/dev-pidfile.sh` reads `/proc//exe` before signalling, so a reused pid is never a licence to kill whatever inherited the number. - [x] Each `dev-*.sh` sources `scripts/dev-profile.sh`, the one place the port, the zone and the state directory are written down, and a variable already in the environment still wins. - [x] One command running the same checks as the commit hooks: [scripts/ci.sh](../scripts/ci.sh) runs prek over `prek.toml` rather than restating it, adds the commit-msg hook over the whole branch, and adds the test suite the hooks are too slow to run. A commit made with `--no-verify` is checked by it. - [x] Dev scripts that find each other by default: `dev-pds.sh`, `dev-swarm.sh` and `dev-site.sh`, over the shared `dev-profile.sh`/`dev-pidfile.sh`/`dev-watch.sh` helpers. The index, query and web scripts live with their crates in vibescrobble.com. - [x] A swarm that drives the stack with simulated agents and records. - [x] The swarm signs agents in over OAuth (PAR, decision record, approve, token exchange) rather than only using the raw credential `createAccount` hands back, so `crates/didbot-swarm/tests/decision_bounds.rs` can drive the decision store's per-account and total bounds under concurrent load. - [x] The swarm's sign-in presents real RFC 9449 proofs over a key it generates per agent, so `dev-swarm.sh --sign-in` signs agents in against `dev-pds.sh` and writes their records with the tokens that yields. - [x] `--data`, so a development server keeps its state across a restart. - [x] Commit hooks: format, clippy, doc build, doc lint, lexicon tests, plan shape and register, and Conventional Commits. - [x] The crate scaffold, the namespace constant and the embedded lexicon loader. - [x] The Claude Code hook payload types.