Leaf Agent Architecture
The leaf agent is a Go daemon in its own module (leaf/) that opens a Fossil repo via libfossil, connects to NATS, and syncs artifacts on a poll loop. It acts as both sync client and server.
Lifecycle: New(Config) -> Start() -> Stop(). A single poll goroutine is the sole executor of doSync(). SyncNow() is a non-blocking signal (buffered channel, size 1) that coalesces if a sync is already running. This avoids concurrent SQLite access.
Signals: SIGUSR1 triggers SyncNow(). SIGINT/SIGTERM trigger graceful Stop() (cancel context, drain NATS, close repo).
| Config Field | Default | CLI Flag | Env Var |
|---|---|---|---|
RepoPath |
(required) | --repo |
LEAF_REPO |
NATSRole |
"peer" |
--nats-role |
|
NATSUpstream |
"" (embedded only) |
--nats |
LEAF_NATS_URL |
NATSClientPort |
0 (random) |
--nats-client-port |
LEAF_NATS_CLIENT_PORT |
PollInterval |
5s |
--poll |
|
User |
"" (anonymous) |
--user |
|
Password |
"" |
--password |
|
Push |
true |
--push / --no-push |
|
Pull |
true |
--pull / --no-pull |
|
ServeHTTPAddr |
"" (disabled) |
--serve-http |
|
ServeNATSEnabled |
false |
--serve-nats |
|
UV |
false |
--uv |
|
SubjectPrefix |
"fossil" |
--prefix |
Embedded NATS: Every agent runs an in-process NATS server. The agent's NATS client connects to localhost. If NATSUpstream is set, the embedded server joins external NATS as a leaf node. If iroh peers are configured, the embedded server connects to them via tunnels over QUIC.
NATSRole determines mesh topology:
| Role | Accepts leaf connections | Solicits leaf connections | Use case |
|---|---|---|---|
peer (default) |
Yes | Yes (lower EndpointId solicits) | Native P2P mesh |
hub |
Yes | No | Dedicated server, cluster node |
leaf |
No | Yes (always solicits outward) | WASM browser, lightweight client |
Hub Package
The hub package (hub/, in the root module) is an in-process host for an EdgeSync hub: fossil repo bootstrap, embedded NATS server with JetStream, auto-port allocation, and the libfossil HTTP server. Consumers (notably bones) use it to host a hub without rolling their own libfossil + nats-server bootstrap; everything is libfossil-free at the public API boundary. Leaf agents on the same host (or another host) sync to it via Hub.HTTPAddr() (HTTP-xfer) or Hub.LeafUpstream() (NATS leaf node). See hub-package.md.
Bridge Architecture
The bridge is a NATS-to-HTTP translator in its own module (bridge/). It subscribes to <prefix>.<project-code>.sync, decodes xfer messages, proxies them to a Fossil HTTP server via sync.HTTPTransport, and replies. One project per instance.
Error handling: Decode or HTTP failures produce an empty response (never leave the leaf hanging, never crash).
| Config Field | Default | CLI Flag | Env Var |
|---|---|---|---|
FossilURL |
(required) | --fossil |
BRIDGE_FOSSIL_URL |
ProjectCode |
(required) | --project |
BRIDGE_PROJECT_CODE |
NATSUrl |
nats://localhost:4222 |
--nats |
BRIDGE_NATS_URL |
SubjectPrefix |
"fossil" |
--prefix |
BRIDGE_PREFIX |
NATS Transport
NATSTransport implements sync.Transport over NATS request/reply. Subject scheme: <prefix>.<project-code>.sync. Exchange flow: encode xfer message -> zlib compress -> NATS request -> decode reply.
Constraint: Leaf and bridge must agree on the subject prefix for a given project.
JetStream upgrade path: Replace Request with JetStream publish/consume. The Exchange signature stays the same.
Iroh P2P Transport
Third transport option alongside NATS and HTTP. Enables direct peer-to-peer sync without central infrastructure, using QUIC holepunching + relay fallback for NAT traversal.
Architecture: Rust sidecar binary (iroh-sidecar) communicates with the Go agent via HTTP on a Unix socket. The agent spawns the sidecar as a child process. This avoids CGo while leveraging iroh's Rust networking stack.
Identity: Ed25519 keypair. Each agent has a stable EndpointId derived from its key. Peers are configured by EndpointId, not address.
| Config Field | Default | CLI Flag |
|---|---|---|
IrohEnabled |
false |
--iroh |
IrohPeers |
[] |
--iroh-peer (repeatable) |
IrohKeyPath |
~/.config/edgesync/iroh.key |
--iroh-key |
Agent lifecycle:
Start(): spawn sidecar (--socket,--key-path,--callback,--alpn /edgesync/xfer/1), wait for/status200, register each peer as asyncTargetwithIrohTransport- Poll loop: iterates all
syncTargetsuniformly (NATS, iroh, HTTP) - Incoming: sidecar forwards peer requests to agent's callback listener →
repo.XferHandler() Stop(): POST/shutdownto sidecar, wait 5s, SIGTERM, wait 2s, SIGKILL
Socket path: /tmp/iroh-<sha256(repoPath)[:12]>.sock — deterministic, collision-free for multiple agents.
IrohTransport: RoundTrip(ctx, payload) POSTs to http://iroh-sidecar/exchange/<endpointID> over Unix socket. Same Transport interface as NATS and HTTP — sync layer is unaware of the transport.
Discovery: Phase 1 is manual config only (--iroh-peer). DNS discovery and NATS-assisted peer exchange are future work.
HTTP Serving
serve_http.go in leaf/agent/ composes an HTTP mux with /healthz (200 OK, JSON status) and sync.XferHandler() for Fossil's /xfer endpoint. Operational endpoints live in the agent, not in libfossil (which is transport-agnostic).
Stock fossil clone and fossil sync work against the leaf's HTTP endpoint.
Docker Deployment
Stack lives in deploy/. Two containers: NATS + leaf agent.
Dockerfile: Multi-stage build. Build stage: golang:1.26-alpine with CGO (modernc SQLite). Run stage: alpine:3.20 (~20-30MB). Uses GOWORK=off, copies only libfossil/ and leaf/.
Compose services:
| Service | Ports | Notes |
|---|---|---|
nats |
100.64.0.1:4222 (Tailscale only) |
JetStream enabled, no auth (Tailscale boundary) |
leaf |
0.0.0.0:9000 (public HTTP) |
Health check via /healthz, auto-restart |
Volumes: deploy/data/*.fossil (host mount), nats-data (named volume).
Endpoints:
| Endpoint | URL | Access |
|---|---|---|
| Public HTTPS | https://sync.example.com |
Cloudflare Tunnel |
| Tailscale HTTP | http://100.64.0.1:9000 |
Tailnet only |
| Tailscale NATS | nats://100.64.0.1:4222 |
Tailnet only |
Excluded from v1: TLS termination (use reverse proxy), NATS auth, multi-repo, non-root user, OTel in container, backup cron.
WASM Targets
Two targets, same agent API (New -> Start -> Stop):
| Target | GOOS | SQLite | Network | HTTP Serve | Entry Point |
|---|---|---|---|---|---|
| WASI | wasip1 |
ncruces (as-is) | Host networking | Full | leaf/cmd/leaf/ |
| Browser | js |
ncruces + OPFS VFS | NATS via WebSocket | No-op (can't listen) | leaf/cmd/wasm/ |
Key decisions:
simio.Storageinterface added (following Clock/Rand pattern):OSStorage(prod/WASI),MemStorage(tests),OPFSStorage(browser,//go:build js).- Browser NATS uses
nats.SetCustomDialer()with asyscall/jsWebSocket dialer. - Build-tagged no-ops:
serve_http_js.go,signals_js.goreplace platform features in browser. - Browser exposes JS globals (
edgesync.newAgent,.start,.stop,.syncNow,.clone) and runs in a Web Worker. stash/andundo/excluded from WASM (//go:build !js) -- checkout features, not needed for sync.
Observability
Observer Pattern
sync.Observer interface in libfossil -- zero OTel dependency. Lifecycle hooks injected via SyncOpts.Observer and CloneOpts.Observer. Nil observer uses nopObserver (zero-cost, ~2ns indirect call).
type Observer interface {
Started(ctx context.Context, info SessionStart) context.Context
RoundStarted(ctx context.Context, round int) context.Context
RoundCompleted(ctx context.Context, round int, stats RoundStats)
Completed(ctx context.Context, info SessionEnd, err error)
Error(ctx context.Context, err error)
HandleStarted(ctx context.Context, info HandleStart) context.Context
HandleCompleted(ctx context.Context, info HandleEnd)
}
Design constraint: No per-card child spans. Card-level detail aggregated into RoundStats (files, gimmes, igots, bytes). HandleStarted/HandleCompleted cover server-side request lifecycle. Bridge uses Observer for tracing without an OTel dependency.
OTel Integration
All OTel code lives in leaf/telemetry/ (build-tagged !wasip1 && !js). WASM gets no-op stubs. libfossil and bridge modules have zero OTel dependencies.
Setup: telemetry.Setup() initializes TracerProvider + MeterProvider with OTLP exporters. Falls back to standard OTEL_* env vars. No endpoint = no-op (zero overhead).
Shutdown order: agent.Stop() first (drains in-flight sync), then telemetry.Shutdown() (flushes spans/metrics).
| Metric | Type |
|---|---|
sync.sessions.total |
Counter |
sync.errors.total |
Counter |
sync.duration.seconds |
Histogram |
sync.rounds |
Histogram |
sync.files.sent |
Histogram |
sync.files.received |
Histogram |
sync.uv.files.sent |
Histogram |
sync.uv.files.received |
Histogram |
slog integration: log.Printf replaced with slog.InfoContext/slog.ErrorContext across agent, bridge, and serve_http. Context carries trace/span IDs via otelslog bridge.
Backend swappable via env vars only (Honeycomb, Grafana Cloud, Jaeger, Axiom). OTel secrets managed by Doppler -- no .env files.
| CLI Flag | Env Var |
|---|---|
--otel-endpoint |
OTEL_EXPORTER_OTLP_ENDPOINT |
--otel-headers |
OTEL_EXPORTER_OTLP_HEADERS |
--otel-service-name |
OTEL_SERVICE_NAME |
Module Layout
Four modules in EdgeSync's go.work:
| Module | OTel Deps | NATS Deps | Purpose |
|---|---|---|---|
. (root) |
No | No | CLI, sim tests, soak runner |
leaf/ |
Yes | Yes | Agent daemon, telemetry |
bridge/ |
No | Yes | NATS-HTTP translator |
dst/ |
No | No | Deterministic simulation |
libfossil is an external dependency at github.com/danmestas/libfossil (public, standalone repo). For local development, copy go.work.example to go.work and clone libfossil at ../libfossil.