Automation
Local daemon
Design for the local nipa daemon: a long-lived process that every GUI surface
(desktop client, Windows Explorer, game-engine plugins) talks to instead of
spawning the CLI per event. It reuses the exact usecase graph the CLI already
builds — there is no second implementation of VCS logic, only a host for it.
Scope decisions locked in (see docs/INTEGRATIONS.md):
- Loopback TCP + capability token file (no Unix sockets/named pipes in v1).
- Synchronous server-streaming RPCs for long operations (no operation registry).
- One daemon process, N clone roots (not one process per clone).
Non-goals
- No virtual/VFS checkout layer — the working copy stays materialized on disk.
- No serving of other machines: loopback only, single-user.
- No new VCS semantics: status, staging, push, update, switch, merge, revert and diff are the existing usecase code, just hosted.
- The CLI keeps its own process model;
nipa serveis additive.
Architecture overview
nipa-gui / Explorer / engine plugin ──(loopback gRPC + daemon token)──► nipa serve
│ per registered root:
│ ├ localrepo handle (cached)
│ ├ fsnotify watcher (dirty set)
│ └ op coordinator (mutex)
│ shared:
│ ├ grpc.Transport + Session (per host)
│ ├ securestorage keyring (exclusive owner)
│ └ usecase registry (Repo/Push/Update/Merge/…)
nipa serve ──(server gRPC + presigned HTTP)──► nipad
cmd/nipagains aservesubcommand. Itsmain()wiring is a copy of the CLI’s construction (usecase.NewAuth/NewPush/NewUpdate/...overgrpc.NewClient+usecase.NewSession), kept alive across calls instead of exiting after one command.- The daemon proxies remote operations: branches, MRs, reviews, locks, commit history and tree manifests go through the daemon’s warm transport, so GUI clients never speak the server protocol.
- Each registered root gets a lazy
localrepo.NewLocalRepo()(SQLite handle), a watcher, and an op coordinator. Handles are closed on unwatch/shutdown.
Lifecycle & discovery
Endpoint + token (daemon.json)
On start the daemon binds 127.0.0.1:<random> (NIPA_DAEMON_PORT pins it for
tests; NIPA_DAEMON_EVENT_INTERVAL shortens the WatchEvents poll) and
atomically writes:
~/.config/nipa/daemon.json // {pid, port, token, version} perms 0600
Client discovery: read file → check pid alive → dial → Ping. A stale file
(pid gone or dial refused) means “no daemon”; the GUI auto-spawns
nipa serve and waits for Ping readiness before showing any workspace.
Auth
- Every RPC must carry
x-nipa-daemon-tokenmetadata, verified constant-time against the token indaemon.json. This is a local capability, deliberately a separate header from the server’sauthorization: Bearerso the two token classes cannot be confused. - The user’s server session stays keyring-backed via
securestorage, which the daemon owns exclusively once running.Session.reauth’s interactive prompt path is never used here: a 401/refresh-rejection from the server is surfaced as an RPC error, the GUI renders a login dialog, anddaemon.Login(host, user, password)stores the result via the normalsecurestorage.SaveTokenpath.
Shutdown
daemon.ShutdownRPC orSIGTERM.- Grace period (default 10s): in-flight exclusive ops are allowed to finish;
no new ops are admitted; all
localrepohandles close;daemon.jsonis removed last so discovery never half-succeeds. - No idle-exit in MVP. An idle-exit timer (auto-spawned daemons dying after N idle hours) is listed as later work — it must not fire while a watcher is registered.
Repo registry & op coordination
WatchRepo(root) validates .nipa/config exists and registers the clone:
| Concern | Mechanism |
|---|---|
| Root → handle | Lazy localrepo cache; ref-counted; UnwatchRepo/shutdown closes |
| Serialization | Per-root coordinator: update/push/switch/merge/revert take an exclusive slot (queued FIFO, not rejected); status/stage/diff/lock-list go shared |
| Cancellation | Op context derives from the streaming RPC’s context — cancelling the stream cancels the op |
| Two clones | Fully independent coordinators; only keyring/transport are shared |
Queued exclusive ops stream a queued event so the UI can show “waiting for
the running update…”. Watch out: not every hash loop checks ctx yet
(WorkingCopy.rehash does not); cancellation is best-effort in v1 and a
follow-up to tighten.
RPC surface (internal/client/grpc/proto/daemon.proto)
daemon.proto imports server.proto (include path = module root) and reuses
TreeManifest/FileNode/Branch/CommitLogEntry types verbatim; generated
pb goes to internal/client/grpc/daemonpb. make proto gains a second
protoc invocation.
Local working-copy ops
| RPC | Shape | Notes |
|---|---|---|
Ping | unary | readiness + version; discovery handshake |
WatchRepo / UnwatchRepo / ListRepos | unary | validate + register; returns config (url, branch, sparse) |
Status | unary | full WorkingCopy.Status result (configured branch + detached head marker, staged/deleted/modified/untracked/missing/conflicts, and added: the staged paths the snapshot does not track yet); pending is merge or revert while one waits for continue/abort, pending_remaining the revert targets left (current one included); no_cache passthrough |
Stage | unary | add/remove path lists → returns fresh status |
Restore | unary | discard unpushed changes for files/dirs → returns fresh status; staged paths refused |
Update, Switch | server-streaming | progress events → terminal result; Switch takes an optional tag to detach HEAD at a release tag instead of switching branches |
Push | server-streaming | message → progress → result (commit id/tree hash) |
Merge, Revert | server-streaming | incl. continue/skip/abort variants; conflicts returned as terminal event |
Conflicts | unary | the waiting merge or revert: op, the base/ours/theirs revisions with labels (merge: merge base, target branch, source branch; revert: the reverted commit, the branch head, its parent) and each conflicted file’s kind (text, binary, add_add, modify_delete, delete_modify), binary flag, viewer kind and resolved flag; empty op when nothing waits |
ResolveConflict | unary | settles one conflicted file (ours/theirs = that side’s committed version, a side without the file deletes it; content = given bytes; working = keep the disk file; delete), stages it, records it in the pending state’s resolved list and returns the status (which carries resolved). Text written or kept must have no <<<<<<< ours/>>>>>>> theirs lines |
Diff | server-streaming | unified/stat/name output paged as bytes (reuse usecase.Diff renderers) |
DiffFiles | server-streaming | same request as Diff (format ignored; show with one revision compares it with its first parent, or an empty tree for a root commit — the GUI’s commit view); one structured DiffFile per path: status, viewer kind (internal/preview.Classify), old/new side metadata (mode, size, binary, hex hash), add/remove counts and greet.DiffHunkDetail hunks — omitted with hunks_omitted above 1 MiB of hunk text per file |
ReadBlob | server-streaming | one file version (BlobRef: working default, snapshot = last synced side of a working diff, offline, or revision + branch/tag/commit/HEAD); BlobInfo (size, binary, hex hash, kind) then decoded content in 64 KiB chunks; revision chunks are downloaded into .nipa/objects first. Paths are repo-relative, .nipa/ and symlinks leaving the clone are refused |
Preview | server-streaming | old/new BlobRefs + optional texture level/face → per side ImagePreview (dims, format, encoding, depth, alpha, levels/faces/cube and the decoded level/face, PSD layers, hdr) + downscaled PNG chunks (max_size, default 2048; HDR sides are preview.PackHDR RGBE PNGs twice as wide), then ImageDiffStats from preview.CompareImages when both sides are given; non-image or unsupported formats fail with 415 and the decoder’s reason |
WatchEvents | server-streaming | team activity for one watched root, polled from the server every interval_seconds (default 60, clamped to 15–3600; NIPA_DAEMON_EVENT_INTERVAL overrides it for tests): incoming (commits on the clone’s branch it does not have, up to 100), review_requests (open merge requests with a pending review request for the caller) and events since the previous poll (pushed with author/message/count, lock_released for someone else’s lock, review_requested). The first poll is a baseline without events; later ones are sent only when something changed. A failed poll sends error once and keeps the counts. Ends on unwatch or shutdown |
WatchChanges | server-streaming | change notices for one watched root: the watcher batch’s paths after the stat-cache refresh, or no paths (= refresh everything) after an operation, a stage/restore, or more than 512 coalesced paths. Headers are sent once subscribed; never pins the repo, ends on unwatch or shutdown |
Login | unary | (host, user, password) → securestorage; no prompts in daemon |
Shutdown | unary | drain + exit |
Proxy ops (unary pass-through to the server)
Phase A scope: branches (list/create/delete/protect info), release tags
(ProxyTagList/ProxyTagCreate/ProxyTagDelete), GetTreeManifest,
GetCommitLog (with path: only the commits that changed a file or folder)/
GetCommit/WalkCommits/GetMergeBase, MR
list/create/update/merge/close/reopen + detail incl. review summary, reviews,
threads, commits, checks and timeline, comments (ProxyMergeRequestAddComment/
Reply/Resolve) and review submission, file locks
(lock/unlock/list). Phase B: PBAC/path permissions, groups, admin.
All proxy calls take the repo root, resolve .nipa/config → host/org/project,
and run on the daemon’s warm, auto-refreshing client.
Server-scoped calls work before any clone exists. They take a server
(host, host:port or http/https URL, normalized by ParseEndpoint) and run on
a fresh connection per call (Options.Accounts.Connect): AccountStatus
(is a session stored for the host; email and account id from the token),
Login (password; the connection to the server is opened first, the session
is stored under the host), LoginSSH (registered key from ssh-agent or
~/.ssh, no passphrase prompt), ListProjects (server ListMyProjects),
RemoteBranches and RemoteTree (server ListDirectory: one directory’s
subfolders plus its file count and bytes). Clone(url, branch, sparse, target) streams OpEvents like the long operations but outside any repo
slot (target must be absolute and empty) and ends with a sync result read
from the new clone; SetSparse(root, paths) takes the root’s exclusive slot,
stores the prefixes and runs an update so the working copy matches. The
daemon never prompts: a missing session fails with “sign in to this server
first”.
LockStatus(root) combines proxy calls with the local status: it lists the
project’s locks (ListFileLocks), resolves whether the configured branch is
the default (GetBranchByName: mainline locks apply, otherwise that branch’s
locks), checks admin rights (GetMyPermissions) and reads the caller’s
account id from the session’s access token (JWT sub, unverified, used only
to tell “mine” from “theirs”). Each changed path that is a tracked binary or
has a lockable asset extension (chunker.IsLockablePath) gets a state:
mine, theirs (covered by someone else’s file or directory lock),
needed (tracked, uncovered: the server’s push gate would refuse it) or
free.
Progress streaming (OpEvent)
message OpEvent {
oneof event {
Queued queued; // exclusive slot held by another op
Started started; // phase began
Progress progress; // objects done/total, bytes done/total, phase
Result result; // terminal success payload (per-op message)
Failure failure; // terminal domain.Error
}
}
The daemon adapts the existing usecase.DownloadProgress /
usecase.UploadProgress callbacks onto a channel feeding the stream — the same
pattern the CLI’s progressRenderer plugs into, so no usecase changes. A
stream disconnect cancels the op context; progress lost mid-flight is
acceptable (the usecases are idempotent up to their existing transactional
guarantees).
Status pipeline
Status stays read-through WorkingCopy.Status — snapshot + staged set +
stat_cache, one bounded walk, rehashing only fingerprint misses. Crash/restart
safe, and identical semantics to the CLI (no forked status definition).
The watcher is an accelerator, not the source of truth:
- Recursive watch per registered root (skip
.nipa; pruned on dir removal). - Events are debounced (coalesce ~250–500 ms) into a per-root dirty set.
- A background reconciler rehashes dirty paths (bounded workers) and writes
stat_cachefingerprints viaSaveStatEntries. - A
Statuscall therefore usually pays only the stat walk; fingerprint misses still rehash on demand, so event loss is tolerated — the next status stat-detects anything the watcher missed.
On Linux/macOS, fsnotify needs manual per-directory watch management
(recursive add/remove helpers); on Windows, ReadDirectoryChangesW
(fsnotify’s recursive backend) covers it. StatusStream (server-streamed
status-change notifications for live GUI views) is deferred to Phase B; the
polling RPC is correct and fast without it.
Errors, config, package layout
- Errors cross the wire already shaped like the server’s: the CLI’s
grpc.toDomainErrormapping (400/401/403/404/409) is reused in reverse — the daemon mapsdomain.Errorback to gRPC codes, and clients re-map on receipt. Message strings pass through untouched. ~/.config/nipa/config.json(diffExternal,uploadWorkers) is read once at daemon startup; per-op overrides are not supported in v1.- New packages:
internal/client/daemon(service, registry, coordinator, watcher, token/daemon.jsonplumbing) +internal/client/grpc/daemonpb(generated).cmd/nipagainsserve.
Testing strategy
- Unit (
t.TempDir): token file read/verify, registry registration/eviction, coordinator queueing + cancellation, status cache behavior (the existingstatus_cache_test.gopatterns carry over). - Watcher: behind an interface; tests inject a fake event source so no fsnotify flakes. A “watcher disabled” run asserts read-through status still detects everything (event-loss tolerance is a feature, not a fallback).
- Integration: daemon on a local listener driven end-to-end against an
in-process
nipad(mirroringinternal/e2e): clone → watch → status → stage → push → update, plus exclusive-op queueing and stream-cancel behavior. Keyring is a fakesecurestoragebackend injected in tests. - Long-hash stress tests are skipped unless an env var is set, like
NIPA_BENCH_MBbenches.
Implementation order
daemon.proto+ codegen (make protoextension).- Token +
daemon.jsonplumbing;nipa serveskeleton withPing/Shutdown. - Repo registry + coordinator;
WatchRepo/Status/Stage. - Progress adapter;
Update/Switch/Push/Merge/Revertstreaming. - Watcher + background reconciler.
- Proxy RPCs (branches, tree, log/commits, MRs, locks).
- e2e integration tests.
Implementation status
Implemented (phases 1–7): nipa serve with token discovery, lifecycle and
drain; the repo registry, per-root coordinator and Status/Stage; streaming
Update/Switch/Push/Merge/Revert; streaming Diff; the fsnotify
watcher + background reconciler; and the Phase A proxy surface (branches, tree,
commits, merge base, MR reads/lifecycle, file locks). The client daemon lives
in internal/client/daemon, the proto in
internal/client/grpc/proto/daemon.proto, generated code in
internal/client/grpc/daemonpb.
Notes where the implementation settled details the design left open:
- Per-repo client, not one warm transport. Every watched root gets its own
RepoOpsgraph and gRPC client (serveRepoOps): the client transport binds one host at a time, so sharing it across roots could cross-wire concurrent calls. The session/keyring is still shared. - Shutdown of a queued/running operation cancels it and ends its stream
with a terminal failure event (code 499);
GracefulStopthen waits for the handler to return. - The reconciler skips paths whose fingerprint
Statuswould already trust, so the accelerator follows Status’s trust model exactly instead of being stricter (an update/push does not trigger redundant rehashing). ReadBlob/Previewrun throughDiffRunner.ReadBlob(usecase.Diff.ReadBlob, callback-style soSerialDiffholds its lock for the whole read) under the shared slot; content streams chunk by chunk (blobReader) and previews cap encoded input at 512 MiB on top ofpreview.MaxPixels.DiffFilesis the GUI’s diff feed (docs/DESKTOP.md): it sharesrunDiff(validation, shared slot,FilterIgnored) withDiff, and reuses the server’s hunk messages so desktop and web MR diffs share one shape.WatchChangesis the GUI’s live feed: each repo owns achangeHub; publishing never blocks (each subscriber coalesces until it reads), the reconciler publishes its batch,runOpandStage/Restorepublish a full refresh.Servenow closes the stop channel beforeGracefulStop, so open streams end instead of blocking shutdown.Diffgained acontextfield (hunk context, 0 = default) and accepts<a>..<b>/<a>...<b>ranges daemon-side, matching the CLI.- Proxy requests are forwarded as-is (e.g.
limitis the caller’s), with only the clone’s org/project context filled in and the caller’s context overwritten. WorkingCopy.RefreshStatEntries(internal/client/usecase) is the one addition outsideinternal/client/daemon;localrepo.Initnow closes a previous handle and the DSN setsbusy_timeout, since status and the reconciler share a handle.- Tag checkout rides the existing
Switchstream:SwitchRequest.tagroutes toUpdateRunner.SwitchTag,Statuscarries the detached marker (branch+head{kind,name}), andUpdatewhile detached re-syncs the pinned commit. The manifest fetch usesGetTreeManifestwithcommit_id(project-scoped), so a sparse clone only receives the tagged set.
Not yet implemented: Phase B proxies (PBAC/path permissions, groups, review submission/thread writes, branch protection/default changes), idle-exit, CLI passthrough via the daemon, and socket/named-pipe transport.
Later
- Idle-exit timer for auto-spawned daemons.
StatusStreamlive notifications.- CLI passthrough (
nipa status --daemon) reusing a running daemon — nice for big repos, not required for the GUI. - Socket/named-pipe transport if the TCP+token model proves too exposed in hardened environments.