- Status: done
- Date: 2026-06-10
- Specs touched:
APP_LIFECYCLE.md,DASHBOARD.md,DECISIONS.md
Lets a user stop an installed app they don't currently need (freeing its CPU/RAM) and start it again later. Until now an installed instance was always running; the only off-switch was uninstall, which deletes the app. This slice adds the stop/start half of the running ⇄ stopped state machine that APP_LIFECYCLE.md already locked, wires it through the API, and gives it two UI surfaces: a per-app management page in Settings, and click-to-start straight from a grayed home tile.
Manager.Stop—docker compose stop(neverdown, so containers/network/route/mDNS all survive), flips the Caddy route to the "stopped" splash, sets statestopped. Guarded torunning; returnsErrNotRunningotherwise.Manager.Start—docker compose up -d(the same op the reconcile pass uses — seeDECISIONS.md2026-06-10, notcompose start), bounded by the health-wait budget; flips the route to the "starting" splash, waits formain_servicehealthy, then flips to the real upstream. State is writtenrunningbefore the docker op (brain-commits-first), so a crash mid-start is finished by the reconcile pass exactly as a reboot is. A start that comes up but never goes healthy lands infailedwith the "failed" splash, mirroring an install health-timeout. Guarded tostopped; returnsErrNotStoppedotherwise.- Per-instance lock —
instLocksmap +lockInstance(id);Stop,Start, andUninstalltake it so a stop can't race an uninstall (or each other). Implements the "one lifecycle op at a time per instance" the spec locked but the code hadn't enforced. Install allocates a fresh id, so it has nothing to contend with and skips the lock. ErrNotRunning/ErrNotStoppedsentinels are the only conflict discriminators lifecycle exposes; the API maps them to 409.
POST /api/v1/apps/{id}/stopandPOST /api/v1/apps/{id}/start, both job-based (Pattern B) like uninstall.authorizeAppMutationshared gate: 404-leak-guard viacanSee, then household = admin only, personal = owner or admin (mirrors uninstall). Illegal transitions are a synchronous 409 before the job runs, so the UI gets a clean error instead of a failed job to poll.- Regenerated
api/openapi.{json,yaml}+web-ui/src/generated/openapi.ts.
InstalledAppDetailSection.vue(new) at/settings/apps/:id, rendered inside the Settings shell: header (logo + name + description, with logo/description best-effort fromGET /catalog/{manifest_id}— Door-2 custom apps fall back to the glyph), an action row (Open / Stop service·Start service / Uninstall with an inline two-step confirm), and the app's Logs at the bottom. Control + logs gated to admins / the personal owner.InstalledAppsSection.vueis now a list of links to the detail page; its old inline Logs/Uninstall buttons moved onto the detail page.AppTile.vue— a deliberately stopped tile stays grayed but loses the corner alert mark (that's reserved for failed/crashed), becomes clickable for a viewer who may control it, shows a "Service stopped - click to start again" hover caption, and a persistent "Starting up…" caption while the start job runs.HomeView.vueowns the start mutation + the per-idstartingset; tiles stay presentational.
lifecycle_stopstart_test.go— stop→start round-trip (state + route variant + driver calls), both transition guards, and the start-health-failure →failedpath.stopstart_test.go(api) — the synchronous rejection matrix: member-vs-household 403, unknown 404, member-other-personal 404 leak guard, and both 409 guards. (The happy path runs the job goroutine againstlife, which the api harness builds as nil, so it's covered at the lifecycle layer.)
- Crash detection vs. stopped. The Docker
/eventscrash-detection subscriber (APP_LIFECYCLE.md# crash detection) is still unbuilt; when it lands it must read SQLite state and suppress the "unhealthy" badge for astoppedinstance, or a deliberate stop will read as a crash. - Stop doesn't reclaim the shared service. A stopped app backed by a Tier-1 managed Postgres/MySQL leaves that shared service running (it's brain-owned, not part of the app's compose project). Grace-shutdown of an idle managed service stays deferred (
NEXT.md). - Uninstall confirm is bare. The detail page's uninstall is a two-step inline confirm; the spec's "keep data" checkbox (
APP_LIFECYCLE.md# uninstall) is not yet wired — uninstall still always deletes data, same as before this slice.