Arius.Cli host¶
Code:
src/Arius.Cli/· Decisions: ADR-0007 · ADR-0013 · Terms: chunk · large chunk · tar chunk · content hash · snapshot · storage tier hint
Purpose¶
The arius command-line host. It owns nothing of the backup model: each verb (archive, restore, ls, snapshot list/snapshot diff, repair-index, update) parses flags, builds a per-repository IServiceProvider, drives Arius.Core through IMediator, and turns the events Core publishes during a run into a live terminal display. Maintainer-facing — for user/operator usage see guide/cli.md.
How it works¶
One provider per repository (no shared host container)¶
There is no long-lived application container. Program.cs builds the RootCommand once (CliBuilder.BuildRootCommand) and each verb lazily constructs a ServiceProvider scoped to exactly one account + container, via the serviceProviderFactory delegate injected into every verb's Build. CliBuilder.BuildProductionServices opens the blob container (IBlobServiceFactory.CreateAsync → OpenContainerServiceAsync(container, preflightMode)) and then wires DI:
ProgressStateas a singleton — the one piece of shared mutable state, read by the display and written by the notification handlers.AddMediator()is called in the CLI assembly, not insideAddArius, on purpose: theMediator.SourceGeneratormust run inArius.Cliso it discoversINotificationHandler<T>implementations in both Core and the CLI (the progress handlers inCommands/Archive/ArchiveProgressHandlers.csetc. live here).AddArius(blobContainer, passphrase, accountName, containerName)registers the Core feature handlers.
PreflightMode (ReadWrite for archive/repair, ReadOnly for restore/ls) is threaded into the factory so the container open fails fast; verbs catch PreflightException and translate its structured fields (ErrorKind, AuthMode, account/container names) into actionable console messages — the long switch in each verb is the same shape across ArchiveVerb, RestoreVerb, LsVerb. The read verbs additionally catch the Core RepositoryEncryptionException (a missing or incorrect passphrase) and render it through the shared CliBuilder.FormatRepositoryEncryptionError helper — an actionable --passphrase/-p hint instead of a raw decompression/crypto error.
Driving Core and consuming its events¶
The CLI sends a single command per verb and renders progress purely as a side effect of Core's published events — it never inspects Core internals. The Core → CLI direction is loosely coupled through Mediator notifications; that emission and loose-coupling design lives in cross-cutting/events-and-progress.md — this doc only covers the consume/render side.
sequenceDiagram
participant V as ArchiveVerb.SetAction
participant M as IMediator
participant Core as ArchiveCommandHandler
participant H as INotificationHandler<T> (CLI)
participant PS as ProgressState (singleton)
participant Live as AnsiConsole.Live loop
V->>M: Send(ArchiveCommand(opts)) [archiveTask, not awaited]
par Pipeline runs
Core-->>H: Publish FileScannedEvent / FileHashedEvent / ChunkUploadedEvent …
H->>PS: IncrementFilesScanned / SetFileHashed / IncrementChunksUploaded …
and Poll loop (~10 Hz)
loop until archiveTask.IsCompleted
Live->>PS: read counters + tracked rows
Live->>Live: ctx.UpdateTarget(BuildDisplay(state))
Live->>Live: await Task.WhenAny(archiveTask, Task.Delay(100))
end
end
M-->>V: ArchiveResult
Two distinct event channels feed ProgressState:
- Notifications — handlers in
ArchiveProgressHandlers.cs/Restore/RestoreProgressHandlers.csmutateProgressState. They are pure state transitions (e.g.FileHashedHandler→state.SetFileHashed,ChunkUploadedHandler→ remove the tracked row +IncrementChunksUploaded). - Per-item
IProgress<long>callbacks — for byte-level progress on a specific file/chunk. The verb supplies factory delegates on the Core options (CreateHashProgress,CreateUploadProgress,CreateLargeFileDownloadProgress,CreateTarBundleDownloadProgress) that hand back aProgress<long>writing into the matchingTrackedFile/TrackedTar/TrackedDownload. Queue depths arrive the same way viaOnHashQueueReady/OnUploadQueueReady/OnDownloadQueueReady, which stash aFunc<int>the display polls.
ProgressState: the shared model¶
ProgressState (singleton) holds all live counters plus per-item tracking dictionaries. Every field uses Interlocked / Volatile or a Concurrent* collection because Core's pipeline stages publish from many worker threads concurrently while the display reads on the poll thread. Three per-item entities track the rows the display shows:
TrackedFile— a file moving through the archiveFileStatemachineHashing → Hashed → Uploading → Done. OnlyHashingandUploadingare visible as rows;Hashedis invisible but retained so theContentHashToPathreverse map can find it. The reverse map (content hash → paths) is the bridge that lets content-hash-keyed events (ChunkUploadingEvent,ChunkUploadedEvent) and the chunk-keyed upload-progress callback find the per-file rows — large-file chunk hashes equal the file's content hash, soContentHash.Parse(chunkHash)reconnects them.TrackedTar— one tar chunk bundle throughAccumulating → Sealing → Uploading. Small files are collapsed into a TAR row (TarEntryAddedHandlerremoves the individualTrackedFileand adds to the bundle), so a single bundle row stands in for many small files.TrackedDownload— one restore chunk download (large-file or tar-bundle), keyed by relative path or chunk hash.
Rendering: BuildDisplay as a pure function¶
Each verb has an internal static IRenderable BuildDisplay(ProgressState) that is a pure projection of the current state — no rendering decision reads anything but ProgressState. The poll loop calls it repeatedly:
await AnsiConsole.Live(BuildDisplay(progressState))
.Overflow(VerticalOverflow.Crop).Cropping(VerticalOverflowCropping.Bottom)
.AutoClear(false)
.StartAsync(async ctx =>
{
var archiveTask = mediator.Send(new ArchiveCommand(opts), ct).AsTask();
while (!archiveTask.IsCompleted)
{
ctx.UpdateTarget(BuildDisplay(progressState));
await Task.WhenAny(archiveTask, Task.Delay(100, ct)); // ~10 Hz, responsive cancel
}
result = await archiveTask;
ctx.UpdateTarget(BuildDisplay(progressState)); // final frame
});
The layout is built bottom-up from ProgressState: phase headers (Scanning / Hashing / Uploading for archive; Resolved / Checked / Restoring for restore) each render a ●/○ symbol whose state is derived from the counters (e.g. archive's Hashing header is "done" only when ScanComplete && filesHashed + filesSkipped >= filesScanned), followed by a borderless Table of the active per-item rows. DisplayHelpers provides the shared cell formatting: RenderProgressBar (green █ / dim ░), SplitSizePair (aligns a current/total byte pair onto a shared unit), and TruncateAndLeftJustify (fixed-width path cell that keeps the deepest path segment via a "..." + tail ellipsis).
When stdout is not interactive (!Console.Profile.Capabilities.Interactive, e.g. piped/CI), the Live loop is skipped entirely and the command is awaited directly — the events still fire and update ProgressState, they just aren't rendered.
Restore: interactive phase coordination¶
Restore is the one verb that must ask the user a question mid-pipeline (rehydration cost confirmation, cleanup confirmation) — but the prompt and the pipeline run on different threads. RestoreVerb bridges them with TaskCompletionSource pairs: Core's ConfirmRehydration / ConfirmCleanup option delegates set a "question" TCS and await an "answer" TCS; the CLI's outer loop watches the question TCSs alongside the pipeline task (Task.WhenAny(pipelineTask, questionTcs.Task, cleanupQuestionTcs.Task, Task.Delay(100))), tears down the Live display when a question fires, renders the cost table / SelectionPrompt, and posts the answer back through the answer TCS. The numbered // ── Phase N ── comments in RestoreVerb mark this: resolve → rehydration question → download (a second Live loop) → cleanup question.
ls, snapshot list / snapshot diff, repair-index, update¶
lsdoes not use a Live display or a recorder. It streams entries viamediator.CreateStream(new ListQuery(...))and writes each row as it arrives (AnsiConsole.MarkupLineperRepositoryFileEntry), so memory stays bounded for million-entry repositories. The 4-char state cell (PBRH-style: local Pointer / local Binary / in Repository / tier H·A·~·?) is formatted byLsStateFormatter, whose colors mirror Arius.Explorer.snapshot list/snapshot diffform the CLI's first nested verb group (asnapshotparent under the root). Both stream likels(no Live display):liststreamsSnapshotsListQueryand numbers rows with a 1-based oldest-first display ordinal;diff <from> <to>streamsSnapshotDiffQueryas git--name-statuslines (A/D/M/T) plus a summary. Each<from>/<to>is resolved CLI-side bySnapshotArgumentResolver— a bare integer is thelistindex (the snapshot list is only fetched when an index is used), anything else a version/timestamp prefix (colons stripped). The convenience index is a display ordinal only; Core sees version strings.repair-indexis a fire-and-forget command (RepairChunkIndexCommand) with a recorder-captured single summary line.updateis self-contained — no Core, no DI. It queries the GitHub releases API, maps theRuntimeIdentifierto a release asset, downloads it under aProgressbar, and replaces the running executable (on Windows via the embeddedWindowsUpdateAfterExit.ps1helper that waits for the process to exit; on Unix via copy-overwrite +chmod).
Audit logging¶
Per ADR-0007 the pipeline emits two log levels (coarse [phase] markers + category detail). The CLI is where they land: CliBuilder.ConfigureAuditLogging configures a per-invocation Serilog file sink under RepositoryLocalStatePaths.GetLogsDirectory(account, container) at Information+, with a {ShortSourceContext} template. Verbs that own a Live display swap AnsiConsole.Console for a Recorder, then FlushAuditLog writes the captured console text into the log on exit — so the audit log preserves both the structured pipeline events and what the user actually saw.
Key invariants¶
ProgressStateis the only shared mutable state, and every access is thread-safe. Core publishes from concurrent worker threads while the display reads on the poll thread; all counters useInterlocked/Volatileand all collections areConcurrent*. A handler must not introduce a non-atomic field.BuildDisplayis a pure function ofProgressState. No I/O, no Core calls, no side effects — it can be invoked at any frame rate and at completion. Display logic that needs new information must first land inProgressState.- The reverse map
ContentHashToPathmust be populated (onFileHashedEvent) before any content-hash-keyed event for that hash arrives. Large-file rows depend onContentHash.Parse(chunkHash)reconnecting to it; the pipeline's ordering guarantee is load-bearing. - Small files collapse into exactly one
TrackedTarrow.TarEntryAddedHandlerremoves the per-fileTrackedFilewhen the file joins a bundle; nothing should re-add it. Upload progress for a bundle is measured against the sealedTarByteSize(set inTarBundleSealingHandler), not the sum of file contents, or the bar overshoots 100%. - One
IServiceProviderper repository. Account/container/passphrase are baked into the provider at construction; there is no cross-repository state to leak between verbs. - Non-interactive runs still complete correctly. Skipping the Live loop must never change command behavior — only whether frames are drawn.
Why this shape¶
- Core ⊥ hosts (ADR-0013). The CLI consumes Core only through
IMediator+ option delegates, never reaching into pipeline internals; the same Core is reused unchanged by Explorer and the Web/Api host. This is why progress flows through published events into a host-ownedProgressStaterather than Core knowing about a terminal. - Two-level audit logging (ADR-0007). The CLI is the sink for the phase/detail taxonomy; see the ADR for why phases are entry-markers (not begin/end spans) given overlapping concurrent stages.
- Mediator registered in the CLI assembly. The source generator must scan
Arius.Clito find the progress handlers; registering insideAddAriuswould only discover Core handlers. This is the one piece of DI wiring that can't move into Core. Task.WhenAny(work, Task.Delay(100))instead of a timer. A ~10 Hz poll keeps the display responsive and, critically, keeps the loop awaiting the real work task so cancellation propagates immediately rather than on the next tick.- TCS pairs for restore prompts. A prompt that blocks a pipeline thread would stall Core's workers; routing question/answer through
TaskCompletionSourcelets the pipeline keep running (or park) while the CLI thread owns all console interaction.
Open seams / future¶
- Display logic is duplicated per verb.
ArchiveVerb.BuildDisplayandRestoreVerb.BuildDisplayeach re-implement the phase-header + table pattern; only the leaf cell helpers (DisplayHelpers) are shared. A common "phase header + tracked-row table" renderable would remove the divergence — the next display addition is the place to factor it out. - The
PreflightException→ messageswitchis copy-pasted acrossArchiveVerb,RestoreVerb,LsVerb(differing only in the RBAC role suggested). A shared formatter keyed onPreflightModewould collapse it. repair-indexhas no progress display — it runs blind to a single summary line even thoughRepairChunkIndexCommandcould publish events. If repair grows long-running, it should adopt the sameProgressState/Live pattern.updatetrusts the GitHub asset unconditionally (no checksum/signature verification) and only supports the four RIDs inridToAsset.