detached ac2dcc4

feat(daemon): announce disabled persistence at attach

When the daemon can't open its session key-value store, persistence
was silently disabled with only a log line. Queue a global WARN
notice right after daemon construction so the first attaching client
sees it, using Task 3's pending-global drain machinery.
commit
ac2dcc4551c89904b405e6c66b4c74f04db5a631
author
brice · · 2026-07-20 18:50 UTC
parent
778ed2e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +37 -0
M internal/app/run.go +6 -0
@@ -451,13 +451,19 @@ func runDaemon() (retErr error) {
451451 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
452452 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
453453 storePath := persist.StorePath(platform.StateDir())
454+ var storeErr error
454455 if store, err := kv.Open(storePath); err != nil {
455456 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
457+ storeErr = err
456458 } else {
457459 log.Info("session persistence enabled", "path", storePath)
458460 daemonOpts = append(daemonOpts, daemon.WithStore(store))
459461 }
460462 d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
463+ if storeErr != nil {
464+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled,
465+ "session persistence is disabled; sessions will not survive daemon restarts", storeErr)
466+ }
461467 watchCtx, stopWatch := context.WithCancel(ctx)
462468 defer stopWatch()
463469 go func() {
@@ -451,13 +451,19 @@ func runDaemon() (retErr error) {
451 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))451 daemonOpts = append(daemonOpts, daemon.WithProcessInspector(platform.NewProcessInspector()), daemon.WithDirOrHome(platform.DirOrHome))
452 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))452 daemonOpts = append(daemonOpts, daemon.WithSnapshotStore(snapshotadapter.NewStore(snapshotDir())))
453 storePath := persist.StorePath(platform.StateDir())453 storePath := persist.StorePath(platform.StateDir())
454+ var storeErr error
454 if store, err := kv.Open(storePath); err != nil {455 if store, err := kv.Open(storePath); err != nil {
455 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)456 log.Warn("opening session store failed; persistence disabled", "path", storePath, "err", err)
457+ storeErr = err
456 } else {458 } else {
457 log.Info("session persistence enabled", "path", storePath)459 log.Info("session persistence enabled", "path", storePath)
458 daemonOpts = append(daemonOpts, daemon.WithStore(store))460 daemonOpts = append(daemonOpts, daemon.WithStore(store))
459 }461 }
460 d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)462 d := daemon.New(pty.NewFactory(), clk, log, daemonOpts...)
463+ if storeErr != nil {
464+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled,
465+ "session persistence is disabled; sessions will not survive daemon restarts", storeErr)
466+ }
461 watchCtx, stopWatch := context.WithCancel(ctx)467 watchCtx, stopWatch := context.WithCancel(ctx)
462 defer stopWatch()468 defer stopWatch()
463 go func() {469 go func() {
M internal/usecase/daemon/notify_test.go +31 -0
@@ -443,6 +443,37 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
443443 require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
444444 }
445445
446+// TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open
447+// failure contract (Task 13): app/run.go queues this exact code and message
448+// via NotifyGlobal before any client has attached, and the first attach must
449+// see it via firstPaint's pending-queue drain.
450+func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
451+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
452+ sess.mu.Lock()
453+ sess.client = nil
454+ sess.mu.Unlock()
455+
456+ cause := errors.New("open store.db: permission denied")
457+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled,
458+ "session persistence is disabled; sessions will not survive daemon restarts", cause)
459+
460+ require.Len(t, d.notices.history(), 1)
461+ toasts, _ := visibleToasts(ac)
462+ require.Empty(t, toasts, "no attached client means nothing to paint yet")
463+
464+ // Re-attach and let firstPaint drain the pending queue.
465+ sess.mu.Lock()
466+ sess.client = ac
467+ sess.mu.Unlock()
468+ d.firstPaint(sess, ac, domain.Size{})
469+
470+ drained := awaitToastCount(t, ac, 1)
471+ require.Equal(t, domain.NoticePersistDisabled, drained[0].Code)
472+ require.Equal(t, domain.NoticeWarn, drained[0].Severity)
473+ require.Equal(t, "session persistence is disabled; sessions will not survive daemon restarts", drained[0].Message)
474+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
475+}
476+
446477 // levelCaptureHandler records the slog.Level of every record it's handed, so
447478 // tests can assert on log verbosity without parsing formatted output.
448479 type levelCaptureHandler struct {
@@ -443,6 +443,37 @@ func TestNotifyGlobalQueuesWhenUnattached(t *testing.T) {
443 require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")443 require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
444 }444 }
445 445
446+// TestNotifyGlobalPersistDisabledDrainsAtFirstAttach guards the store-open
447+// failure contract (Task 13): app/run.go queues this exact code and message
448+// via NotifyGlobal before any client has attached, and the first attach must
449+// see it via firstPaint's pending-queue drain.
450+func TestNotifyGlobalPersistDisabledDrainsAtFirstAttach(t *testing.T) {
451+ d, sess, ac, _ := newNoticeFixture(t, newNoticeClock())
452+ sess.mu.Lock()
453+ sess.client = nil
454+ sess.mu.Unlock()
455+
456+ cause := errors.New("open store.db: permission denied")
457+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticePersistDisabled,
458+ "session persistence is disabled; sessions will not survive daemon restarts", cause)
459+
460+ require.Len(t, d.notices.history(), 1)
461+ toasts, _ := visibleToasts(ac)
462+ require.Empty(t, toasts, "no attached client means nothing to paint yet")
463+
464+ // Re-attach and let firstPaint drain the pending queue.
465+ sess.mu.Lock()
466+ sess.client = ac
467+ sess.mu.Unlock()
468+ d.firstPaint(sess, ac, domain.Size{})
469+
470+ drained := awaitToastCount(t, ac, 1)
471+ require.Equal(t, domain.NoticePersistDisabled, drained[0].Code)
472+ require.Equal(t, domain.NoticeWarn, drained[0].Severity)
473+ require.Equal(t, "session persistence is disabled; sessions will not survive daemon restarts", drained[0].Message)
474+ require.Empty(t, d.notices.drainPending(), "firstPaint must consume the queue")
475+}
476+
446 // levelCaptureHandler records the slog.Level of every record it's handed, so477 // levelCaptureHandler records the slog.Level of every record it's handed, so
447 // tests can assert on log verbosity without parsing formatted output.478 // tests can assert on log verbosity without parsing formatted output.
448 type levelCaptureHandler struct {479 type levelCaptureHandler struct {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close