Plan: real per-container status during shc install (install-time live service tracking)
Status: IMPLEMENTED — internal/modules/app/compose_progress.go + swarm_progress.go (the swarm path went beyond this plan’s v1 scope), wired into ExecuteCompose for both runtimes; full docker-free unit coverage per the TDD steps below (derive table, both ps shapes, happy path, poller-error isolation, up-failure frame, ctx-cancel).
Problem
Section titled “Problem”The shc install spinner (internal/cli/livedisplay) is synthetic. ExecuteCompose
parses the compose YAML for service names (serviceNamesFromCompose,
install_runner.go:57) and hand-sets every row’s state through one emit() closure:
resolving → blanket pulling (install_runner.go:415-419) → run the blocking
docker compose up -d --wait (compose_driver.go:23-57, captured into a buffer by
runDocker, :92-101) → blanket flip every service to healthy (:491-495). All
service rows therefore flip pulling → healthy together the instant up --wait
returns. No docker compose ps, no docker inspect, no goroutine runs during the up.
The documented design (docs/architecture/status-states.md,
docs/architecture/events/live-service-tracking.md) wants the live display to render
the real container→service rollup, refreshed as container facts change — the same
semantic states the rest of SHC uses. The Python predecessor achieved this: up --wait
blocked, but a concurrent DeploymentTracker._update_loop polled live rows (kept fresh
by docker compose ps) and rendered real per-service status/health while the up ran
(deployment_tracker.py:124-292, compose.py:309-388,720-738).
Go already has the real observe→rollup→reconcile logic, but only in the out-of-band health crons (2s tick / 1m poll / 1m reconcile) — it is not wired into the install display.
During the up --wait window of ExecuteCompose, run a concurrent poller that shells
docker compose ps --format json, derives each service’s real state+health, and feeds
the existing Observer.Step with accurate per-service rows. The spinner then shows
real creating → starting → healthy (and unhealthy/failed) progression instead of a
single blanket flip.
Non-negotiable invariants (do not regress)
Section titled “Non-negotiable invariants (do not regress)”- The poller is purely additive observability. Any poller error (ps failure, JSON
parse error, docker missing) is swallowed and skips that tick — it must NEVER change
the install’s success/failure.
dockerComposeUp’s return value remains the sole source of truth for the result. - No contract change.
operations.ServiceInfo/DeploymentPayload/StepObservershapes are unchanged (shared with the daemon SSE encoder and the CLI decoder — see the warning atobserver.go:8-11). The poller only builds richerServicesslices. - One emitter at a time. During the up window the poller is the only caller of
emit/Observer.Step; the main goroutine resumes emitting only after the poller has fully stopped (WaitGroup join), so payloads never interleave with stale slices. - No goroutine leak. The poller stops on up-return, on error, and on ctx-cancel, in all paths.
- CLI self-contained. The poller shells
docker compose ps(likecompose_driver.goand the reconcile actions), it does NOT pull in the docker SDK. - Both paths benefit unchanged. Same
ExecuteComposeruns daemon-side withStoreObserver; the extra Step calls become richer SSEstepevents for free. No daemon-side change required.
- In: the default compose runtime path (
dockerComposeUp). TTY and non-TTY render (livedisplay already handles both; poller adds change-detection so non-TTY isn’t spammed). - Out (v1, documented as follow-up):
- Swarm path (
dockerStackDeploy) —docker stack pshas a different shape; keep the current blanket flip for swarm. SHC_COMPOSE_NO_WAIT=1— the up returns immediately so the poller stops early; v1 keeps current behavior there (note in code + this doc). A bounded post-up poll window is a separate change because it alters the synchronous-readiness contract.
- Swarm path (
Design
Section titled “Design”New file internal/modules/app/compose_progress.go (package app):
// deriveServiceState maps a `docker compose ps` State+Health(+ExitCode) to the// spinner state string livedisplay's pickIcon understands. Mirrors the Python// shc_status ladder (service_model.py:205-254) and reuses the health vocabulary// of metrics/collectors.parseHealth.func deriveServiceState(state, health string, exitCode int) (spinnerState string)// running + healthy|"" -> "healthy" (✔)// running + starting -> "starting" (spinner)// running + unhealthy -> "unhealthy" (✘)// created -> "creating" (spinner)// restarting -> "restarting"(spinner)// exited/dead, exit==0 -> "complete" (✔) // one-shot init success// exited/dead, exit!=0 -> "failed" (✘)// (absent from ps output) -> keep prior "pulling"/"creating" (spinner)
// composePSEntry is the subset of `docker compose ps --format json` we read.type composePSEntry struct { Service string `json:"Service"` State string `json:"State"` Health string `json:"Health"` ExitCode int `json:"ExitCode"`}
// parseComposePS handles BOTH compose output shapes: a JSON array (newer) and// NDJSON, one object per line (older). Returns []composePSEntry.func parseComposePS(raw []byte) ([]composePSEntry, error)
// runComposePS is the injectable command seam (real impl shells// `docker compose -p <project> -f <path> ps -a --format json`); tests swap it.type psFunc func(ctx context.Context, project, composePath string) ([]byte, error)
// composeUpWithProgress runs the blocking up while a goroutine polls ps every// ~tickInterval and emits real per-service payloads via emit. Returns the up's// error verbatim. Guarantees the poller has stopped before returning.func composeUpWithProgress( ctx context.Context, project, composePath string, services []operations.ServiceInfo, // names + DesiredReplicas, pre-rendered emit func(status string, services []operations.ServiceInfo, completed, total int), ps psFunc, // nil -> real docker up func(ctx context.Context) error, // closure over dockerComposeUp(ctx, project, composePath)) errorBehavior of composeUpWithProgress:
pollCtx, cancel := context.WithCancel(ctx); defer cancel().- Start one goroutine (tracked by a
sync.WaitGroup):- ticker at
tickInterval(default 500ms — matcheslive-service-tracking.md:173-177; overridable viaSHC_INSTALL_POLL_MSfor tests/tuning). - each tick:
ps(...); on error →continue(best-effort). Build a fresh services slice = base names with state fromderiveServiceState,CurrentReplicas= count of running containers for that service,DesiredReplicascarried from the base.completed= count ofhealthy/complete. Status string ="deploying". - change-detection: keep the last emitted slice; skip
emitif identical (prevents TTY redraw churn and non-TTY log spam).
- ticker at
- Run
up(pollCtx)(blocking) in the calling goroutine. cancel(); wg.Wait()— poller fully stopped.- Do ONE final
ps(...)andemit("running", finalServices, completed, total)so the terminal frame reflects real state (replaces the blanket healthy flip). Onuperror, still do the final ps so failed services show ✘ where they actually failed, then return the up’s error.
Wiring in install_runner.go (replaces lines 473-495):
if total > 0 { switch opts.Runtime { case string(RuntimeSwarm): // unchanged: blanket flip for swarm (out of v1 scope) if err := dockerStackDeploy(ctx, project, composePath); err != nil { /* failed flip + emit */ } for i := range observedServices { observedServices[i].State = "healthy"; observedServices[i].CurrentReplicas = 1 } emit("running", total, total) default: upErr := composeUpWithProgress(ctx, project, composePath, observedServices, func(status string, svcs []operations.ServiceInfo, c, t int) { observedServices = svcs // keep state.json/down path consistent opts.Observer.Step(operations.DeploymentPayload{ Deployment: deployment, Services: svcs, Progress: [2]int{c, t}, Status: status, }) }, nil, func(c context.Context) error { return dockerComposeUp(c, project, composePath) }) if upErr != nil { /* final ps already emitted failed states */ return upErr } }} else { // headless: unchanged for i := range observedServices { observedServices[i].State = "healthy"; observedServices[i].CurrentReplicas = 1 } emit("running", total, total)}Note: the emit closure stays for the pre-up phases (resolving/pulling) and the
failure paths above the up (install_runner.go:446-449); only the up block delegates to
the poller. The poller calls opts.Observer.Step directly (guard opts.Observer != nil).
TDD steps (each step: write the test, watch it fail, implement, watch it pass)
Section titled “TDD steps (each step: write the test, watch it fail, implement, watch it pass)”deriveServiceStatetruth table —compose_progress_test.go, table-driven over the mapping above (running/healthy, running/starting, running/unhealthy, created, restarting, exited-0, exited-1, dead-137). Pure function, no docker.parseComposePSboth shapes — feed a JSON array fixture and an NDJSON fixture (captured fromdocker compose ps --format jsonon two compose versions); assert equal parsed slices; assert a malformed line returns an error (caller will swallow it).composeUpWithProgresshappy path — injectpsreturning a scripted sequence (created→starting→healthy) and anupthat blocks on a channel for N ticks then returns nil. Assert: emitted payloads show the progression; final emit is"running"with allhealthy;completed==total; change-detection collapsed identical ticks; no goroutine left (usegoleakor a WaitGroup assertion).composeUpWithProgresspoller-error isolation —psalways errors;upreturns nil. Assert: function returns nil (success), at least the final emit fired, no panic.composeUpWithProgressup-failure —psshows one servicefailed;upreturns a wrapped error. Assert: function returns that exact error; final emit shows the failed service as"failed"(✘), not blanket healthy.composeUpWithProgressctx-cancel — cancel ctx mid-run; assert the poller goroutine exits and the function returns promptly (up closure observes cancellation).- Wiring smoke (no docker) — call
ExecuteComposeshape with a stub by injecting theps/upseam through a test-only hook, OR keep the seam internal and assert via an existingapppackage test that the blanket-flip code path fortotal==0and swarm is unchanged. (Real docker behavior stays covered bymake test/apps.)
Acceptance criteria
Section titled “Acceptance criteria”make buildgreen;make lintgreen (coded errors only on prod paths — reuseX500059DCKCMPfor ps shellouts or add a dedicated factory; no plainerrors.New).- New unit tests (steps 1–6) pass; existing
internal/modules/app+internal/core/operationsinternal/cli/livedisplaytests still pass.
- Manual TTY check:
shc install <multi-service-app> --host …shows services converging individually (e.g. dbhealthybefore appstarting) rather than all flipping at once. make test/appsfor one multi-service app (e.g.apps/planeorapps/nextcloud) still green — the poller did not alter install outcome or timing materially.
Closes
Section titled “Closes”Gaps 1–3 and 5 from the Python/Go comparison: concurrent per-container tracking during install, layered rollup feeding the spinner, service-level health distinction mid-deploy, and (via the final-ps frame) a truthful terminal state instead of a trusted-exit-code flip. Out of scope here: gap 4 (three-tier SSE event hierarchy) and gap 6 (wiring the 2s tick cache into the deployment poll) — separate changes.