Skip to content
edgesync Artifact [087caa3ffb]
Login

Artifact [087caa3ffb]

Artifact 087caa3ffb39229cf54b8d5de31f33ce0ad61531a54dd6c59aecbcc39f54673b:


Agent & Bridge Deployment

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:

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:

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.