* feat: add service-managed self-update * chore: add temporary self-update test scripts * chore: simplify temporary script markdown fences * chore: run frontend in temporary self-update scripts * fix: show self-update job progress after apply * chore: add real npm self-update test script * fix: isolate npm self-update prefix * fix: detect npm service installs from bin shim * chore: reset service before real npm update test * chore: expand real npm status diagnostics * fix: preserve service home during self-update * fix(cli): tee self-update helper output to a log file * fix(cli): avoid launchd bootstrap race when reloading the macOS service * fix(backend): run macOS self-update helper once via transient launchd plist * fix(web): show self-update progress and lock the apply button until the update lands * fix(cli): reinstall via the kandev wrapper on Homebrew self-update * chore: remove temp self-update test scripts * fix(web): drop unused apply path from useUpdates hook * fix(backend): snapshot install state once in apply preflight * docs: align self-update docs with transient LaunchAgent flow * chore(release): run cli bundle from apps workspace root * fix(backend): guard against concurrent self-update applies * fix(backend): check scheme and helper paths in self-update apply * fix(backend): drop duplicate binary name from npx manual command * fix(cli): allow --help for service self-update without --intent * fix(cli): harden self-update dir perms and darwin uid resolution * fix(web): set required POST fields last in applyUpdate * docs: use JSON body notation for the apply endpoint * fix(backend): expire self-update guard so a stuck helper cannot wedge apply * refactor(cli): collapse redundant writeSync branch in self-update log
19 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| draft | 2026-05-18 | tbd |
System pages
Why
Today, settings-style operational concerns (health, disk usage, current version, OSS attribution, log access, database maintenance) have no home in kandev's UI. The existing settings sidebar is product-configuration only (executors, agents, integrations); the only system-shaped entry — Changelog — sits awkwardly inside it. When something goes wrong, users have no path inside the app to see "where is my database, how big is it, what version am I on, is there an update, where are the logs." This forces them into the filesystem and into GitHub, and it makes recoverable problems (bloated SQLite, corrupt state) look unrecoverable.
Radarr and Sonarr solve this with a dedicated System area: a group of read-only diagnostic pages plus a small number of gated maintenance actions. This spec brings the same shape to kandev, scoped to the kandev-specific surface (SQLite + worktrees + GitHub releases + lumberjack log files).
What (v1)
A new System group is added to the existing settings sidebar (apps/web/components/settings/settings-app-sidebar.tsx), alongside General/Workspaces/Agents/Executors/Integrations/etc. The group contains seven child pages, each on its own route under /settings/system/*. The existing /settings/changelog route is removed and its UI is absorbed into the new Updates page.
Pages
- Status —
/settings/system/status- Health issues card: renders the existing
GET /api/v1/system/healthpayload (warning/error/info issues with messages and links). - Disk usage card: shows total kandev data footprint with a per-subdirectory breakdown (
data dir / worktrees / repos / sessions / tasks / quick-chat / backups), an "as of HH:MM" timestamp, and a Refresh button. The disk walk is lazy and asynchronous: the first visit after a cold start (or after the 2h cache expires) returnsnullimmediately and the page shows a loading spinner while the walk runs in the background; subsequent visits within 2h return the cached value instantly. - Version + update card: short summary of current version and "update available" badge, with a CTA to the Updates page.
- Health issues card: renders the existing
- Database —
/settings/system/database- Read-only: SQLite file path, file size, WAL size, schema version, last-backup timestamp (from
<data-dir>/backups/). - VACUUM button — safe, reclaims space; shows progress and final size delta.
- Optimize button — runs
PRAGMA optimize; shows progress. - Factory Reset button — destructive. Wipes the full kandev install (database + worktrees + repo clones + session dirs + tasks dir + quick-chat dir), equivalent to deleting
~/.kandev/and starting over. Opens a modal that requires (a) typing the literal stringRESETto enable the confirm button, (b) acknowledging that the backend will create a pre-reset snapshot first and then restart. Server-side: stop orchestrator and running executions → snapshot DB to<data-dir>/backups/pre-reset-<ts>.db→ drop DB tables and re-run migrations →rm -rfthe worktrees/repos/sessions/tasks/quick-chat subdirs → restart backend process. The frontend disables the UI and waits for the backend to come back, then routes to the empty onboarding state.
- Read-only: SQLite file path, file size, WAL size, schema version, last-backup timestamp (from
- Backups —
/settings/system/backups- Lists existing snapshots in
<data-dir>/backups/with name, size, and mtime. - Per-row actions: Download, Restore (gated like Factory Reset), Delete (confirm-only).
- Create snapshot button at the top of the list; uses the existing
VACUUM INTOpath.
- Lists existing snapshots in
- Logs —
/settings/system/logs- Static log viewer: last 1000 lines of the current lumberjack log file, with a Refresh button. No live tail in v1.
- List of rotated log files with name/size/mtime and a per-row Download button.
- Download current button at the top.
- Updates —
/settings/system/updates- Shows running version, latest available version, and a "new version available" badge if newer.
- Check now button — forces a re-poll (rate-limited 30s per process).
- Below: the embedded changelog list (the content currently rendered at
/settings/changelogvia@/generated/changelog.json).
- Licenses —
/settings/system/licenses- Renders a JSON manifest of all third-party OSS dependencies (npm + Go), each with its license name, version, repository URL, and license text. The manifest is generated at build time and committed to the repo.
- Searchable list (filter by package name or license type), one-line per row, expand to see full license text.
- About —
/settings/system/about- Version, build commit, build timestamp, Go runtime version, Node runtime version, OS/arch.
- Links: GitHub repo, documentation, license, "Report an issue".
Sidebar badge
The System group header (and the Status child entry) show a numeric badge equal to count(health.issues where severity != info) + (updateAvailable ? 1 : 0). The badge is sourced from the existing useSystemHealth hook plus the new updates hook; no new WS topic is required for v1.
Backend surface
A new package apps/backend/internal/system/ owns these endpoints. It absorbs the existing internal/health/ package as a sub-component. Endpoints use the same auth model as the rest of /api/v1/settings/* (no separate admin tier); see Permissions for the destructive-action confirmation pattern.
GET /api/v1/system/health (existing; unchanged)
GET /api/v1/system/info - versions, commit, build time, OS/arch
GET /api/v1/system/disk-usage - cached breakdown + computedAt; null while computing
POST /api/v1/system/disk-usage/refresh - kick async recompute; 202
GET /api/v1/system/database - path, sizeBytes, walSizeBytes, schemaVersion, lastBackupAt
POST /api/v1/system/database/vacuum - 202 + jobId
POST /api/v1/system/database/optimize - 202 + jobId
POST /api/v1/system/database/reset - factory reset; body { confirm: "RESET" }
GET /api/v1/system/backups - list snapshots
POST /api/v1/system/backups - create snapshot; 202 + jobId
GET /api/v1/system/backups/:name/download - stream snapshot file
POST /api/v1/system/backups/:name/restore - restore; body { confirm: "RESTORE" }
DELETE /api/v1/system/backups/:name - delete snapshot
GET /api/v1/system/logs - { files: [{ name, size, mtime }] }
GET /api/v1/system/logs/tail?n=1000 - last N lines of current log
GET /api/v1/system/logs/:name/download - stream log file
GET /api/v1/system/updates - { current, latest, latestCheckedAt, releaseUrl, install, applySupported }
POST /api/v1/system/updates/check - force GitHub re-poll; rate-limited 30s
POST /api/v1/system/updates/apply - queue service-only self-update; body { confirm: "UPDATE" }
Long-running operations (vacuum, optimize, reset, restore, snapshot create, disk walk) return 202 Accepted with a jobId and publish progress on the existing event bus. The frontend subscribes via WS (system.job.update event) to render progress and final result. On success/failure the operation flips a corresponding entry in the existing health surface (e.g., a "VACUUM completed: reclaimed X MB" info issue that auto-expires).
Updates poller
A background goroutine in internal/system/updates/poller.go starts on backend boot and polls GitHub releases for kdlbs/kandev every 6 hours (unauthenticated; well under the 60 req/hr/IP unauth limit). It compares tag_name to the running version (semver) and persists the result to kandev_meta:
latest_version TEXT— highest published semver tag (e.g.,1.2.4)latest_version_url TEXT— URL to that GitHub releaselatest_version_checked_at INTEGER— unix timestamp of last successful poll
The GET /api/v1/system/updates handler reads from kandev_meta only; it never calls GitHub synchronously. It also reports the current service install state (running_as_service, managed_service, mode, manager, kind) so the UI can decide whether one-click apply is allowed. POST /api/v1/system/updates/check triggers an out-of-band refresh, rate-limited per-process to one call per 30 seconds. If the GitHub call fails (offline, rate limited, 5xx), the handler returns the last-known value and the latestCheckedAt exposes the staleness.
POST /api/v1/system/updates/apply is available only when Kandev is running as a kandev-managed user service (systemd --user or launchd user agent) and the latest release is newer than the current binary. It writes an update intent under <KANDEV_HOME_DIR>/service/update-intents/, starts a manager-owned helper (systemd-run --user on Linux, or a one-shot transient LaunchAgent plist bootstrapped via launchctl bootstrap on macOS), and returns a self-update system job id. Under KANDEV_E2E_MOCK=true, the helper path is fake so UI and backend tests can exercise the flow without mutating npm/Homebrew or restarting the service.
Disk-usage cache
Cache is an in-memory { value *Breakdown, computedAt time.Time, computing bool } guarded by a mutex. The walk is lazy — never runs at boot. GET /api/v1/system/disk-usage returns immediately:
- If
value == nil && !computing→ start the walk in a goroutine, return{ data: null, computing: true }. - If
value == nil && computing→ return{ data: null, computing: true }. - If
value != nil && time.Since(computedAt) < 2h→ return{ data: value, computing: false }. - If
value != nil && time.Since(computedAt) >= 2h→ return{ data: value, computing: true }and start a background refresh.
POST /api/v1/system/disk-usage/refresh forces a refresh regardless of TTL. The job publishes a system.job.update event so the frontend can swap the cached value for the fresh one without polling.
Licenses generation
Generation is lockfile-driven and committed to the repo. The file is read statically in dev, build, prod, and release with zero runtime cost and no network access.
- Generator:
apps/web/scripts/generate-licenses.ts— pure function of two inputs.- npm side: walks
pnpm-lock.yamlvialicense-checker-rseidelsohn(or equivalent), capturing{ name, version, licenses, repository, licenseFile }per package. - Go side: resolves modules from
apps/backend/go.sumviago-licenses report(or by reading vendored LICENSE files), captured into the same shape. - Output:
apps/web/generated/licenses.json, committed to git.
- npm side: walks
- Local trigger:
pnpm licenses:gen— devs run it after a dep bump. - CI gate: A workflow step in
.github/workflows/runs the generator and fails the build if the result differs from the committed file. This guarantees the artifact is up to date wheneverpnpm-lock.yamlorgo.sumchanges, without requiring it to run on everypnpm build.
The page reads the JSON statically; no backend endpoint is needed.
Scenarios
- GIVEN a user opens
/settings/system/statusfor the first time after backend boot, WHEN the page mounts, THEN the Disk Usage card shows a spinner and "Calculating…", the backend kicks off the walk, and the value populates within seconds without further interaction (via WS job update or 5s poll fallback). - GIVEN the disk-usage cache is 30 minutes old, WHEN the user reopens the Status page, THEN the cached value renders instantly with "as of <30 min ago>" and no refresh kicks off.
- GIVEN the disk-usage cache is 3 hours old, WHEN the user reopens the Status page, THEN the stale value renders immediately, the page badge shows "Refreshing…", and the value updates when the background walk completes.
- GIVEN the user is on
1.2.3and the GitHub latest release is1.2.4, WHEN they open/settings/system/updates, THEN an "Update available" badge renders next to the version, the changelog list shows1.2.4highlighted as the new entry, and the System sidebar group shows a1badge. - GIVEN the user is running a kandev-managed user service and a newer release exists, WHEN they open
/settings/system/updates, THEN the page shows Apply update and the confirmation queues aself-updatejob. - GIVEN the user is not running as a kandev-managed service or is running a
--systemservice, WHEN they open/settings/system/updates, THEN the page does not render an update-apply control and instead shows the manual update commands. - GIVEN the user clicks VACUUM on the Database page, WHEN the operation completes, THEN the DB size delta is shown ("Reclaimed 12.3 MB"), the page Database stats refresh, and a transient info issue appears on the Status page.
- GIVEN the user clicks Factory Reset, types
RESET, and confirms, WHEN the backend executes, THEN a fresh snapshot is created first, all tables are dropped and migrations re-run, the backend restarts, and the frontend redirects to the empty onboarding state once it reconnects. - GIVEN the backend cannot reach GitHub, WHEN the poller fires, THEN the failure is logged but the previous
latest_versionandlatest_version_checked_atremain inkandev_meta; the Updates page surfaces the stale value with a "Last checked - GIVEN the user clicks Check now twice within 30 seconds, WHEN the second click fires, THEN the endpoint returns
429 Too Many Requestsand the UI shows "Already checked, try again in s". - GIVEN lumberjack has rotated the current log into
kandev.log.1, WHEN the user opens/settings/system/logs, THEN the viewer shows the tail of the currentkandev.logand the rotated files appear in the list with download buttons. - GIVEN the user opens
/settings/system/licenseswhile offline, WHEN the page renders, THEN every dependency's license text is available locally (no network calls).
Data model
Two new columns on the existing kandev_meta table (key/value or pivoted to columns — match the existing schema). One new in-memory cache in the system package. No new SQLite tables.
ALTER TABLE kandev_meta ADD COLUMN latest_version TEXT;
ALTER TABLE kandev_meta ADD COLUMN latest_version_url TEXT;
ALTER TABLE kandev_meta ADD COLUMN latest_version_checked_at INTEGER;
(If kandev_meta is key/value-shaped, write three rows with these keys instead.)
State machine — long-running jobs
Jobs (vacuum, optimize, reset, restore, snapshot create, disk walk) progress through:
queued → running → succeeded
↘ failed
Each transition publishes system.job.update with { jobId, kind, state, message, result? } on the event bus. Reset and restore additionally emit a system.restart.pending event that the frontend uses to switch into a "waiting for backend" state.
Permissions
All System endpoints require the same "logged-in install user" check as the existing /api/v1/settings/* endpoints — there is no admin tier in v1. Factory reset and restore endpoints additionally validate the confirm body field server-side as a defence-in-depth check against accidental fetches.
Failure modes
- GitHub poll failure — log + keep the previous
kandev_metarow;/api/v1/system/updatesreturns the stale value. - Disk walk failure (permission error on a subdir) — return the partial result with a
warnings: [...]array per subdir; the page renders a per-row warning icon. - VACUUM failure — DB is unaffected (VACUUM is atomic); the job ends
failedwith the SQLite error string. Status page shows a recoverable error issue. - Factory reset failure mid-run — the pre-reset snapshot remains in
<data-dir>/backups/; the user can restore it from the Backups page on next boot. Recovery is documented inline in the failure UI. - Restore failure — original DB file is left untouched; restore writes to a temp file and atomic-renames only on success.
- Log file missing / unreadable — viewer renders an empty state with the file path so the user can investigate manually.
Persistence guarantees
kandev_metawrites are SQLite-atomic.- Snapshot files in
<data-dir>/backups/are created viaVACUUM INTO <tmp>then atomic-renamed; partial files cannot appear. - Restore writes the snapshot to
<data-dir>/kandev.db.newand atomic-renames overkandev.dbafter closing all pool connections; on failure the original file is intact. - The existing boot-time backup retention (newest 2) is preserved, but only auto-snapshots are subject to it. Snapshots are distinguished by filename prefix:
auto-<version>-<ts>.db— created automatically on a version-change boot or as the pre-reset snapshot before factory reset. Pruned by the existing "keep newest 2" rule (operating only on files with theauto-prefix).manual-<ts>.db— created by the user clicking Create snapshot in the Backups page. Never auto-pruned; lives until the user deletes it explicitly from the same page.
- The existing pre-migration backup path is updated to use the
auto-prefix (a one-shot rename of existing snapshots happens on first boot after this change, treating any extension-less / unprefixed*.dbfile under<data-dir>/backups/asauto-<existing-name>for retention purposes).
Out of scope (v1)
- Live log tail (WS streaming of new lines as they're written).
- Channel-aware upgrade hints (npm vs homebrew vs binary install path).
- Admin/role gating of destructive actions; everyone who can sign in can VACUUM/Reset.
- Importing or analyzing older log files beyond filename listing + download.
- Inline editing of
kandev_metaor other system tables. - A separate top-level "System" nav entry alongside "Settings" (chose nested-in-Settings instead).
- Tasks/Cron page (Radarr-equivalent) — kandev's office routines surface already covers this.
- Events page — covered by the office activity feed.
Future scope
- Live log tail via WS subscription.
- Channel-aware update flow (detect install method, surface the right upgrade command, optionally invoke
kandev upgradefrom the UI for binary installs). - Selective reset (drop only tasks, only sessions, etc.) — only if users actually need finer-grained recovery than Restore-from-snapshot.
- Backup scheduling (e.g., daily snapshot) beyond the existing pre-migration trigger.
- Health-issue → action wiring (e.g., a "VACUUM" CTA inline on the "Database is XYZ MB" health issue).
Open questions
- Should the Updates page show only the latest release entry, or the full changelog history with the running version highlighted? Default: full history, running version highlighted, matches the existing
/settings/changelogpage.