detached 84be71d

refactor(wire): collapse recovery states into one session state

commit
84be71d178b20ff8fed5038347659cff7bb03483
author
brice · · 2026-07-26 11:57 UTC
parent
5b0ff57
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +242 -341
M internal/app/daemon_integration_test.go +3 -3
@@ -493,7 +493,7 @@ func TestOfflineKillWaitsForLifecycleOwner(t *testing.T) {
493493 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
494494 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
495495 now := time.Now().UnixNano()
496- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
496+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now}))
497497 require.NoError(t, p.Close())
498498 owner, err := lifecycle.TryAcquire(ipc.SocketDir())
499499 require.NoError(t, err)
@@ -533,7 +533,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
533533 Catalogue: newTestPersister(t, stateDir),
534534 closed: make(chan struct{}),
535535 }
536- require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}))
536+ require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}}))
537537 repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
538538 shutdownClock := newLifecycleShutdownClock(t)
539539 maintenanceRepository := &lifecycleBlockingMaintenanceRepository{
@@ -764,7 +764,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap
764764 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("checkpoint me\n")})}))
765765 require.Eventually(t, func() bool {
766766 record, ok, _ := opened.Catalogue.Record(name)
767- return ok && record.RecoveryState == domain.RecoveryHealthy && record.Committed != nil
767+ return ok && record.Committed != nil && record.DegradedReason == ""
768768 }, 5*time.Second, 10*time.Millisecond, "session never committed a checkpoint")
769769 require.NoError(t, tr.Close())
770770
@@ -493,7 +493,7 @@ func TestOfflineKillWaitsForLifecycleOwner(t *testing.T) {
493 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)493 t.Setenv("XDG_RUNTIME_DIR", runtimeRoot)
494 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))494 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
495 now := time.Now().UnixNano()495 now := time.Now().UnixNano()
496- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))496+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, CreatedAt: now, UpdatedAt: now}))
497 require.NoError(t, p.Close())497 require.NoError(t, p.Close())
498 owner, err := lifecycle.TryAcquire(ipc.SocketDir())498 owner, err := lifecycle.TryAcquire(ipc.SocketDir())
499 require.NoError(t, err)499 require.NoError(t, err)
@@ -533,7 +533,7 @@ func TestLifecycleOwnershipOutlivesMaintenanceWriter(t *testing.T) {
533 Catalogue: newTestPersister(t, stateDir),533 Catalogue: newTestPersister(t, stateDir),
534 closed: make(chan struct{}),534 closed: make(chan struct{}),
535 }535 }
536- require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}))536+ require.NoError(t, catalogue.Create(domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}}))
537 repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))537 repository := snapshot.NewRepository(filepath.Join(stateDir, "snapshots"))
538 shutdownClock := newLifecycleShutdownClock(t)538 shutdownClock := newLifecycleShutdownClock(t)
539 maintenanceRepository := &lifecycleBlockingMaintenanceRepository{539 maintenanceRepository := &lifecycleBlockingMaintenanceRepository{
@@ -764,7 +764,7 @@ func publishRestorableCheckpoint(t *testing.T, stateDir string, repository *snap
764 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("checkpoint me\n")})}))764 require.NoError(t, tr.Send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("checkpoint me\n")})}))
765 require.Eventually(t, func() bool {765 require.Eventually(t, func() bool {
766 record, ok, _ := opened.Catalogue.Record(name)766 record, ok, _ := opened.Catalogue.Record(name)
767- return ok && record.RecoveryState == domain.RecoveryHealthy && record.Committed != nil767+ return ok && record.Committed != nil && record.DegradedReason == ""
768 }, 5*time.Second, 10*time.Millisecond, "session never committed a checkpoint")768 }, 5*time.Second, 10*time.Millisecond, "session never committed a checkpoint")
769 require.NoError(t, tr.Close())769 require.NoError(t, tr.Close())
770 770
M internal/app/offline_kill_retry_test.go +2 -2
@@ -21,7 +21,7 @@ func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) {
2121
2222 p := newTestPersister(t, stateDir)
2323 now := time.Now().UnixNano()
24- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
24+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
2525 require.NoError(t, p.Close())
2626
2727 original := openCatalogue
@@ -65,7 +65,7 @@ func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
6565 id := domain.IncarnationID{1}
6666 p := newTestPersister(t, stateDir)
6767 now := time.Now().UnixNano()
68- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))
68+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
6969 require.NoError(t, p.Close())
7070 source := filepath.Join(stateDir, "snapshots", "sessions", id.String())
7171 require.NoError(t, os.MkdirAll(source, 0o700))
@@ -21,7 +21,7 @@ func TestRunOfflineNamedKillUsesOpenCatalogueSeam(t *testing.T) {
21 21
22 p := newTestPersister(t, stateDir)22 p := newTestPersister(t, stateDir)
23 now := time.Now().UnixNano()23 now := time.Now().UnixNano()
24- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))24+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
25 require.NoError(t, p.Close())25 require.NoError(t, p.Close())
26 26
27 original := openCatalogue27 original := openCatalogue
@@ -65,7 +65,7 @@ func TestRunKillOfflineUsesIncarnationDeletionProtocol(t *testing.T) {
65 id := domain.IncarnationID{1}65 id := domain.IncarnationID{1}
66 p := newTestPersister(t, stateDir)66 p := newTestPersister(t, stateDir)
67 now := time.Now().UnixNano()67 now := time.Now().UnixNano()
68- require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}))68+ require.NoError(t, p.Save(persist.Record{Name: "named", IncarnationID: id, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}))
69 require.NoError(t, p.Close())69 require.NoError(t, p.Close())
70 source := filepath.Join(stateDir, "snapshots", "sessions", id.String())70 source := filepath.Join(stateDir, "snapshots", "sessions", id.String())
71 require.NoError(t, os.MkdirAll(source, 0o700))71 require.NoError(t, os.MkdirAll(source, 0o700))
M internal/app/run.go +12 -15
@@ -505,18 +505,18 @@ func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, re
505505 }
506506
507507 func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {
508- healthy, fresh, degraded := 0, 0, 0
508+ healthy, fresh, broken := 0, 0, 0
509509 for _, record := range records {
510- switch record.RecoveryState {
511- case domain.RecoveryHealthy:
512- healthy++
513- case domain.RecoveryFresh:
510+ switch {
511+ case record.DegradedReason != "":
512+ broken++
513+ case record.Committed == nil:
514514 fresh++
515- case domain.RecoveryDegraded:
516- degraded++
515+ default:
516+ healthy++
517517 }
518518 }
519- log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "degraded", degraded)
519+ log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken)
520520 }
521521
522522 func constructDaemonBeforeSocketPublication(
@@ -1290,8 +1290,8 @@ func runList(ctx context.Context) (retErr error) {
12901290 infos := make([]ports.SessionInfo, 0, len(records))
12911291 for _, r := range records {
12921292 state := ports.SessionStopped
1293- if r.RecoveryState == domain.RecoveryDegraded || r.RecoveryState == domain.RecoveryDeleting {
1294- state = ports.SessionDegraded
1293+ if r.DegradedReason != "" {
1294+ state = ports.SessionBroken
12951295 }
12961296 infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})
12971297 }
@@ -1338,11 +1338,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
13381338 case ports.SessionStopped:
13391339 state = "stopped"
13401340 tabs = "-"
1341- case ports.SessionRestoring:
1342- state = "restoring"
1343- tabs = "-"
1344- case ports.SessionDegraded:
1345- state = "degraded"
1341+ case ports.SessionBroken:
1342+ state = "broken"
13461343 tabs = "-"
13471344 default:
13481345 if s.Ephemeral {
@@ -505,18 +505,18 @@ func logCatalogueRecovery(log *slog.Logger, records []domain.CatalogueRecord, re
505 }505 }
506 506
507 func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {507 func logStartupRecoveryCounts(log *slog.Logger, records []domain.CatalogueRecord, restoring int) {
508- healthy, fresh, degraded := 0, 0, 0508+ healthy, fresh, broken := 0, 0, 0
509 for _, record := range records {509 for _, record := range records {
510- switch record.RecoveryState {510+ switch {
511- case domain.RecoveryHealthy:511+ case record.DegradedReason != "":
512- healthy++512+ broken++
513- case domain.RecoveryFresh:513+ case record.Committed == nil:
514 fresh++514 fresh++
515- case domain.RecoveryDegraded:515+ default:
516- degraded++516+ healthy++
517 }517 }
518 }518 }
519- log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "degraded", degraded)519+ log.Info("daemon_startup_complete", "healthy", healthy, "fresh", fresh, "restoring", restoring, "broken", broken)
520 }520 }
521 521
522 func constructDaemonBeforeSocketPublication(522 func constructDaemonBeforeSocketPublication(
@@ -1290,8 +1290,8 @@ func runList(ctx context.Context) (retErr error) {
1290 infos := make([]ports.SessionInfo, 0, len(records))1290 infos := make([]ports.SessionInfo, 0, len(records))
1291 for _, r := range records {1291 for _, r := range records {
1292 state := ports.SessionStopped1292 state := ports.SessionStopped
1293- if r.RecoveryState == domain.RecoveryDegraded || r.RecoveryState == domain.RecoveryDeleting {1293+ if r.DegradedReason != "" {
1294- state = ports.SessionDegraded1294+ state = ports.SessionBroken
1295 }1295 }
1296 infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})1296 infos = append(infos, ports.SessionInfo{Name: r.Name, State: state})
1297 }1297 }
@@ -1338,11 +1338,8 @@ func printSessions(w io.Writer, sessions []ports.SessionInfo) {
1338 case ports.SessionStopped:1338 case ports.SessionStopped:
1339 state = "stopped"1339 state = "stopped"
1340 tabs = "-"1340 tabs = "-"
1341- case ports.SessionRestoring:1341+ case ports.SessionBroken:
1342- state = "restoring"1342+ state = "broken"
1343- tabs = "-"
1344- case ports.SessionDegraded:
1345- state = "degraded"
1346 tabs = "-"1343 tabs = "-"
1347 default:1344 default:
1348 if s.Ephemeral {1345 if s.Ephemeral {
M internal/app/run_test.go +13 -12
@@ -74,11 +74,12 @@ func TestRecoveryObservability(t *testing.T) {
7474 },
7575 log: log,
7676 }
77+ ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
7778 records := []domain.CatalogueRecord{
78- {RecoveryState: domain.RecoveryHealthy},
79- {RecoveryState: domain.RecoveryHealthy},
80- {RecoveryState: domain.RecoveryFresh},
81- {RecoveryState: domain.RecoveryDegraded},
79+ {Committed: ref},
80+ {Committed: ref},
81+ {},
82+ {Committed: ref, DegradedReason: "checkpoint validation failed"},
8283 }
8384 require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
8485 logCatalogueRecovery(log, records, "current")
@@ -94,7 +95,7 @@ func TestRecoveryObservability(t *testing.T) {
9495 require.EqualValues(t, 2, startup["healthy"])
9596 require.EqualValues(t, 1, startup["fresh"])
9697 require.EqualValues(t, 0, startup["restoring"])
97- require.EqualValues(t, 1, startup["degraded"])
98+ require.EqualValues(t, 1, startup["broken"])
9899 require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
99100 }
100101
@@ -640,19 +641,19 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
640641 }
641642 }
642643
643-func TestListShowsDegraded(t *testing.T) {
644+func TestListShowsBroken(t *testing.T) {
644645 var out bytes.Buffer
645646 printSessions(&out, []ports.SessionInfo{
646647 {Name: "fresh", State: ports.SessionStopped},
647- {Name: "loading", State: ports.SessionRestoring},
648- {Name: "broken", State: ports.SessionDegraded},
648+ {Name: "loading", State: ports.SessionStopped},
649+ {Name: "broken", State: ports.SessionBroken},
649650 })
650651 require.Contains(t, out.String(), "fresh")
651652 require.Contains(t, out.String(), "stopped")
652653 require.Contains(t, out.String(), "loading")
653- require.Contains(t, out.String(), "restoring")
654654 require.Contains(t, out.String(), "broken")
655- require.Contains(t, out.String(), "degraded")
655+ require.NotContains(t, out.String(), "restoring")
656+ require.NotContains(t, out.String(), "degraded")
656657 }
657658
658659 func TestPrintSessionsShowsStoppedState(t *testing.T) {
@@ -696,7 +697,7 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
696697
697698 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
698699 now := time.Now().UnixNano()
699- if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil {
700+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
700701 t.Fatalf("Save error = %v", err)
701702 }
702703 if err := p.Close(); err != nil {
@@ -736,7 +737,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
736737
737738 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
738739 now := time.Now().UnixNano()
739- if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil {
740+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
740741 t.Fatalf("Save error = %v", err)
741742 }
742743 if err := p.Close(); err != nil {
@@ -74,11 +74,12 @@ func TestRecoveryObservability(t *testing.T) {
74 },74 },
75 log: log,75 log: log,
76 }76 }
77+ ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
77 records := []domain.CatalogueRecord{78 records := []domain.CatalogueRecord{
78- {RecoveryState: domain.RecoveryHealthy},79+ {Committed: ref},
79- {RecoveryState: domain.RecoveryHealthy},80+ {Committed: ref},
80- {RecoveryState: domain.RecoveryFresh},81+ {},
81- {RecoveryState: domain.RecoveryDegraded},82+ {Committed: ref, DegradedReason: "checkpoint validation failed"},
82 }83 }
83 require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {84 require.NoError(t, runWithLifecycleOwnerDeps(context.Background(), "/runtime/vev", "/state/vev", func(context.Context) error {
84 logCatalogueRecovery(log, records, "current")85 logCatalogueRecovery(log, records, "current")
@@ -94,7 +95,7 @@ func TestRecoveryObservability(t *testing.T) {
94 require.EqualValues(t, 2, startup["healthy"])95 require.EqualValues(t, 2, startup["healthy"])
95 require.EqualValues(t, 1, startup["fresh"])96 require.EqualValues(t, 1, startup["fresh"])
96 require.EqualValues(t, 0, startup["restoring"])97 require.EqualValues(t, 0, startup["restoring"])
97- require.EqualValues(t, 1, startup["degraded"])98+ require.EqualValues(t, 1, startup["broken"])
98 require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")99 require.NotContains(t, eventNames(entries), "interrupted_transaction_recovery_complete")
99 }100 }
100 101
@@ -640,19 +641,19 @@ func TestParseArgsNewRejectsUnsafeSessionName(t *testing.T) {
640 }641 }
641 }642 }
642 643
643-func TestListShowsDegraded(t *testing.T) {644+func TestListShowsBroken(t *testing.T) {
644 var out bytes.Buffer645 var out bytes.Buffer
645 printSessions(&out, []ports.SessionInfo{646 printSessions(&out, []ports.SessionInfo{
646 {Name: "fresh", State: ports.SessionStopped},647 {Name: "fresh", State: ports.SessionStopped},
647- {Name: "loading", State: ports.SessionRestoring},648+ {Name: "loading", State: ports.SessionStopped},
648- {Name: "broken", State: ports.SessionDegraded},649+ {Name: "broken", State: ports.SessionBroken},
649 })650 })
650 require.Contains(t, out.String(), "fresh")651 require.Contains(t, out.String(), "fresh")
651 require.Contains(t, out.String(), "stopped")652 require.Contains(t, out.String(), "stopped")
652 require.Contains(t, out.String(), "loading")653 require.Contains(t, out.String(), "loading")
653- require.Contains(t, out.String(), "restoring")
654 require.Contains(t, out.String(), "broken")654 require.Contains(t, out.String(), "broken")
655- require.Contains(t, out.String(), "degraded")655+ require.NotContains(t, out.String(), "restoring")
656+ require.NotContains(t, out.String(), "degraded")
656 }657 }
657 658
658 func TestPrintSessionsShowsStoppedState(t *testing.T) {659 func TestPrintSessionsShowsStoppedState(t *testing.T) {
@@ -696,7 +697,7 @@ func TestRunListReadsStoppedSessionsWithoutDaemon(t *testing.T) {
696 697
697 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))698 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
698 now := time.Now().UnixNano()699 now := time.Now().UnixNano()
699- if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil {700+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
700 t.Fatalf("Save error = %v", err)701 t.Fatalf("Save error = %v", err)
701 }702 }
702 if err := p.Close(); err != nil {703 if err := p.Close(); err != nil {
@@ -736,7 +737,7 @@ func TestRunKillDeletesStoppedSessionWithoutDaemon(t *testing.T) {
736 737
737 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))738 p := newTestPersister(t, filepath.Join(stateRoot, "vev"))
738 now := time.Now().UnixNano()739 now := time.Now().UnixNano()
739- if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now, RecoveryState: domain.RecoveryFresh}); err != nil {740+ if err := p.Save(persist.Record{Name: "stored", IncarnationID: domain.IncarnationID{1}, Cwd: t.TempDir(), CreatedAt: now, UpdatedAt: now}); err != nil {
740 t.Fatalf("Save error = %v", err)741 t.Fatalf("Save error = %v", err)
741 }742 }
742 if err := p.Close(); err != nil {743 if err := p.Close(); err != nil {
M internal/domain/recovery.go +4 -37
@@ -12,16 +12,6 @@ import (
1212 // IncarnationID permanently distinguishes uses of the same session name.
1313 type IncarnationID [16]byte
1414
15-// RecoveryState describes whether and how a named session can be restored.
16-type RecoveryState uint8
17-
18-const (
19- RecoveryFresh RecoveryState = iota + 1
20- RecoveryHealthy
21- RecoveryDegraded
22- RecoveryDeleting
23-)
24-
2515 // CheckpointRef identifies one immutable snapshot manifest.
2616 type CheckpointRef struct {
2717 Generation uint64
@@ -37,7 +27,6 @@ type CatalogueRecord struct {
3727 UpdatedAt int64
3828 LastUsedSeq uint64
3929 TabNames []string
40- RecoveryState RecoveryState
4130 Committed *CheckpointRef
4231 DegradedReason string
4332 }
@@ -55,8 +44,8 @@ func (r *CheckpointRef) Equal(other *CheckpointRef) bool {
5544 func (r CatalogueRecord) Equal(other CatalogueRecord) bool {
5645 return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd &&
5746 r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq &&
58- slices.Equal(r.TabNames, other.TabNames) && r.RecoveryState == other.RecoveryState &&
59- r.Committed.Equal(other.Committed) && r.DegradedReason == other.DegradedReason
47+ slices.Equal(r.TabNames, other.TabNames) && r.Committed.Equal(other.Committed) &&
48+ r.DegradedReason == other.DegradedReason
6049 }
6150
6251 // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue
@@ -130,30 +119,8 @@ func (r CatalogueRecord) Validate() error {
130119 return errors.New("zero catalogue incarnation ID")
131120 }
132121
133- switch r.RecoveryState {
134- case RecoveryFresh:
135- if r.Committed != nil {
136- return errors.New("fresh session has checkpoint reference")
137- }
138- case RecoveryHealthy:
139- if r.Committed == nil {
140- return errors.New("healthy session has no committed checkpoint")
141- }
142- case RecoveryDegraded:
143- if r.Committed == nil {
144- return errors.New("degraded session has no committed checkpoint")
145- }
146- if r.DegradedReason == "" {
147- return errors.New("degraded session has no reason")
148- }
149- case RecoveryDeleting:
150- // A fresh or checkpointed session may be deleted.
151- default:
152- return errors.New("invalid recovery state")
153- }
154-
155- if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" {
156- return errors.New("non-degraded session has degraded reason")
122+ if r.DegradedReason != "" && r.Committed == nil {
123+ return errors.New("broken session has no committed checkpoint")
157124 }
158125 if r.Committed != nil {
159126 if r.Committed.Generation == 0 {
@@ -12,16 +12,6 @@ import (
12 // IncarnationID permanently distinguishes uses of the same session name.12 // IncarnationID permanently distinguishes uses of the same session name.
13 type IncarnationID [16]byte13 type IncarnationID [16]byte
14 14
15-// RecoveryState describes whether and how a named session can be restored.
16-type RecoveryState uint8
17-
18-const (
19- RecoveryFresh RecoveryState = iota + 1
20- RecoveryHealthy
21- RecoveryDegraded
22- RecoveryDeleting
23-)
24-
25 // CheckpointRef identifies one immutable snapshot manifest.15 // CheckpointRef identifies one immutable snapshot manifest.
26 type CheckpointRef struct {16 type CheckpointRef struct {
27 Generation uint6417 Generation uint64
@@ -37,7 +27,6 @@ type CatalogueRecord struct {
37 UpdatedAt int6427 UpdatedAt int64
38 LastUsedSeq uint6428 LastUsedSeq uint64
39 TabNames []string29 TabNames []string
40- RecoveryState RecoveryState
41 Committed *CheckpointRef30 Committed *CheckpointRef
42 DegradedReason string31 DegradedReason string
43 }32 }
@@ -55,8 +44,8 @@ func (r *CheckpointRef) Equal(other *CheckpointRef) bool {
55 func (r CatalogueRecord) Equal(other CatalogueRecord) bool {44 func (r CatalogueRecord) Equal(other CatalogueRecord) bool {
56 return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd &&45 return r.Name == other.Name && r.IncarnationID == other.IncarnationID && r.Cwd == other.Cwd &&
57 r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq &&46 r.CreatedAt == other.CreatedAt && r.UpdatedAt == other.UpdatedAt && r.LastUsedSeq == other.LastUsedSeq &&
58- slices.Equal(r.TabNames, other.TabNames) && r.RecoveryState == other.RecoveryState &&47+ slices.Equal(r.TabNames, other.TabNames) && r.Committed.Equal(other.Committed) &&
59- r.Committed.Equal(other.Committed) && r.DegradedReason == other.DegradedReason48+ r.DegradedReason == other.DegradedReason
60 }49 }
61 50
62 // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue51 // CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue
@@ -130,30 +119,8 @@ func (r CatalogueRecord) Validate() error {
130 return errors.New("zero catalogue incarnation ID")119 return errors.New("zero catalogue incarnation ID")
131 }120 }
132 121
133- switch r.RecoveryState {122+ if r.DegradedReason != "" && r.Committed == nil {
134- case RecoveryFresh:123+ return errors.New("broken session has no committed checkpoint")
135- if r.Committed != nil {
136- return errors.New("fresh session has checkpoint reference")
137- }
138- case RecoveryHealthy:
139- if r.Committed == nil {
140- return errors.New("healthy session has no committed checkpoint")
141- }
142- case RecoveryDegraded:
143- if r.Committed == nil {
144- return errors.New("degraded session has no committed checkpoint")
145- }
146- if r.DegradedReason == "" {
147- return errors.New("degraded session has no reason")
148- }
149- case RecoveryDeleting:
150- // A fresh or checkpointed session may be deleted.
151- default:
152- return errors.New("invalid recovery state")
153- }
154-
155- if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" {
156- return errors.New("non-degraded session has degraded reason")
157 }124 }
158 if r.Committed != nil {125 if r.Committed != nil {
159 if r.Committed.Generation == 0 {126 if r.Committed.Generation == 0 {
M internal/domain/recovery_test.go +9 -13
@@ -37,18 +37,14 @@ func TestCatalogueRecordValidate(t *testing.T) {
3737 record CatalogueRecord
3838 wantValid bool
3939 }{
40- {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}, wantValid: true},
41- {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true},
42- {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}},
43- {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}},
44- {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}},
45- {name: "fresh with committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh, Committed: validRef}},
46- {name: "healthy without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy}},
47- {name: "degraded without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, DegradedReason: "missing manifest"}},
48- {name: "degraded without reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, Committed: validRef}},
49- {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}},
50- {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
51- {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}},
40+ {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}}, wantValid: true},
41+ {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true},
42+ {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}}},
43+ {name: "zero incarnation", record: CatalogueRecord{Name: "work"}},
44+ {name: "broken with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: validRef, DegradedReason: "missing manifest"}, wantValid: true},
45+ {name: "broken without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, DegradedReason: "missing manifest"}},
46+ {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
47+ {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7}}},
5248 }
5349 for _, tt := range tests {
5450 t.Run(tt.name, func(t *testing.T) {
@@ -64,7 +60,7 @@ func TestCatalogueRecordValidate(t *testing.T) {
6460
6561 func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) {
6662 committed := CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
67- left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, RecoveryState: RecoveryHealthy, Committed: &committed}
63+ left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, Committed: &committed}
6864 right := left
6965 right.TabNames = append([]string(nil), left.TabNames...)
7066 committedCopy := committed
@@ -37,18 +37,14 @@ func TestCatalogueRecordValidate(t *testing.T) {
37 record CatalogueRecord37 record CatalogueRecord
38 wantValid bool38 wantValid bool
39 }{39 }{
40- {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}, wantValid: true},40+ {name: "fresh valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}}, wantValid: true},
41- {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true},41+ {name: "healthy with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}}}, wantValid: true},
42- {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}},42+ {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}}},
43- {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}},43+ {name: "zero incarnation", record: CatalogueRecord{Name: "work"}},
44- {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}},44+ {name: "broken with committed checkpoint valid", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: validRef, DegradedReason: "missing manifest"}, wantValid: true},
45- {name: "fresh with committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh, Committed: validRef}},45+ {name: "broken without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, DegradedReason: "missing manifest"}},
46- {name: "healthy without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy}},46+ {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
47- {name: "degraded without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, DegradedReason: "missing manifest"}},47+ {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, Committed: &CheckpointRef{Generation: 7}}},
48- {name: "degraded without reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, Committed: validRef}},
49- {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}},
50- {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}},
51- {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}},
52 }48 }
53 for _, tt := range tests {49 for _, tt := range tests {
54 t.Run(tt.name, func(t *testing.T) {50 t.Run(tt.name, func(t *testing.T) {
@@ -64,7 +60,7 @@ func TestCatalogueRecordValidate(t *testing.T) {
64 60
65 func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) {61 func TestCatalogueRecordEqualUsesCheckpointValuesAndOrderedTabs(t *testing.T) {
66 committed := CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}62 committed := CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
67- left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, RecoveryState: RecoveryHealthy, Committed: &committed}63+ left := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, TabNames: []string{"shell", "logs"}, Committed: &committed}
68 right := left64 right := left
69 right.TabNames = append([]string(nil), left.TabNames...)65 right.TabNames = append([]string(nil), left.TabNames...)
70 committedCopy := committed66 committedCopy := committed
M internal/persist/catalogue_test.go +12 -13
@@ -25,10 +25,10 @@ func TestCatalogue(t *testing.T) {
2525 func testCatalogueRecordRoundTrip(t *testing.T) {
2626 ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
2727 tests := []domain.CatalogueRecord{
28- {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", RecoveryState: domain.RecoveryFresh},
29- {Name: "healthy", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy, Committed: ref1},
30- {Name: "degraded", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, Committed: ref1, DegradedReason: "invalid manifest"},
31- {Name: "deleting", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryDeleting},
28+ {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp"},
29+ {Name: "healthy", IncarnationID: domain.IncarnationID{2}, Committed: ref1},
30+ {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref1, DegradedReason: "invalid manifest"},
31+ {Name: "other-fresh", IncarnationID: domain.IncarnationID{5}},
3232 }
3333 for _, record := range tests {
3434 t.Run(record.Name, func(t *testing.T) {
@@ -46,17 +46,16 @@ func testCatalogueRecordRoundTrip(t *testing.T) {
4646 }
4747 }
4848
49-func TestDecodeRecordValueWrapsMalformedAndValidationErrors(t *testing.T) {
49+func TestDecodeRecordValueRejectsMalformedCheckpointMarker(t *testing.T) {
5050 encoded, err := encodeRecordValue(validRecord("work", 1))
5151 require.NoError(t, err)
52- encoded[recoveryStateFieldOffset(t, encoded)] = 99
52+ encoded[checkpointMarkerOffset(t, encoded)] = 99
5353
5454 _, err = decodeRecordValue("work", encoded)
5555 require.ErrorIs(t, err, errMalformedRecord)
56- require.ErrorContains(t, err, "invalid recovery state")
5756 }
5857
59-func recoveryStateFieldOffset(t *testing.T, encoded []byte) int {
58+func checkpointMarkerOffset(t *testing.T, encoded []byte) int {
6059 t.Helper()
6160 r := valueReader{data: encoded}
6261 _, ok := r.take(len(catalogueMagic) + 2 + len(domain.IncarnationID{}))
@@ -77,8 +76,8 @@ func recoveryStateFieldOffset(t *testing.T, encoded []byte) int {
7776 func testCatalogueDuplicateIncarnations(t *testing.T) {
7877 id := domain.IncarnationID{1}
7978 require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{
80- {Name: "one", IncarnationID: id, RecoveryState: domain.RecoveryFresh},
81- {Name: "two", IncarnationID: id, RecoveryState: domain.RecoveryFresh},
79+ {Name: "one", IncarnationID: id},
80+ {Name: "two", IncarnationID: id},
8281 }))
8382 }
8483
@@ -105,8 +104,8 @@ func testCatalogueApplyRenameReplace(t *testing.T) {
105104 require.Empty(t, records)
106105 defer func() { require.NoError(t, p.Close()) }()
107106
108- one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}
109- two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryFresh}
107+ one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}}
108+ two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}}
110109 require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two}))
111110 renamed := one
112111 renamed.Name = "renamed"
@@ -129,7 +128,7 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
129128 original := domain.CatalogueRecord{
130129 Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11,
131130 UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"},
132- RecoveryState: domain.RecoveryDegraded, Committed: committed, DegradedReason: "repair pending",
131+ Committed: committed, DegradedReason: "repair pending",
133132 }
134133 require.NoError(t, p.Create(original))
135134
@@ -25,10 +25,10 @@ func TestCatalogue(t *testing.T) {
25 func testCatalogueRecordRoundTrip(t *testing.T) {25 func testCatalogueRecordRoundTrip(t *testing.T) {
26 ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}26 ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
27 tests := []domain.CatalogueRecord{27 tests := []domain.CatalogueRecord{
28- {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", RecoveryState: domain.RecoveryFresh},28+ {Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp"},
29- {Name: "healthy", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy, Committed: ref1},29+ {Name: "healthy", IncarnationID: domain.IncarnationID{2}, Committed: ref1},
30- {Name: "degraded", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, Committed: ref1, DegradedReason: "invalid manifest"},30+ {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref1, DegradedReason: "invalid manifest"},
31- {Name: "deleting", IncarnationID: domain.IncarnationID{5}, RecoveryState: domain.RecoveryDeleting},31+ {Name: "other-fresh", IncarnationID: domain.IncarnationID{5}},
32 }32 }
33 for _, record := range tests {33 for _, record := range tests {
34 t.Run(record.Name, func(t *testing.T) {34 t.Run(record.Name, func(t *testing.T) {
@@ -46,17 +46,16 @@ func testCatalogueRecordRoundTrip(t *testing.T) {
46 }46 }
47 }47 }
48 48
49-func TestDecodeRecordValueWrapsMalformedAndValidationErrors(t *testing.T) {49+func TestDecodeRecordValueRejectsMalformedCheckpointMarker(t *testing.T) {
50 encoded, err := encodeRecordValue(validRecord("work", 1))50 encoded, err := encodeRecordValue(validRecord("work", 1))
51 require.NoError(t, err)51 require.NoError(t, err)
52- encoded[recoveryStateFieldOffset(t, encoded)] = 9952+ encoded[checkpointMarkerOffset(t, encoded)] = 99
53 53
54 _, err = decodeRecordValue("work", encoded)54 _, err = decodeRecordValue("work", encoded)
55 require.ErrorIs(t, err, errMalformedRecord)55 require.ErrorIs(t, err, errMalformedRecord)
56- require.ErrorContains(t, err, "invalid recovery state")
57 }56 }
58 57
59-func recoveryStateFieldOffset(t *testing.T, encoded []byte) int {58+func checkpointMarkerOffset(t *testing.T, encoded []byte) int {
60 t.Helper()59 t.Helper()
61 r := valueReader{data: encoded}60 r := valueReader{data: encoded}
62 _, ok := r.take(len(catalogueMagic) + 2 + len(domain.IncarnationID{}))61 _, ok := r.take(len(catalogueMagic) + 2 + len(domain.IncarnationID{}))
@@ -77,8 +76,8 @@ func recoveryStateFieldOffset(t *testing.T, encoded []byte) int {
77 func testCatalogueDuplicateIncarnations(t *testing.T) {76 func testCatalogueDuplicateIncarnations(t *testing.T) {
78 id := domain.IncarnationID{1}77 id := domain.IncarnationID{1}
79 require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{78 require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{
80- {Name: "one", IncarnationID: id, RecoveryState: domain.RecoveryFresh},79+ {Name: "one", IncarnationID: id},
81- {Name: "two", IncarnationID: id, RecoveryState: domain.RecoveryFresh},80+ {Name: "two", IncarnationID: id},
82 }))81 }))
83 }82 }
84 83
@@ -105,8 +104,8 @@ func testCatalogueApplyRenameReplace(t *testing.T) {
105 require.Empty(t, records)104 require.Empty(t, records)
106 defer func() { require.NoError(t, p.Close()) }()105 defer func() { require.NoError(t, p.Close()) }()
107 106
108- one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}107+ one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}}
109- two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryFresh}108+ two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}}
110 require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two}))109 require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two}))
111 renamed := one110 renamed := one
112 renamed.Name = "renamed"111 renamed.Name = "renamed"
@@ -129,7 +128,7 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
129 original := domain.CatalogueRecord{128 original := domain.CatalogueRecord{
130 Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11,129 Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11,
131 UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"},130 UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"},
132- RecoveryState: domain.RecoveryDegraded, Committed: committed, DegradedReason: "repair pending",131+ Committed: committed, DegradedReason: "repair pending",
133 }132 }
134 require.NoError(t, p.Create(original))133 require.NoError(t, p.Create(original))
135 134
M internal/persist/persist_test.go +1 -1
@@ -16,7 +16,7 @@ import (
1616
1717 func privateDir(t *testing.T) string { t.Helper(); return filepath.Join(t.TempDir(), "vev") }
1818 func validRecord(name string, id byte) domain.CatalogueRecord {
19- return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}, RecoveryState: domain.RecoveryFresh}
19+ return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}}
2020 }
2121
2222 func TestOpenOrCreate(t *testing.T) {
@@ -16,7 +16,7 @@ import (
16 16
17 func privateDir(t *testing.T) string { t.Helper(); return filepath.Join(t.TempDir(), "vev") }17 func privateDir(t *testing.T) string { t.Helper(); return filepath.Join(t.TempDir(), "vev") }
18 func validRecord(name string, id byte) domain.CatalogueRecord {18 func validRecord(name string, id byte) domain.CatalogueRecord {
19- return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}, RecoveryState: domain.RecoveryFresh}19+ return domain.CatalogueRecord{Name: name, IncarnationID: domain.IncarnationID{id}}
20 }20 }
21 21
22 func TestOpenOrCreate(t *testing.T) {22 func TestOpenOrCreate(t *testing.T) {
M internal/persist/record.go +1 -7
@@ -9,7 +9,7 @@ import (
99 "github.com/bnema/vev/internal/domain"
1010 )
1111
12-const catalogueRecordVersion uint16 = 2
12+const catalogueRecordVersion uint16 = 3
1313
1414 var errMalformedRecord = errors.New("persist: malformed catalogue record")
1515
@@ -44,7 +44,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) {
4444 return nil, err
4545 }
4646 }
47- buf = append(buf, byte(record.RecoveryState))
4847 buf = appendRef(buf, record.Committed)
4948 buf, err = appendCheckedString(buf, record.DegradedReason)
5049 return buf, err
@@ -100,11 +99,6 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error
10099 }
101100 record.TabNames = append(record.TabNames, tab)
102101 }
103- state, ok := reader.byte()
104- if !ok {
105- return domain.CatalogueRecord{}, errMalformedRecord
106- }
107- record.RecoveryState = domain.RecoveryState(state)
108102 if record.Committed, ok = reader.ref(); !ok {
109103 return domain.CatalogueRecord{}, errMalformedRecord
110104 }
@@ -9,7 +9,7 @@ import (
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10 )10 )
11 11
12-const catalogueRecordVersion uint16 = 212+const catalogueRecordVersion uint16 = 3
13 13
14 var errMalformedRecord = errors.New("persist: malformed catalogue record")14 var errMalformedRecord = errors.New("persist: malformed catalogue record")
15 15
@@ -44,7 +44,6 @@ func encodeRecordValue(record domain.CatalogueRecord) ([]byte, error) {
44 return nil, err44 return nil, err
45 }45 }
46 }46 }
47- buf = append(buf, byte(record.RecoveryState))
48 buf = appendRef(buf, record.Committed)47 buf = appendRef(buf, record.Committed)
49 buf, err = appendCheckedString(buf, record.DegradedReason)48 buf, err = appendCheckedString(buf, record.DegradedReason)
50 return buf, err49 return buf, err
@@ -100,11 +99,6 @@ func decodeRecordValue(name string, value []byte) (domain.CatalogueRecord, error
100 }99 }
101 record.TabNames = append(record.TabNames, tab)100 record.TabNames = append(record.TabNames, tab)
102 }101 }
103- state, ok := reader.byte()
104- if !ok {
105- return domain.CatalogueRecord{}, errMalformedRecord
106- }
107- record.RecoveryState = domain.RecoveryState(state)
108 if record.Committed, ok = reader.ref(); !ok {102 if record.Committed, ok = reader.ref(); !ok {
109 return domain.CatalogueRecord{}, errMalformedRecord103 return domain.CatalogueRecord{}, errMalformedRecord
110 }104 }
M internal/ports/wire.go +4 -7
@@ -49,9 +49,7 @@ const (
4949 ErrNotScriptable uint16 = 7
5050 ErrInvalidCommandArgs uint16 = 8
5151 ErrNoSuchTarget uint16 = 9
52- ErrSessionRestoring uint16 = 10
53- ErrSessionDegraded uint16 = 11
54- ErrAmbiguousTarget uint16 = 12
52+ ErrAmbiguousTarget uint16 = 10
5553 ErrInternal uint16 = 255
5654 )
5755
@@ -196,10 +194,9 @@ type Kill struct {
196194 type SessionState uint8
197195
198196 const (
199- SessionRunning SessionState = iota + 1
197+ SessionRunning SessionState = iota
200198 SessionStopped
201- SessionRestoring
202- SessionDegraded
199+ SessionBroken
203200 )
204201
205202 // SessionInfo describes one session in a Sessions listing.
@@ -926,7 +923,7 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
926923 return Sessions{}, err
927924 }
928925 s.State = SessionState(state)
929- if s.State < SessionRunning || s.State > SessionDegraded {
926+ if s.State > SessionBroken {
930927 return Sessions{}, errors.New("ports: invalid session state")
931928 }
932929 sessions = append(sessions, s)
@@ -49,9 +49,7 @@ const (
49 ErrNotScriptable uint16 = 749 ErrNotScriptable uint16 = 7
50 ErrInvalidCommandArgs uint16 = 850 ErrInvalidCommandArgs uint16 = 8
51 ErrNoSuchTarget uint16 = 951 ErrNoSuchTarget uint16 = 9
52- ErrSessionRestoring uint16 = 1052+ ErrAmbiguousTarget uint16 = 10
53- ErrSessionDegraded uint16 = 11
54- ErrAmbiguousTarget uint16 = 12
55 ErrInternal uint16 = 25553 ErrInternal uint16 = 255
56 )54 )
57 55
@@ -196,10 +194,9 @@ type Kill struct {
196 type SessionState uint8194 type SessionState uint8
197 195
198 const (196 const (
199- SessionRunning SessionState = iota + 1197+ SessionRunning SessionState = iota
200 SessionStopped198 SessionStopped
201- SessionRestoring199+ SessionBroken
202- SessionDegraded
203 )200 )
204 201
205 // SessionInfo describes one session in a Sessions listing.202 // SessionInfo describes one session in a Sessions listing.
@@ -926,7 +923,7 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
926 return Sessions{}, err923 return Sessions{}, err
927 }924 }
928 s.State = SessionState(state)925 s.State = SessionState(state)
929- if s.State < SessionRunning || s.State > SessionDegraded {926+ if s.State > SessionBroken {
930 return Sessions{}, errors.New("ports: invalid session state")927 return Sessions{}, errors.New("ports: invalid session state")
931 }928 }
932 sessions = append(sessions, s)929 sessions = append(sessions, s)
M internal/ports/wire_test.go +7 -10
@@ -448,9 +448,7 @@ func TestCommandErrorCodes(t *testing.T) {
448448 require.Equal(t, uint16(7), ErrNotScriptable)
449449 require.Equal(t, uint16(8), ErrInvalidCommandArgs)
450450 require.Equal(t, uint16(9), ErrNoSuchTarget)
451- require.Equal(t, uint16(10), ErrSessionRestoring)
452- require.Equal(t, uint16(11), ErrSessionDegraded)
453- require.Equal(t, uint16(12), ErrAmbiguousTarget)
451+ require.Equal(t, uint16(10), ErrAmbiguousTarget)
454452 }
455453
456454 func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
@@ -749,10 +747,9 @@ func TestSessionInfoRecoveryState(t *testing.T) {
749747 state SessionState
750748 want []byte
751749 }{
752- {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
753- {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
754- {name: "restoring", state: SessionRestoring, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 3}},
755- {name: "degraded", state: SessionDegraded, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 4}},
750+ {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
751+ {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
752+ {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
756753 }
757754 for _, tt := range tests {
758755 t.Run(tt.name, func(t *testing.T) {
@@ -765,7 +762,7 @@ func TestSessionInfoRecoveryState(t *testing.T) {
765762 assertTrailingGarbageFails(t, payload, UnmarshalSessions)
766763 })
767764 }
768- for _, state := range []byte{0, 5} {
765+ for _, state := range []byte{3, 255} {
769766 payload := append([]byte(nil), tests[0].want...)
770767 payload[len(payload)-1] = state
771768 _, err := UnmarshalSessions(payload)
@@ -792,8 +789,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
792789 }},
793790 want: []byte{
794791 0x00, 0x02,
795- 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x01,
796- 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x02,
792+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
793+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
797794 },
798795 },
799796 }
@@ -448,9 +448,7 @@ func TestCommandErrorCodes(t *testing.T) {
448 require.Equal(t, uint16(7), ErrNotScriptable)448 require.Equal(t, uint16(7), ErrNotScriptable)
449 require.Equal(t, uint16(8), ErrInvalidCommandArgs)449 require.Equal(t, uint16(8), ErrInvalidCommandArgs)
450 require.Equal(t, uint16(9), ErrNoSuchTarget)450 require.Equal(t, uint16(9), ErrNoSuchTarget)
451- require.Equal(t, uint16(10), ErrSessionRestoring)451+ require.Equal(t, uint16(10), ErrAmbiguousTarget)
452- require.Equal(t, uint16(11), ErrSessionDegraded)
453- require.Equal(t, uint16(12), ErrAmbiguousTarget)
454 }452 }
455 453
456 func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {454 func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
@@ -749,10 +747,9 @@ func TestSessionInfoRecoveryState(t *testing.T) {
749 state SessionState747 state SessionState
750 want []byte748 want []byte
751 }{749 }{
752- {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},750+ {name: "running", state: SessionRunning, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0}},
753- {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},751+ {name: "stopped", state: SessionStopped, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1}},
754- {name: "restoring", state: SessionRestoring, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 3}},752+ {name: "broken", state: SessionBroken, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 2}},
755- {name: "degraded", state: SessionDegraded, want: []byte{0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 4}},
756 }753 }
757 for _, tt := range tests {754 for _, tt := range tests {
758 t.Run(tt.name, func(t *testing.T) {755 t.Run(tt.name, func(t *testing.T) {
@@ -765,7 +762,7 @@ func TestSessionInfoRecoveryState(t *testing.T) {
765 assertTrailingGarbageFails(t, payload, UnmarshalSessions)762 assertTrailingGarbageFails(t, payload, UnmarshalSessions)
766 })763 })
767 }764 }
768- for _, state := range []byte{0, 5} {765+ for _, state := range []byte{3, 255} {
769 payload := append([]byte(nil), tests[0].want...)766 payload := append([]byte(nil), tests[0].want...)
770 payload[len(payload)-1] = state767 payload[len(payload)-1] = state
771 _, err := UnmarshalSessions(payload)768 _, err := UnmarshalSessions(payload)
@@ -792,8 +789,8 @@ func TestSessionsGoldenAndRoundTrip(t *testing.T) {
792 }},789 }},
793 want: []byte{790 want: []byte{
794 0x00, 0x02,791 0x00, 0x02,
795- 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x01,792+ 0x00, 0x01, 0x30, 0x00, 0x01, 0x30, 0x01, 0x00, 0x01, 0x00, 0x00,
796- 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x02,793+ 0x00, 0x04, 0x77, 0x6f, 0x72, 0x6b, 0x00, 0x04, 0x70, 0x72, 0x6f, 0x6a, 0x00, 0x00, 0x05, 0x01, 0x01,
797 },794 },
798 },795 },
799 }796 }
M internal/usecase/daemon/coderabbit_regression_test.go +2 -3
@@ -26,14 +26,13 @@ func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) {
2626 persisted, ok, err := catalogue.Record(record.Name)
2727 require.NoError(t, err)
2828 require.True(t, ok)
29- require.Equal(t, domain.RecoveryDegraded, persisted.RecoveryState)
3029 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)
3130 d.mu.Lock()
3231 entry := d.stopped[record.Name]
3332 d.mu.Unlock()
34- require.Equal(t, runtimeDegraded, entry.state)
33+ require.Equal(t, ports.SessionBroken, entry.state)
3534 require.Equal(t, persisted, entry.record)
36- require.Equal(t, ports.SessionDegraded, listSessions(t, d).Sessions[0].State)
35+ require.Equal(t, ports.SessionBroken, listSessions(t, d).Sessions[0].State)
3736 select {
3837 case <-entry.restoreDone:
3938 default:
@@ -26,14 +26,13 @@ func TestRestoreTransientLoadFailureBecomesDiscardable(t *testing.T) {
26 persisted, ok, err := catalogue.Record(record.Name)26 persisted, ok, err := catalogue.Record(record.Name)
27 require.NoError(t, err)27 require.NoError(t, err)
28 require.True(t, ok)28 require.True(t, ok)
29- require.Equal(t, domain.RecoveryDegraded, persisted.RecoveryState)
30 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)29 require.Equal(t, "checkpoint load failed", persisted.DegradedReason)
31 d.mu.Lock()30 d.mu.Lock()
32 entry := d.stopped[record.Name]31 entry := d.stopped[record.Name]
33 d.mu.Unlock()32 d.mu.Unlock()
34- require.Equal(t, runtimeDegraded, entry.state)33+ require.Equal(t, ports.SessionBroken, entry.state)
35 require.Equal(t, persisted, entry.record)34 require.Equal(t, persisted, entry.record)
36- require.Equal(t, ports.SessionDegraded, listSessions(t, d).Sessions[0].State)35+ require.Equal(t, ports.SessionBroken, listSessions(t, d).Sessions[0].State)
37 select {36 select {
38 case <-entry.restoreDone:37 case <-entry.restoreDone:
39 default:38 default:
M internal/usecase/daemon/control.go +1 -1
@@ -473,7 +473,7 @@ func (e controlExec) SessionRecovery(action string) (string, error) {
473473 return "", err
474474 }
475475 if ok {
476- e.d.setStoppedRecovery(record, runtimeFresh)
476+ e.d.setStoppedRecovery(record, ports.SessionStopped)
477477 }
478478 return "", nil
479479 }
@@ -473,7 +473,7 @@ func (e controlExec) SessionRecovery(action string) (string, error) {
473 return "", err473 return "", err
474 }474 }
475 if ok {475 if ok {
476- e.d.setStoppedRecovery(record, runtimeFresh)476+ e.d.setStoppedRecovery(record, ports.SessionStopped)
477 }477 }
478 return "", nil478 return "", nil
479 }479 }
M internal/usecase/daemon/daemon.go +45 -77
@@ -221,16 +221,6 @@ type parkedAttachment struct {
221221
222222 // session is a single multiplexed session. It owns one or more full-screen
223223
224-type runtimeRecoveryState uint8
225-
226-const (
227- runtimeFresh runtimeRecoveryState = iota + 1
228- runtimeRestoring
229- runtimeHealthy
230- runtimeDegraded
231- runtimeDeleting
232-)
233-
234224 type stoppedSession struct {
235225 name string
236226 cwd string
@@ -240,7 +230,7 @@ type stoppedSession struct {
240230 tabNames []string
241231 purging bool
242232 record domain.CatalogueRecord
243- state runtimeRecoveryState
233+ state ports.SessionState
244234 restoreDone chan struct{}
245235 }
246236
@@ -487,7 +477,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
487477 stopped := stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)}
488478 if d.catalogueRecordsProvided {
489479 stopped.record = r
490- stopped.state, stopped.restoreDone = initialRuntimeRecoveryState(r)
480+ stopped.state, stopped.restoreDone = initialSessionState(r)
491481 }
492482 d.stopped[r.Name] = stopped
493483 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
@@ -811,17 +801,10 @@ func (d *Daemon) handleList(tr ports.Transport) {
811801 continue
812802 }
813803 state := ports.SessionStopped
814- if stopped.purging {
804+ if stopped.purging || stopped.state == ports.SessionBroken {
815805 // Purge is the dominant externally visible state: restoration must
816806 // never make a deletion-reserved record appear attachable.
817- state = ports.SessionDegraded
818- } else {
819- switch stopped.state {
820- case runtimeRestoring:
821- state = ports.SessionRestoring
822- case runtimeDegraded, runtimeDeleting:
823- state = ports.SessionDegraded
824- }
807+ state = ports.SessionBroken
825808 }
826809 infos = append(infos, ports.SessionInfo{Name: name, State: state})
827810 }
@@ -959,63 +942,44 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
959942 return ac
960943 }
961944
962-func (d *Daemon) waitForTargetRestore(name string) error {
963- var observedClosed chan struct{}
964- for {
965- d.mu.Lock()
966- if d.findByNameLocked(name) != nil {
967- d.mu.Unlock()
968- return nil
969- }
970- stopped, ok := d.stopped[name]
971- if !ok || stopped.purging {
972- d.mu.Unlock()
973- return nil
974- }
975- switch stopped.state {
976- case runtimeFresh:
977- if stopped.record.Name == "" || stopped.record.RecoveryState == domain.RecoveryFresh {
978- d.mu.Unlock()
979- return nil
980- }
981- d.mu.Unlock()
982- return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
983- case runtimeRestoring:
984- done := stopped.restoreDone
985- if done == nil || done == observedClosed {
986- d.mu.Unlock()
987- return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
988- }
989- var shutdown <-chan struct{}
990- if d.serveCtx != nil {
991- shutdown = d.serveCtx.Done()
992- }
993- d.mu.Unlock()
994- select {
995- case <-done:
996- observedClosed = done
997- continue
998- case <-shutdown:
999- return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
1000- }
1001- case runtimeHealthy:
1002- // The catalogue record is healthy; only its runtime registration was
1003- // skipped (shutdown, or a live session claimed the name first). Saying
1004- // "degraded" here would send the operator to durable recovery for a
1005- // session whose durable state is intact.
1006- d.mu.Unlock()
1007- return &protoErr{ports.ErrSessionDegraded, "session was not restored into this daemon: " + name}
1008- case runtimeDegraded, runtimeDeleting:
1009- d.mu.Unlock()
1010- return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
1011- default:
1012- d.mu.Unlock()
1013- if stopped.record.Name != "" {
1014- return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
1015- }
1016- return nil
945+func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
946+ d.mu.Lock()
947+ if d.findByNameLocked(name) != nil {
948+ d.mu.Unlock()
949+ return nil
950+ }
951+ stopped, ok := d.stopped[name]
952+ if !ok || stopped.purging {
953+ d.mu.Unlock()
954+ return nil
955+ }
956+ done := stopped.restoreDone
957+ d.mu.Unlock()
958+
959+ if done != nil {
960+ select {
961+ case <-done:
962+ case <-ctx.Done():
963+ return ctx.Err()
1017964 }
1018965 }
966+
967+ d.mu.Lock()
968+ defer d.mu.Unlock()
969+ if d.findByNameLocked(name) != nil {
970+ return nil
971+ }
972+ stopped, ok = d.stopped[name]
973+ if !ok || stopped.record.Name == "" {
974+ return nil
975+ }
976+ if stopped.state == ports.SessionBroken {
977+ return &protoErr{ports.ErrInternal, "session durable state is broken: " + name}
978+ }
979+ if stopped.record.Committed == nil {
980+ return nil
981+ }
982+ return &protoErr{ports.ErrInternal, "session was not restored into this daemon: " + name}
1019983 }
1020984
1021985 // route resolves a Hello to a session and a freshly attached client, creating
@@ -1033,7 +997,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1033997 }
1034998 }
1035999 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
1036- if err := d.waitForTargetRestore(h.Name); err != nil {
1000+ ctx := d.serveCtx
1001+ if ctx == nil {
1002+ ctx = context.Background()
1003+ }
1004+ if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
10371005 return nil, nil, err
10381006 }
10391007 }
@@ -221,16 +221,6 @@ type parkedAttachment struct {
221 221
222 // session is a single multiplexed session. It owns one or more full-screen222 // session is a single multiplexed session. It owns one or more full-screen
223 223
224-type runtimeRecoveryState uint8
225-
226-const (
227- runtimeFresh runtimeRecoveryState = iota + 1
228- runtimeRestoring
229- runtimeHealthy
230- runtimeDegraded
231- runtimeDeleting
232-)
233-
234 type stoppedSession struct {224 type stoppedSession struct {
235 name string225 name string
236 cwd string226 cwd string
@@ -240,7 +230,7 @@ type stoppedSession struct {
240 tabNames []string230 tabNames []string
241 purging bool231 purging bool
242 record domain.CatalogueRecord232 record domain.CatalogueRecord
243- state runtimeRecoveryState233+ state ports.SessionState
244 restoreDone chan struct{}234 restoreDone chan struct{}
245 }235 }
246 236
@@ -487,7 +477,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
487 stopped := stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)}477 stopped := stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, incarnation: r.IncarnationID, lastUsedSeq: r.LastUsedSeq, tabNames: append([]string(nil), r.TabNames...)}
488 if d.catalogueRecordsProvided {478 if d.catalogueRecordsProvided {
489 stopped.record = r479 stopped.record = r
490- stopped.state, stopped.restoreDone = initialRuntimeRecoveryState(r)480+ stopped.state, stopped.restoreDone = initialSessionState(r)
491 }481 }
492 d.stopped[r.Name] = stopped482 d.stopped[r.Name] = stopped
493 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {483 if !hasCreatedAt || r.CreatedAt > maxCreatedAt {
@@ -811,17 +801,10 @@ func (d *Daemon) handleList(tr ports.Transport) {
811 continue801 continue
812 }802 }
813 state := ports.SessionStopped803 state := ports.SessionStopped
814- if stopped.purging {804+ if stopped.purging || stopped.state == ports.SessionBroken {
815 // Purge is the dominant externally visible state: restoration must805 // Purge is the dominant externally visible state: restoration must
816 // never make a deletion-reserved record appear attachable.806 // never make a deletion-reserved record appear attachable.
817- state = ports.SessionDegraded807+ state = ports.SessionBroken
818- } else {
819- switch stopped.state {
820- case runtimeRestoring:
821- state = ports.SessionRestoring
822- case runtimeDegraded, runtimeDeleting:
823- state = ports.SessionDegraded
824- }
825 }808 }
826 infos = append(infos, ports.SessionInfo{Name: name, State: state})809 infos = append(infos, ports.SessionInfo{Name: name, State: state})
827 }810 }
@@ -959,63 +942,44 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
959 return ac942 return ac
960 }943 }
961 944
962-func (d *Daemon) waitForTargetRestore(name string) error {945+func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
963- var observedClosed chan struct{}946+ d.mu.Lock()
964- for {947+ if d.findByNameLocked(name) != nil {
965- d.mu.Lock()948+ d.mu.Unlock()
966- if d.findByNameLocked(name) != nil {949+ return nil
967- d.mu.Unlock()950+ }
968- return nil951+ stopped, ok := d.stopped[name]
969- }952+ if !ok || stopped.purging {
970- stopped, ok := d.stopped[name]953+ d.mu.Unlock()
971- if !ok || stopped.purging {954+ return nil
972- d.mu.Unlock()955+ }
973- return nil956+ done := stopped.restoreDone
974- }957+ d.mu.Unlock()
975- switch stopped.state {958+
976- case runtimeFresh:959+ if done != nil {
977- if stopped.record.Name == "" || stopped.record.RecoveryState == domain.RecoveryFresh {960+ select {
978- d.mu.Unlock()961+ case <-done:
979- return nil962+ case <-ctx.Done():
980- }963+ return ctx.Err()
981- d.mu.Unlock()
982- return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
983- case runtimeRestoring:
984- done := stopped.restoreDone
985- if done == nil || done == observedClosed {
986- d.mu.Unlock()
987- return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
988- }
989- var shutdown <-chan struct{}
990- if d.serveCtx != nil {
991- shutdown = d.serveCtx.Done()
992- }
993- d.mu.Unlock()
994- select {
995- case <-done:
996- observedClosed = done
997- continue
998- case <-shutdown:
999- return &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
1000- }
1001- case runtimeHealthy:
1002- // The catalogue record is healthy; only its runtime registration was
1003- // skipped (shutdown, or a live session claimed the name first). Saying
1004- // "degraded" here would send the operator to durable recovery for a
1005- // session whose durable state is intact.
1006- d.mu.Unlock()
1007- return &protoErr{ports.ErrSessionDegraded, "session was not restored into this daemon: " + name}
1008- case runtimeDegraded, runtimeDeleting:
1009- d.mu.Unlock()
1010- return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
1011- default:
1012- d.mu.Unlock()
1013- if stopped.record.Name != "" {
1014- return &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + name}
1015- }
1016- return nil
1017 }964 }
1018 }965 }
966+
967+ d.mu.Lock()
968+ defer d.mu.Unlock()
969+ if d.findByNameLocked(name) != nil {
970+ return nil
971+ }
972+ stopped, ok = d.stopped[name]
973+ if !ok || stopped.record.Name == "" {
974+ return nil
975+ }
976+ if stopped.state == ports.SessionBroken {
977+ return &protoErr{ports.ErrInternal, "session durable state is broken: " + name}
978+ }
979+ if stopped.record.Committed == nil {
980+ return nil
981+ }
982+ return &protoErr{ports.ErrInternal, "session was not restored into this daemon: " + name}
1019 }983 }
1020 984
1021 // route resolves a Hello to a session and a freshly attached client, creating985 // route resolves a Hello to a session and a freshly attached client, creating
@@ -1033,7 +997,11 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1033 }997 }
1034 }998 }
1035 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {999 if h.Intent == ports.IntentResume || h.Intent == ports.IntentAttach {
1036- if err := d.waitForTargetRestore(h.Name); err != nil {1000+ ctx := d.serveCtx
1001+ if ctx == nil {
1002+ ctx = context.Background()
1003+ }
1004+ if err := d.waitForTargetRestore(ctx, h.Name); err != nil {
1037 return nil, nil, err1005 return nil, nil, err
1038 }1006 }
1039 }1007 }
M internal/usecase/daemon/durable_recovery_test.go +46 -38
@@ -168,7 +168,6 @@ func durableRecoveryRecord(index int) domain.CatalogueRecord {
168168 Cwd: "/tmp",
169169 CreatedAt: int64(index + 1),
170170 UpdatedAt: int64(index + 1),
171- RecoveryState: domain.RecoveryHealthy,
172171 Committed: &ref,
173172 }
174173 }
@@ -208,7 +207,6 @@ func TestFinalCheckpointFailurePreservesCatalogue(t *testing.T) {
208207 IncarnationID: domain.IncarnationID{1},
209208 Cwd: "/work",
210209 CreatedAt: 42,
211- RecoveryState: domain.RecoveryHealthy,
212210 Committed: &priorRef,
213211 }
214212 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior})
@@ -267,8 +265,8 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
267265
268266 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
269267
270- require.Equal(t, runtimeHealthy, d.stopped[healthy.Name].state)
271- require.Equal(t, runtimeDegraded, d.stopped[broken.Name].state)
268+ require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state)
269+ require.Equal(t, ports.SessionBroken, d.stopped[broken.Name].state)
272270 for _, name := range []string{healthy.Name, broken.Name} {
273271 select {
274272 case <-d.stopped[name].restoreDone:
@@ -276,9 +274,9 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
276274 t.Fatalf("restore completion for %q was not closed", name)
277275 }
278276 }
279- degraded, ok, _ := catalogue.Record(broken.Name)
277+ brokenRecord, ok, _ := catalogue.Record(broken.Name)
280278 require.True(t, ok)
281- require.Equal(t, domain.RecoveryDegraded, degraded.RecoveryState)
279+ require.Equal(t, "checkpoint validation failed", brokenRecord.DegradedReason)
282280 require.Equal(t, 1, repository.loads[healthy.Name])
283281 require.Equal(t, 1, repository.loads[broken.Name])
284282 require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime")
@@ -300,7 +298,7 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
300298
301299 require.Len(t, d.stopped, count)
302300 for _, record := range records {
303- require.Equal(t, runtimeHealthy, d.stopped[record.Name].state)
301+ require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)
304302 require.Equal(t, 1, repository.loads[record.Name])
305303 }
306304 }
@@ -314,7 +312,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) {
314312 // discovery API and therefore performs exactly one direct catalogue lookup.
315313 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
316314
317- require.Equal(t, runtimeHealthy, d.stopped[record.Name].state)
315+ require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)
318316 require.Equal(t, 1, repository.loads[record.Name])
319317 }
320318
@@ -327,9 +325,9 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
327325
328326 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
329327
330- require.Equal(t, runtimeDegraded, d.stopped[record.Name].state)
328+ require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
331329 got, _, _ := catalogue.Record(record.Name)
332- require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)
330+ require.Equal(t, "checkpoint validation failed", got.DegradedReason)
333331 }
334332
335333 type cancellationRecoveryRepository struct {
@@ -359,7 +357,6 @@ func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) {
359357 ctx := context.WithValue(t.Context(), key, "request")
360358 wantErr := errors.New("stop after context capture")
361359 record := durableRecoveryRecord(0)
362- record.RecoveryState = domain.RecoveryDegraded
363360 record.DegradedReason = "checkpoint validation failed"
364361 catalogue := portsmocks.NewMockCatalogue(t)
365362 repository := portsmocks.NewMockSnapshotRepository(t)
@@ -472,11 +469,9 @@ func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testi
472469
473470 func TestSessionRecoveryCommand(t *testing.T) {
474471 degraded := durableRecoveryRecord(2)
475- degraded.RecoveryState = domain.RecoveryDegraded
476472 degraded.DegradedReason = "checkpoint validation failed"
477473 fresh := degraded
478474 fresh.IncarnationID = domain.IncarnationID{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}
479- fresh.RecoveryState = domain.RecoveryFresh
480475 fresh.Committed = nil
481476 fresh.DegradedReason = ""
482477 catalogue := portsmocks.NewMockCatalogue(t)
@@ -492,25 +487,23 @@ func TestSessionRecoveryCommand(t *testing.T) {
492487
493488 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
494489 require.NoError(t, err)
495- require.Equal(t, runtimeFresh, d.stopped[degraded.Name].state)
490+ require.Equal(t, ports.SessionStopped, d.stopped[degraded.Name].state)
496491 require.Equal(t, fresh, d.stopped[degraded.Name].record)
497492 }
498493
499494 func TestListShowsDegraded(t *testing.T) {
500495 fresh := durableRecoveryRecord(0)
501- fresh.RecoveryState = domain.RecoveryFresh
502496 fresh.Committed = nil
503497 restoring := durableRecoveryRecord(1)
504498 degraded := durableRecoveryRecord(2)
505- degraded.RecoveryState = domain.RecoveryDegraded
506499 degraded.DegradedReason = "checkpoint validation failed"
507500 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{})
508501
509502 listed := listSessions(t, d)
510503 require.Equal(t, []ports.SessionInfo{
511504 {Name: fresh.Name, State: ports.SessionStopped},
512- {Name: restoring.Name, State: ports.SessionRestoring},
513- {Name: degraded.Name, State: ports.SessionDegraded},
505+ {Name: restoring.Name, State: ports.SessionStopped},
506+ {Name: degraded.Name, State: ports.SessionBroken},
514507 }, listed.Sessions)
515508 }
516509
@@ -520,12 +513,12 @@ func TestListPurgingDominatesRestoringState(t *testing.T) {
520513 d.mu.Lock()
521514 entry := d.stopped[record.Name]
522515 entry.purging = true
523- entry.state = runtimeRestoring
516+ entry.state = ports.SessionStopped
524517 d.stopped[record.Name] = entry
525518 d.mu.Unlock()
526519
527520 listed := listSessions(t, d)
528- require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionDegraded}}, listed.Sessions)
521+ require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionBroken}}, listed.Sessions)
529522 }
530523
531524 func TestAttachWaitsForRestore(t *testing.T) {
@@ -548,16 +541,32 @@ func TestAttachWaitsForRestore(t *testing.T) {
548541 }
549542 require.Zero(t, factory.calls.Load())
550543
551- d.setStoppedRecovery(record, runtimeDegraded)
544+ d.setStoppedRecovery(record, ports.SessionBroken)
552545 d.mu.Lock()
553546 closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
554547 d.mu.Unlock()
555548 var protocolError *protoErr
556549 require.ErrorAs(t, <-result, &protocolError)
557- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
550+ require.Equal(t, ports.ErrInternal, protocolError.code)
558551 require.Zero(t, factory.calls.Load())
559552 }
560553
554+func TestAttachRestoreWaitHonorsContextCancellation(t *testing.T) {
555+ record := durableRecoveryRecord(0)
556+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
557+ ctx, cancel := context.WithCancel(context.Background())
558+ result := make(chan error, 1)
559+ go func() { result <- d.waitForTargetRestore(ctx, record.Name) }()
560+
561+ select {
562+ case err := <-result:
563+ t.Fatalf("restore wait returned before cancellation: %v", err)
564+ case <-time.After(25 * time.Millisecond):
565+ }
566+ cancel()
567+ require.ErrorIs(t, <-result, context.Canceled)
568+}
569+
561570 func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
562571 record := durableRecoveryRecord(0)
563572 repository := &cancellationRecoveryRepository{started: make(chan struct{})}
@@ -579,11 +588,10 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
579588
580589 var protocolError *protoErr
581590 require.ErrorAs(t, <-attached, &protocolError)
582- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
591+ require.Equal(t, ports.ErrInternal, protocolError.code)
583592 <-restored
584593 entry := d.stopped[record.Name]
585- require.Equal(t, runtimeDegraded, entry.state)
586- require.Equal(t, domain.RecoveryDegraded, entry.record.RecoveryState)
594+ require.Equal(t, ports.SessionBroken, entry.state)
587595 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
588596 select {
589597 case <-entry.restoreDone:
@@ -602,13 +610,13 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
602610 d.mu.Unlock()
603611
604612 result := make(chan error, 1)
605- go func() { result <- d.waitForTargetRestore(record.Name) }()
613+ go func() { result <- d.waitForTargetRestore(context.Background(), record.Name) }()
606614
607615 select {
608616 case err := <-result:
609617 var protocolError *protoErr
610618 require.ErrorAs(t, err, &protocolError)
611- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
619+ require.Equal(t, ports.ErrInternal, protocolError.code)
612620 case <-time.After(time.Second):
613621 t.Fatal("waiter spun on a closed restore channel")
614622 }
@@ -620,21 +628,23 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
620628 record := durableRecoveryRecord(0)
621629 tests := []struct {
622630 name string
623- state runtimeRecoveryState
631+ state ports.SessionState
624632 message string
625633 }{
626- {name: "healthy record without runtime", state: runtimeHealthy, message: "session was not restored into this daemon: " + record.Name},
627- {name: "degraded durable state", state: runtimeDegraded, message: "session durable state is degraded: " + record.Name},
628- {name: "deleting durable state", state: runtimeDeleting, message: "session durable state is degraded: " + record.Name},
634+ {name: "healthy record without runtime", state: ports.SessionStopped, message: "session was not restored into this daemon: " + record.Name},
635+ {name: "broken durable state", state: ports.SessionBroken, message: "session durable state is broken: " + record.Name},
629636 }
630637 for _, tt := range tests {
631638 t.Run(tt.name, func(t *testing.T) {
632639 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
633640 d.setStoppedRecovery(record, tt.state)
641+ d.mu.Lock()
642+ closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
643+ d.mu.Unlock()
634644
635645 var protocolError *protoErr
636- require.ErrorAs(t, d.waitForTargetRestore(record.Name), &protocolError)
637- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
646+ require.ErrorAs(t, d.waitForTargetRestore(context.Background(), record.Name), &protocolError)
647+ require.Equal(t, ports.ErrInternal, protocolError.code)
638648 require.Equal(t, tt.message, protocolError.Error())
639649 })
640650 }
@@ -642,7 +652,6 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
642652
643653 func TestAttachRejectsDegraded(t *testing.T) {
644654 record := durableRecoveryRecord(0)
645- record.RecoveryState = domain.RecoveryDegraded
646655 record.DegradedReason = "checkpoint validation failed"
647656 factory := &recoveryCountingPTYFactory{}
648657 d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))
@@ -652,7 +661,7 @@ func TestAttachRejectsDegraded(t *testing.T) {
652661 _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
653662 var protocolError *protoErr
654663 require.ErrorAs(t, err, &protocolError)
655- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
664+ require.Equal(t, ports.ErrInternal, protocolError.code)
656665 require.Zero(t, factory.calls.Load())
657666 }
658667
@@ -664,7 +673,7 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) {
664673 d.mu.Lock()
665674 entry := d.stopped[record.Name]
666675 d.mu.Unlock()
667- require.Equal(t, runtimeDegraded, entry.state)
676+ require.Equal(t, ports.SessionBroken, entry.state)
668677 select {
669678 case <-entry.restoreDone:
670679 default:
@@ -674,13 +683,12 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) {
674683
675684 func TestCatalogueRestoreDegradedVisible(t *testing.T) {
676685 record := durableRecoveryRecord(0)
677- record.RecoveryState = domain.RecoveryDegraded
678686 record.DegradedReason = "checkpoint validation failed"
679687 repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}
680688 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
681689
682690 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
683691
684- require.Equal(t, runtimeDegraded, d.stopped[record.Name].state)
692+ require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
685693 require.Zero(t, repository.loads[record.Name])
686694 }
@@ -168,7 +168,6 @@ func durableRecoveryRecord(index int) domain.CatalogueRecord {
168 Cwd: "/tmp",168 Cwd: "/tmp",
169 CreatedAt: int64(index + 1),169 CreatedAt: int64(index + 1),
170 UpdatedAt: int64(index + 1),170 UpdatedAt: int64(index + 1),
171- RecoveryState: domain.RecoveryHealthy,
172 Committed: &ref,171 Committed: &ref,
173 }172 }
174 }173 }
@@ -208,7 +207,6 @@ func TestFinalCheckpointFailurePreservesCatalogue(t *testing.T) {
208 IncarnationID: domain.IncarnationID{1},207 IncarnationID: domain.IncarnationID{1},
209 Cwd: "/work",208 Cwd: "/work",
210 CreatedAt: 42,209 CreatedAt: 42,
211- RecoveryState: domain.RecoveryHealthy,
212 Committed: &priorRef,210 Committed: &priorRef,
213 }211 }
214 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior})212 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{prior})
@@ -267,8 +265,8 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
267 265
268 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))266 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
269 267
270- require.Equal(t, runtimeHealthy, d.stopped[healthy.Name].state)268+ require.Equal(t, ports.SessionStopped, d.stopped[healthy.Name].state)
271- require.Equal(t, runtimeDegraded, d.stopped[broken.Name].state)269+ require.Equal(t, ports.SessionBroken, d.stopped[broken.Name].state)
272 for _, name := range []string{healthy.Name, broken.Name} {270 for _, name := range []string{healthy.Name, broken.Name} {
273 select {271 select {
274 case <-d.stopped[name].restoreDone:272 case <-d.stopped[name].restoreDone:
@@ -276,9 +274,9 @@ func TestCatalogueRestoreIndependent(t *testing.T) {
276 t.Fatalf("restore completion for %q was not closed", name)274 t.Fatalf("restore completion for %q was not closed", name)
277 }275 }
278 }276 }
279- degraded, ok, _ := catalogue.Record(broken.Name)277+ brokenRecord, ok, _ := catalogue.Record(broken.Name)
280 require.True(t, ok)278 require.True(t, ok)
281- require.Equal(t, domain.RecoveryDegraded, degraded.RecoveryState)279+ require.Equal(t, "checkpoint validation failed", brokenRecord.DegradedReason)
282 require.Equal(t, 1, repository.loads[healthy.Name])280 require.Equal(t, 1, repository.loads[healthy.Name])
283 require.Equal(t, 1, repository.loads[broken.Name])281 require.Equal(t, 1, repository.loads[broken.Name])
284 require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime")282 require.Empty(t, d.sessions, "a corrupt record must never open a replacement runtime")
@@ -300,7 +298,7 @@ func TestCatalogueRestoreAggregateOver4096(t *testing.T) {
300 298
301 require.Len(t, d.stopped, count)299 require.Len(t, d.stopped, count)
302 for _, record := range records {300 for _, record := range records {
303- require.Equal(t, runtimeHealthy, d.stopped[record.Name].state)301+ require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)
304 require.Equal(t, 1, repository.loads[record.Name])302 require.Equal(t, 1, repository.loads[record.Name])
305 }303 }
306 }304 }
@@ -314,7 +312,7 @@ func TestCatalogueRestoreSingleSessionOver4096(t *testing.T) {
314 // discovery API and therefore performs exactly one direct catalogue lookup.312 // discovery API and therefore performs exactly one direct catalogue lookup.
315 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))313 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
316 314
317- require.Equal(t, runtimeHealthy, d.stopped[record.Name].state)315+ require.Equal(t, ports.SessionStopped, d.stopped[record.Name].state)
318 require.Equal(t, 1, repository.loads[record.Name])316 require.Equal(t, 1, repository.loads[record.Name])
319 }317 }
320 318
@@ -327,9 +325,9 @@ func TestCatalogueRestoreIncarnationMismatch(t *testing.T) {
327 325
328 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))326 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
329 327
330- require.Equal(t, runtimeDegraded, d.stopped[record.Name].state)328+ require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
331 got, _, _ := catalogue.Record(record.Name)329 got, _, _ := catalogue.Record(record.Name)
332- require.Equal(t, domain.RecoveryDegraded, got.RecoveryState)330+ require.Equal(t, "checkpoint validation failed", got.DegradedReason)
333 }331 }
334 332
335 type cancellationRecoveryRepository struct {333 type cancellationRecoveryRepository struct {
@@ -359,7 +357,6 @@ func TestSessionRecoveryUsesIncomingCommandContext(t *testing.T) {
359 ctx := context.WithValue(t.Context(), key, "request")357 ctx := context.WithValue(t.Context(), key, "request")
360 wantErr := errors.New("stop after context capture")358 wantErr := errors.New("stop after context capture")
361 record := durableRecoveryRecord(0)359 record := durableRecoveryRecord(0)
362- record.RecoveryState = domain.RecoveryDegraded
363 record.DegradedReason = "checkpoint validation failed"360 record.DegradedReason = "checkpoint validation failed"
364 catalogue := portsmocks.NewMockCatalogue(t)361 catalogue := portsmocks.NewMockCatalogue(t)
365 repository := portsmocks.NewMockSnapshotRepository(t)362 repository := portsmocks.NewMockSnapshotRepository(t)
@@ -472,11 +469,9 @@ func TestPersistAndRegisterRestoredSessionRejectsReplacementIncarnation(t *testi
472 469
473 func TestSessionRecoveryCommand(t *testing.T) {470 func TestSessionRecoveryCommand(t *testing.T) {
474 degraded := durableRecoveryRecord(2)471 degraded := durableRecoveryRecord(2)
475- degraded.RecoveryState = domain.RecoveryDegraded
476 degraded.DegradedReason = "checkpoint validation failed"472 degraded.DegradedReason = "checkpoint validation failed"
477 fresh := degraded473 fresh := degraded
478 fresh.IncarnationID = domain.IncarnationID{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}474 fresh.IncarnationID = domain.IncarnationID{9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9}
479- fresh.RecoveryState = domain.RecoveryFresh
480 fresh.Committed = nil475 fresh.Committed = nil
481 fresh.DegradedReason = ""476 fresh.DegradedReason = ""
482 catalogue := portsmocks.NewMockCatalogue(t)477 catalogue := portsmocks.NewMockCatalogue(t)
@@ -492,25 +487,23 @@ func TestSessionRecoveryCommand(t *testing.T) {
492 487
493 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")488 _, err := (controlExec{d: d, recoveryName: degraded.Name}).SessionRecovery("discard")
494 require.NoError(t, err)489 require.NoError(t, err)
495- require.Equal(t, runtimeFresh, d.stopped[degraded.Name].state)490+ require.Equal(t, ports.SessionStopped, d.stopped[degraded.Name].state)
496 require.Equal(t, fresh, d.stopped[degraded.Name].record)491 require.Equal(t, fresh, d.stopped[degraded.Name].record)
497 }492 }
498 493
499 func TestListShowsDegraded(t *testing.T) {494 func TestListShowsDegraded(t *testing.T) {
500 fresh := durableRecoveryRecord(0)495 fresh := durableRecoveryRecord(0)
501- fresh.RecoveryState = domain.RecoveryFresh
502 fresh.Committed = nil496 fresh.Committed = nil
503 restoring := durableRecoveryRecord(1)497 restoring := durableRecoveryRecord(1)
504 degraded := durableRecoveryRecord(2)498 degraded := durableRecoveryRecord(2)
505- degraded.RecoveryState = domain.RecoveryDegraded
506 degraded.DegradedReason = "checkpoint validation failed"499 degraded.DegradedReason = "checkpoint validation failed"
507 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{})500 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{fresh, restoring, degraded}, &durableRecoveryRepository{})
508 501
509 listed := listSessions(t, d)502 listed := listSessions(t, d)
510 require.Equal(t, []ports.SessionInfo{503 require.Equal(t, []ports.SessionInfo{
511 {Name: fresh.Name, State: ports.SessionStopped},504 {Name: fresh.Name, State: ports.SessionStopped},
512- {Name: restoring.Name, State: ports.SessionRestoring},505+ {Name: restoring.Name, State: ports.SessionStopped},
513- {Name: degraded.Name, State: ports.SessionDegraded},506+ {Name: degraded.Name, State: ports.SessionBroken},
514 }, listed.Sessions)507 }, listed.Sessions)
515 }508 }
516 509
@@ -520,12 +513,12 @@ func TestListPurgingDominatesRestoringState(t *testing.T) {
520 d.mu.Lock()513 d.mu.Lock()
521 entry := d.stopped[record.Name]514 entry := d.stopped[record.Name]
522 entry.purging = true515 entry.purging = true
523- entry.state = runtimeRestoring516+ entry.state = ports.SessionStopped
524 d.stopped[record.Name] = entry517 d.stopped[record.Name] = entry
525 d.mu.Unlock()518 d.mu.Unlock()
526 519
527 listed := listSessions(t, d)520 listed := listSessions(t, d)
528- require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionDegraded}}, listed.Sessions)521+ require.Equal(t, []ports.SessionInfo{{Name: record.Name, State: ports.SessionBroken}}, listed.Sessions)
529 }522 }
530 523
531 func TestAttachWaitsForRestore(t *testing.T) {524 func TestAttachWaitsForRestore(t *testing.T) {
@@ -548,16 +541,32 @@ func TestAttachWaitsForRestore(t *testing.T) {
548 }541 }
549 require.Zero(t, factory.calls.Load())542 require.Zero(t, factory.calls.Load())
550 543
551- d.setStoppedRecovery(record, runtimeDegraded)544+ d.setStoppedRecovery(record, ports.SessionBroken)
552 d.mu.Lock()545 d.mu.Lock()
553 closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)546 closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
554 d.mu.Unlock()547 d.mu.Unlock()
555 var protocolError *protoErr548 var protocolError *protoErr
556 require.ErrorAs(t, <-result, &protocolError)549 require.ErrorAs(t, <-result, &protocolError)
557- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)550+ require.Equal(t, ports.ErrInternal, protocolError.code)
558 require.Zero(t, factory.calls.Load())551 require.Zero(t, factory.calls.Load())
559 }552 }
560 553
554+func TestAttachRestoreWaitHonorsContextCancellation(t *testing.T) {
555+ record := durableRecoveryRecord(0)
556+ d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
557+ ctx, cancel := context.WithCancel(context.Background())
558+ result := make(chan error, 1)
559+ go func() { result <- d.waitForTargetRestore(ctx, record.Name) }()
560+
561+ select {
562+ case err := <-result:
563+ t.Fatalf("restore wait returned before cancellation: %v", err)
564+ case <-time.After(25 * time.Millisecond):
565+ }
566+ cancel()
567+ require.ErrorIs(t, <-result, context.Canceled)
568+}
569+
561 func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {570 func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
562 record := durableRecoveryRecord(0)571 record := durableRecoveryRecord(0)
563 repository := &cancellationRecoveryRepository{started: make(chan struct{})}572 repository := &cancellationRecoveryRepository{started: make(chan struct{})}
@@ -579,11 +588,10 @@ func TestRestoreCancellationTransitionsBeforeAttachCompletion(t *testing.T) {
579 588
580 var protocolError *protoErr589 var protocolError *protoErr
581 require.ErrorAs(t, <-attached, &protocolError)590 require.ErrorAs(t, <-attached, &protocolError)
582- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)591+ require.Equal(t, ports.ErrInternal, protocolError.code)
583 <-restored592 <-restored
584 entry := d.stopped[record.Name]593 entry := d.stopped[record.Name]
585- require.Equal(t, runtimeDegraded, entry.state)594+ require.Equal(t, ports.SessionBroken, entry.state)
586- require.Equal(t, domain.RecoveryDegraded, entry.record.RecoveryState)
587 require.Equal(t, "restore interrupted", entry.record.DegradedReason)595 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
588 select {596 select {
589 case <-entry.restoreDone:597 case <-entry.restoreDone:
@@ -602,13 +610,13 @@ func TestWaitForTargetRestoreRejectsClosedRestoringChannel(t *testing.T) {
602 d.mu.Unlock()610 d.mu.Unlock()
603 611
604 result := make(chan error, 1)612 result := make(chan error, 1)
605- go func() { result <- d.waitForTargetRestore(record.Name) }()613+ go func() { result <- d.waitForTargetRestore(context.Background(), record.Name) }()
606 614
607 select {615 select {
608 case err := <-result:616 case err := <-result:
609 var protocolError *protoErr617 var protocolError *protoErr
610 require.ErrorAs(t, err, &protocolError)618 require.ErrorAs(t, err, &protocolError)
611- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)619+ require.Equal(t, ports.ErrInternal, protocolError.code)
612 case <-time.After(time.Second):620 case <-time.After(time.Second):
613 t.Fatal("waiter spun on a closed restore channel")621 t.Fatal("waiter spun on a closed restore channel")
614 }622 }
@@ -620,21 +628,23 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
620 record := durableRecoveryRecord(0)628 record := durableRecoveryRecord(0)
621 tests := []struct {629 tests := []struct {
622 name string630 name string
623- state runtimeRecoveryState631+ state ports.SessionState
624 message string632 message string
625 }{633 }{
626- {name: "healthy record without runtime", state: runtimeHealthy, message: "session was not restored into this daemon: " + record.Name},634+ {name: "healthy record without runtime", state: ports.SessionStopped, message: "session was not restored into this daemon: " + record.Name},
627- {name: "degraded durable state", state: runtimeDegraded, message: "session durable state is degraded: " + record.Name},635+ {name: "broken durable state", state: ports.SessionBroken, message: "session durable state is broken: " + record.Name},
628- {name: "deleting durable state", state: runtimeDeleting, message: "session durable state is degraded: " + record.Name},
629 }636 }
630 for _, tt := range tests {637 for _, tt := range tests {
631 t.Run(tt.name, func(t *testing.T) {638 t.Run(tt.name, func(t *testing.T) {
632 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})639 d, _ := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, &durableRecoveryRepository{})
633 d.setStoppedRecovery(record, tt.state)640 d.setStoppedRecovery(record, tt.state)
641+ d.mu.Lock()
642+ closeRuntimeRestoreDoneLocked(d.stopped[record.Name].restoreDone)
643+ d.mu.Unlock()
634 644
635 var protocolError *protoErr645 var protocolError *protoErr
636- require.ErrorAs(t, d.waitForTargetRestore(record.Name), &protocolError)646+ require.ErrorAs(t, d.waitForTargetRestore(context.Background(), record.Name), &protocolError)
637- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)647+ require.Equal(t, ports.ErrInternal, protocolError.code)
638 require.Equal(t, tt.message, protocolError.Error())648 require.Equal(t, tt.message, protocolError.Error())
639 })649 })
640 }650 }
@@ -642,7 +652,6 @@ func TestWaitForTargetRestoreDistinguishesHealthyRuntime(t *testing.T) {
642 652
643 func TestAttachRejectsDegraded(t *testing.T) {653 func TestAttachRejectsDegraded(t *testing.T) {
644 record := durableRecoveryRecord(0)654 record := durableRecoveryRecord(0)
645- record.RecoveryState = domain.RecoveryDegraded
646 record.DegradedReason = "checkpoint validation failed"655 record.DegradedReason = "checkpoint validation failed"
647 factory := &recoveryCountingPTYFactory{}656 factory := &recoveryCountingPTYFactory{}
648 d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))657 d := New(factory, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}), []domain.CatalogueRecord{record}))
@@ -652,7 +661,7 @@ func TestAttachRejectsDegraded(t *testing.T) {
652 _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)661 _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)
653 var protocolError *protoErr662 var protocolError *protoErr
654 require.ErrorAs(t, err, &protocolError)663 require.ErrorAs(t, err, &protocolError)
655- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)664+ require.Equal(t, ports.ErrInternal, protocolError.code)
656 require.Zero(t, factory.calls.Load())665 require.Zero(t, factory.calls.Load())
657 }666 }
658 667
@@ -664,7 +673,7 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) {
664 d.mu.Lock()673 d.mu.Lock()
665 entry := d.stopped[record.Name]674 entry := d.stopped[record.Name]
666 d.mu.Unlock()675 d.mu.Unlock()
667- require.Equal(t, runtimeDegraded, entry.state)676+ require.Equal(t, ports.SessionBroken, entry.state)
668 select {677 select {
669 case <-entry.restoreDone:678 case <-entry.restoreDone:
670 default:679 default:
@@ -674,13 +683,12 @@ func TestRestoreUnavailableReleasesPerRecordWaiters(t *testing.T) {
674 683
675 func TestCatalogueRestoreDegradedVisible(t *testing.T) {684 func TestCatalogueRestoreDegradedVisible(t *testing.T) {
676 record := durableRecoveryRecord(0)685 record := durableRecoveryRecord(0)
677- record.RecoveryState = domain.RecoveryDegraded
678 record.DegradedReason = "checkpoint validation failed"686 record.DegradedReason = "checkpoint validation failed"
679 repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}687 repository := &durableRecoveryRepository{generations: make(map[string]ports.SnapshotGeneration), errors: make(map[string]error), loads: make(map[string]int), repairs: make(map[string]domain.CheckpointRef)}
680 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)688 d, catalogue := newDurableRecoveryDaemon(t, []domain.CatalogueRecord{record}, repository)
681 689
682 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))690 d.restoreCatalogue(context.Background(), mustDurableRecords(t, catalogue))
683 691
684- require.Equal(t, runtimeDegraded, d.stopped[record.Name].state)692+ require.Equal(t, ports.SessionBroken, d.stopped[record.Name].state)
685 require.Zero(t, repository.loads[record.Name])693 require.Zero(t, repository.loads[record.Name])
686 }694 }
M internal/usecase/daemon/picker.go +8 -3
@@ -2,6 +2,7 @@
22 package daemon
33
44 import (
5+ "context"
56 "sort"
67
78 "github.com/bnema/vev/internal/domain"
@@ -359,7 +360,11 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
359360 // as part of the source ownership transfer.
360361 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
361362 if target.Name != "" {
362- if err := d.waitForTargetRestore(target.Name); err != nil {
363+ ctx := d.serveCtx
364+ if ctx == nil {
365+ ctx = context.Background()
366+ }
367+ if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
363368 d.invalidateRender(from, ac, true, "picker.go")
364369 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
365370 }
@@ -515,8 +520,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
515520 // the handoff while d.mu is held. Creation failure leaves the source client
516521 // and stopped record untouched.
517522 func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {
518- if stopped.record.Name != "" && (stopped.state != runtimeFresh || stopped.record.RecoveryState != domain.RecoveryFresh) {
519- return nil, nil, false, &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + target.Name}
523+ if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
524+ return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
520525 }
521526
522527 // The caller already holds d.mu. Keep handoff ownership atomic with global
@@ -2,6 +2,7 @@
2 package daemon2 package daemon
3 3
4 import (4 import (
5+ "context"
5 "sort"6 "sort"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
@@ -359,7 +360,11 @@ func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker
359 // as part of the source ownership transfer.360 // as part of the source ownership transfer.
360 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {361 func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
361 if target.Name != "" {362 if target.Name != "" {
362- if err := d.waitForTargetRestore(target.Name); err != nil {363+ ctx := d.serveCtx
364+ if ctx == nil {
365+ ctx = context.Background()
366+ }
367+ if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
363 d.invalidateRender(from, ac, true, "picker.go")368 d.invalidateRender(from, ac, true, "picker.go")
364 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)369 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
365 }370 }
@@ -515,8 +520,8 @@ func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, t
515 // the handoff while d.mu is held. Creation failure leaves the source client520 // the handoff while d.mu is held. Creation failure leaves the source client
516 // and stopped record untouched.521 // and stopped record untouched.
517 func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {522 func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession) (*session, []renderLifecycleCleanup, bool, error) {
518- if stopped.record.Name != "" && (stopped.state != runtimeFresh || stopped.record.RecoveryState != domain.RecoveryFresh) {523+ if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
519- return nil, nil, false, &protoErr{ports.ErrSessionDegraded, "session durable state is degraded: " + target.Name}524+ return nil, nil, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
520 }525 }
521 526
522 // The caller already holds d.mu. Keep handoff ownership atomic with global527 // The caller already holds d.mu. Keep handoff ownership atomic with global
M internal/usecase/daemon/picker_test.go +12 -13
@@ -226,7 +226,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
226226 d, from, ac, _ := newManualSessionWithPTYs(t, p)
227227 record := durableRecoveryRecord(0)
228228 record.Name = "restoring"
229- state, done := initialRuntimeRecoveryState(record)
229+ state, done := initialSessionState(record)
230230 d.stopped[record.Name] = stoppedSession{
231231 name: record.Name,
232232 createdAt: record.CreatedAt,
@@ -296,7 +296,7 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
296296 d.mu.Lock()
297297 entry := d.stopped[record.Name]
298298 d.mu.Unlock()
299- require.Equal(t, runtimeDegraded, entry.state)
299+ require.Equal(t, ports.SessionBroken, entry.state)
300300 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
301301 select {
302302 case <-entry.restoreDone:
@@ -308,13 +308,11 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
308308 func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
309309 tests := []struct {
310310 name string
311- recovery domain.RecoveryState
312- runtimeState runtimeRecoveryState
311+ broken bool
312+ runtimeState ports.SessionState
313313 }{
314- {name: "degraded", recovery: domain.RecoveryDegraded, runtimeState: runtimeDegraded},
315- {name: "deleting", recovery: domain.RecoveryDeleting, runtimeState: runtimeDeleting},
316- {name: "healthy with missing runtime state", recovery: domain.RecoveryHealthy},
317- {name: "healthy without restored runtime", recovery: domain.RecoveryHealthy, runtimeState: runtimeHealthy},
314+ {name: "broken", broken: true, runtimeState: ports.SessionBroken},
315+ {name: "healthy without restored runtime", runtimeState: ports.SessionStopped},
318316 }
319317
320318 for _, tt := range tests {
@@ -325,7 +323,9 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
325323 store, storeState := newMockStore(t)
326324 record := durableRecoveryRecord(0)
327325 record.Name = "unsafe"
328- record.RecoveryState = tt.recovery
326+ if tt.broken {
327+ record.DegradedReason = "checkpoint validation failed"
328+ }
329329 catalogue := persist.New(store)
330330 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
331331 d.stopped[record.Name] = stoppedSession{
@@ -345,7 +345,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
345345 require.Equal(t, domain.NoticeSessionUnavailable, userError.Code)
346346 var protocolError *protoErr
347347 require.ErrorAs(t, err, &protocolError)
348- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)
348+ require.Equal(t, ports.ErrInternal, protocolError.code)
349349 require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY")
350350 require.Same(t, from, ac.currentSession())
351351
@@ -368,7 +368,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
368368 defer release3()
369369 d, from, ac, sends := newManualSessionWithPTYs(t, p1)
370370 d.ptys = newFactorySeq(t, p2, p3)
371- d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work", RecoveryState: domain.RecoveryFresh}, state: runtimeFresh}
371+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionStopped}
372372
373373 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
374374 awaitFrame(t, sends, ports.MsgOutput)
@@ -1142,7 +1142,6 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
11421142 }
11431143 record := target.persistRecordLocked(1)
11441144 target.mu.Unlock()
1145- record.RecoveryState = domain.RecoveryFresh
11461145 require.NoError(t, d.catalogue.Create(record))
11471146
11481147 d.enterPicker(from, ac)
@@ -1178,7 +1177,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
11781177 cause := errors.New("delete failed")
11791178 store, state := newMockStore(t)
11801179 WithStore(t, store)(d)
1181- record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7, RecoveryState: domain.RecoveryFresh}
1180+ record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7}
11821181 require.NoError(t, d.catalogue.Create(record))
11831182 state.mu.Lock()
11841183 state.deleteErr = func(string) error { return cause }
@@ -226,7 +226,7 @@ func TestPickerWaitsForRestoringTargetBeforeSwitching(t *testing.T) {
226 d, from, ac, _ := newManualSessionWithPTYs(t, p)226 d, from, ac, _ := newManualSessionWithPTYs(t, p)
227 record := durableRecoveryRecord(0)227 record := durableRecoveryRecord(0)
228 record.Name = "restoring"228 record.Name = "restoring"
229- state, done := initialRuntimeRecoveryState(record)229+ state, done := initialSessionState(record)
230 d.stopped[record.Name] = stoppedSession{230 d.stopped[record.Name] = stoppedSession{
231 name: record.Name,231 name: record.Name,
232 createdAt: record.CreatedAt,232 createdAt: record.CreatedAt,
@@ -296,7 +296,7 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
296 d.mu.Lock()296 d.mu.Lock()
297 entry := d.stopped[record.Name]297 entry := d.stopped[record.Name]
298 d.mu.Unlock()298 d.mu.Unlock()
299- require.Equal(t, runtimeDegraded, entry.state)299+ require.Equal(t, ports.SessionBroken, entry.state)
300 require.Equal(t, "restore interrupted", entry.record.DegradedReason)300 require.Equal(t, "restore interrupted", entry.record.DegradedReason)
301 select {301 select {
302 case <-entry.restoreDone:302 case <-entry.restoreDone:
@@ -308,13 +308,11 @@ func TestRestoreCancellationTransitionsBeforePickerCompletion(t *testing.T) {
308 func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {308 func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
309 tests := []struct {309 tests := []struct {
310 name string310 name string
311- recovery domain.RecoveryState311+ broken bool
312- runtimeState runtimeRecoveryState312+ runtimeState ports.SessionState
313 }{313 }{
314- {name: "degraded", recovery: domain.RecoveryDegraded, runtimeState: runtimeDegraded},314+ {name: "broken", broken: true, runtimeState: ports.SessionBroken},
315- {name: "deleting", recovery: domain.RecoveryDeleting, runtimeState: runtimeDeleting},315+ {name: "healthy without restored runtime", runtimeState: ports.SessionStopped},
316- {name: "healthy with missing runtime state", recovery: domain.RecoveryHealthy},
317- {name: "healthy without restored runtime", recovery: domain.RecoveryHealthy, runtimeState: runtimeHealthy},
318 }316 }
319 317
320 for _, tt := range tests {318 for _, tt := range tests {
@@ -325,7 +323,9 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
325 store, storeState := newMockStore(t)323 store, storeState := newMockStore(t)
326 record := durableRecoveryRecord(0)324 record := durableRecoveryRecord(0)
327 record.Name = "unsafe"325 record.Name = "unsafe"
328- record.RecoveryState = tt.recovery326+ if tt.broken {
327+ record.DegradedReason = "checkpoint validation failed"
328+ }
329 catalogue := persist.New(store)329 catalogue := persist.New(store)
330 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)330 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
331 d.stopped[record.Name] = stoppedSession{331 d.stopped[record.Name] = stoppedSession{
@@ -345,7 +345,7 @@ func TestPickerRejectsCatalogueTargetsWithoutFreshRuntime(t *testing.T) {
345 require.Equal(t, domain.NoticeSessionUnavailable, userError.Code)345 require.Equal(t, domain.NoticeSessionUnavailable, userError.Code)
346 var protocolError *protoErr346 var protocolError *protoErr
347 require.ErrorAs(t, err, &protocolError)347 require.ErrorAs(t, err, &protocolError)
348- require.Equal(t, ports.ErrSessionDegraded, protocolError.code)348+ require.Equal(t, ports.ErrInternal, protocolError.code)
349 require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY")349 require.Zero(t, factory.calls.Load(), "unsafe target must not open a PTY")
350 require.Same(t, from, ac.currentSession())350 require.Same(t, from, ac.currentSession())
351 351
@@ -368,7 +368,7 @@ func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
368 defer release3()368 defer release3()
369 d, from, ac, sends := newManualSessionWithPTYs(t, p1)369 d, from, ac, sends := newManualSessionWithPTYs(t, p1)
370 d.ptys = newFactorySeq(t, p2, p3)370 d.ptys = newFactorySeq(t, p2, p3)
371- d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work", RecoveryState: domain.RecoveryFresh}, state: runtimeFresh}371+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}, record: domain.CatalogueRecord{Name: "work"}, state: ports.SessionStopped}
372 372
373 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})373 d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
374 awaitFrame(t, sends, ports.MsgOutput)374 awaitFrame(t, sends, ports.MsgOutput)
@@ -1142,7 +1142,6 @@ func TestPickerKillActiveSessionSnapshotDeleteRefusalReportsOnceAndKeepsPicker(t
1142 }1142 }
1143 record := target.persistRecordLocked(1)1143 record := target.persistRecordLocked(1)
1144 target.mu.Unlock()1144 target.mu.Unlock()
1145- record.RecoveryState = domain.RecoveryFresh
1146 require.NoError(t, d.catalogue.Create(record))1145 require.NoError(t, d.catalogue.Create(record))
1147 1146
1148 d.enterPicker(from, ac)1147 d.enterPicker(from, ac)
@@ -1178,7 +1177,7 @@ func TestPickerKillStoppedSessionPersistDeleteFailureSurfacesNoticeAndKeepsEntry
1178 cause := errors.New("delete failed")1177 cause := errors.New("delete failed")
1179 store, state := newMockStore(t)1178 store, state := newMockStore(t)
1180 WithStore(t, store)(d)1179 WithStore(t, store)(d)
1181- record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7, RecoveryState: domain.RecoveryFresh}1180+ record := domain.CatalogueRecord{Name: "stopped", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/stopped", CreatedAt: 7}
1182 require.NoError(t, d.catalogue.Create(record))1181 require.NoError(t, d.catalogue.Create(record))
1183 state.mu.Lock()1182 state.mu.Lock()
1184 state.deleteErr = func(string) error { return cause }1183 state.deleteErr = func(string) error { return cause }
M internal/usecase/daemon/recovery_observability.go +8 -8
@@ -31,22 +31,22 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) {
3131 } else {
3232 records = d.stoppedRecords()
3333 }
34- healthy, fresh, degraded := 0, 0, 0
34+ healthy, fresh, broken := 0, 0, 0
3535 for _, record := range records {
36- switch record.RecoveryState {
37- case domain.RecoveryHealthy:
38- healthy++
39- case domain.RecoveryFresh:
36+ switch {
37+ case record.DegradedReason != "":
38+ broken++
39+ case record.Committed == nil:
4040 fresh++
41- case domain.RecoveryDegraded:
42- degraded++
41+ default:
42+ healthy++
4343 }
4444 }
4545 d.log.Info("daemon_startup_complete",
4646 "healthy", healthy,
4747 "fresh", fresh,
4848 "restoring", restoring,
49- "degraded", degraded,
49+ "broken", broken,
5050 )
5151 }
5252
@@ -31,22 +31,22 @@ func (d *Daemon) logStartupRecoveryCounts(restoring int) {
31 } else {31 } else {
32 records = d.stoppedRecords()32 records = d.stoppedRecords()
33 }33 }
34- healthy, fresh, degraded := 0, 0, 034+ healthy, fresh, broken := 0, 0, 0
35 for _, record := range records {35 for _, record := range records {
36- switch record.RecoveryState {36+ switch {
37- case domain.RecoveryHealthy:37+ case record.DegradedReason != "":
38- healthy++38+ broken++
39- case domain.RecoveryFresh:39+ case record.Committed == nil:
40 fresh++40 fresh++
41- case domain.RecoveryDegraded:41+ default:
42- degraded++42+ healthy++
43 }43 }
44 }44 }
45 d.log.Info("daemon_startup_complete",45 d.log.Info("daemon_startup_complete",
46 "healthy", healthy,46 "healthy", healthy,
47 "fresh", fresh,47 "fresh", fresh,
48 "restoring", restoring,48 "restoring", restoring,
49- "degraded", degraded,49+ "broken", broken,
50 )50 )
51 }51 }
52 52
M internal/usecase/daemon/recovery_observability_test.go +8 -7
@@ -15,11 +15,12 @@ import (
1515 func TestStartupRecoveryCounts(t *testing.T) {
1616 var buffer bytes.Buffer
1717 log := slog.New(slog.NewJSONHandler(&buffer, nil))
18+ ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
1819 records := []domain.CatalogueRecord{
19- {Name: "one", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy},
20- {Name: "two", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy},
21- {Name: "new", IncarnationID: domain.IncarnationID{3}, RecoveryState: domain.RecoveryFresh},
22- {Name: "broken", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, DegradedReason: "terminal contents must not appear"},
20+ {Name: "one", IncarnationID: domain.IncarnationID{1}, Committed: ref},
21+ {Name: "two", IncarnationID: domain.IncarnationID{2}, Committed: ref},
22+ {Name: "new", IncarnationID: domain.IncarnationID{3}},
23+ {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref, DegradedReason: "terminal contents must not appear"},
2324 }
2425 d := New(nil, stubClock{}, log, WithCatalogue(newDurableRecoveryCatalogue(records), records))
2526 d.logSessionRestoreComplete(records[0], 7, false)
@@ -38,7 +39,7 @@ func TestStartupRecoveryCounts(t *testing.T) {
3839 require.EqualValues(t, 2, startup["healthy"])
3940 require.EqualValues(t, 1, startup["fresh"])
4041 require.EqualValues(t, 0, startup["restoring"])
41- require.EqualValues(t, 1, startup["degraded"])
42+ require.EqualValues(t, 1, startup["broken"])
4243 }
4344
4445 type failingRecordsCatalogue struct {
@@ -52,7 +53,7 @@ func (c failingRecordsCatalogue) Records() ([]domain.CatalogueRecord, error) {
5253
5354 func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) {
5455 var buffer bytes.Buffer
55- record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, DegradedReason: "invalid"}
56+ record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"}
5657 d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil)))
5758 d.persistEnabled = true
5859 d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")}
@@ -63,7 +64,7 @@ func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) {
6364 entries := daemonJSONLogs(t, buffer.Bytes())
6465 daemonRequireEvent(t, entries, "daemon_startup_catalogue_read_failed")
6566 startup := daemonRequireEvent(t, entries, "daemon_startup_complete")
66- require.EqualValues(t, 1, startup["degraded"])
67+ require.EqualValues(t, 1, startup["broken"])
6768 }
6869
6970 func daemonJSONLogs(t *testing.T, data []byte) []map[string]any {
@@ -15,11 +15,12 @@ import (
15 func TestStartupRecoveryCounts(t *testing.T) {15 func TestStartupRecoveryCounts(t *testing.T) {
16 var buffer bytes.Buffer16 var buffer bytes.Buffer
17 log := slog.New(slog.NewJSONHandler(&buffer, nil))17 log := slog.New(slog.NewJSONHandler(&buffer, nil))
18+ ref := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
18 records := []domain.CatalogueRecord{19 records := []domain.CatalogueRecord{
19- {Name: "one", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryHealthy},20+ {Name: "one", IncarnationID: domain.IncarnationID{1}, Committed: ref},
20- {Name: "two", IncarnationID: domain.IncarnationID{2}, RecoveryState: domain.RecoveryHealthy},21+ {Name: "two", IncarnationID: domain.IncarnationID{2}, Committed: ref},
21- {Name: "new", IncarnationID: domain.IncarnationID{3}, RecoveryState: domain.RecoveryFresh},22+ {Name: "new", IncarnationID: domain.IncarnationID{3}},
22- {Name: "broken", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, DegradedReason: "terminal contents must not appear"},23+ {Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref, DegradedReason: "terminal contents must not appear"},
23 }24 }
24 d := New(nil, stubClock{}, log, WithCatalogue(newDurableRecoveryCatalogue(records), records))25 d := New(nil, stubClock{}, log, WithCatalogue(newDurableRecoveryCatalogue(records), records))
25 d.logSessionRestoreComplete(records[0], 7, false)26 d.logSessionRestoreComplete(records[0], 7, false)
@@ -38,7 +39,7 @@ func TestStartupRecoveryCounts(t *testing.T) {
38 require.EqualValues(t, 2, startup["healthy"])39 require.EqualValues(t, 2, startup["healthy"])
39 require.EqualValues(t, 1, startup["fresh"])40 require.EqualValues(t, 1, startup["fresh"])
40 require.EqualValues(t, 0, startup["restoring"])41 require.EqualValues(t, 0, startup["restoring"])
41- require.EqualValues(t, 1, startup["degraded"])42+ require.EqualValues(t, 1, startup["broken"])
42 }43 }
43 44
44 type failingRecordsCatalogue struct {45 type failingRecordsCatalogue struct {
@@ -52,7 +53,7 @@ func (c failingRecordsCatalogue) Records() ([]domain.CatalogueRecord, error) {
52 53
53 func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) {54 func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) {
54 var buffer bytes.Buffer55 var buffer bytes.Buffer
55- record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, RecoveryState: domain.RecoveryDegraded, DegradedReason: "invalid"}56+ record := domain.CatalogueRecord{Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}, DegradedReason: "invalid"}
56 d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil)))57 d := New(nil, stubClock{}, slog.New(slog.NewJSONHandler(&buffer, nil)))
57 d.persistEnabled = true58 d.persistEnabled = true
58 d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")}59 d.catalogue = failingRecordsCatalogue{durableRecoveryCatalogue: newDurableRecoveryCatalogue(nil), err: errors.New("read failed")}
@@ -63,7 +64,7 @@ func TestStartupRecoveryCountsFallsBackWhenCatalogueReadFails(t *testing.T) {
63 entries := daemonJSONLogs(t, buffer.Bytes())64 entries := daemonJSONLogs(t, buffer.Bytes())
64 daemonRequireEvent(t, entries, "daemon_startup_catalogue_read_failed")65 daemonRequireEvent(t, entries, "daemon_startup_catalogue_read_failed")
65 startup := daemonRequireEvent(t, entries, "daemon_startup_complete")66 startup := daemonRequireEvent(t, entries, "daemon_startup_complete")
66- require.EqualValues(t, 1, startup["degraded"])67+ require.EqualValues(t, 1, startup["broken"])
67 }68 }
68 69
69 func daemonJSONLogs(t *testing.T, data []byte) []map[string]any {70 func daemonJSONLogs(t *testing.T, data []byte) []map[string]any {
M internal/usecase/daemon/session.go +2 -2
@@ -320,7 +320,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
320320 sess.snapEligible.Store(!ephemeral && name != "")
321321 if !ephemeral {
322322 if d.persistEnabled {
323- record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}
323+ record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}
324324 if d.recovery == nil {
325325 closeTabs(tabs)
326326 cancel()
@@ -901,7 +901,7 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
901901 } else {
902902 tabNames = tabNames[:lastCustom+1]
903903 }
904- return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, RecoveryState: domain.RecoveryFresh}
904+ return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
905905 }
906906
907907 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
@@ -320,7 +320,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
320 sess.snapEligible.Store(!ephemeral && name != "")320 sess.snapEligible.Store(!ephemeral && name != "")
321 if !ephemeral {321 if !ephemeral {
322 if d.persistEnabled {322 if d.persistEnabled {
323- record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}323+ record := domain.CatalogueRecord{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}
324 if d.recovery == nil {324 if d.recovery == nil {
325 closeTabs(tabs)325 closeTabs(tabs)
326 cancel()326 cancel()
@@ -901,7 +901,7 @@ func (s *session) persistRecordLocked(updatedAt int64) domain.CatalogueRecord {
901 } else {901 } else {
902 tabNames = tabNames[:lastCustom+1]902 tabNames = tabNames[:lastCustom+1]
903 }903 }
904- return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames, RecoveryState: domain.RecoveryFresh}904+ return domain.CatalogueRecord{Name: s.name, IncarnationID: s.incarnation, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
905 }905 }
906 906
907 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {907 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
M internal/usecase/daemon/session_purge_control_test.go +1 -1
@@ -16,7 +16,7 @@ func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) {
1616 WithSnapshotRepository(repository)(d)
1717 store, state := newMockStore(t)
1818 WithStore(t, store)(d)
19- record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}
19+ record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}}
2020 require.NoError(t, d.catalogue.Create(record))
2121 metadataErr := errors.New("metadata delete failed")
2222 state.mu.Lock()
@@ -16,7 +16,7 @@ func TestStoppedPurgeMetadataFailureRemainsFencedForRetry(t *testing.T) {
16 WithSnapshotRepository(repository)(d)16 WithSnapshotRepository(repository)(d)
17 store, state := newMockStore(t)17 store, state := newMockStore(t)
18 WithStore(t, store)(d)18 WithStore(t, store)(d)
19- record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryFresh}19+ record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}}
20 require.NoError(t, d.catalogue.Create(record))20 require.NoError(t, d.catalogue.Create(record))
21 metadataErr := errors.New("metadata delete failed")21 metadataErr := errors.New("metadata delete failed")
22 state.mu.Lock()22 state.mu.Lock()
M internal/usecase/daemon/session_purge_retry_test.go +0 -1
@@ -34,7 +34,6 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te
3434 WithStore(t, store)(d)
3535 sess := newSnapshotTestSession(t, "work", false, "/work")
3636 record := sess.persistRecordLocked(1)
37- record.RecoveryState = domain.RecoveryFresh
3837 require.NoError(t, d.catalogue.Create(record))
3938 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
4039 require.True(t, ok)
@@ -34,7 +34,6 @@ func TestLivePurgeLeavesFailedDirectoryDeletionForStartupGarbageCollection(t *te
34 WithStore(t, store)(d)34 WithStore(t, store)(d)
35 sess := newSnapshotTestSession(t, "work", false, "/work")35 sess := newSnapshotTestSession(t, "work", false, "/work")
36 record := sess.persistRecordLocked(1)36 record := sess.persistRecordLocked(1)
37- record.RecoveryState = domain.RecoveryFresh
38 require.NoError(t, d.catalogue.Create(record))37 require.NoError(t, d.catalogue.Create(record))
39 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)38 pty, ok := sess.tabs[0].panes["pane-1"].pty.(*portsmocks.MockPTY)
40 require.True(t, ok)39 require.True(t, ok)
M internal/usecase/daemon/session_test.go +10 -12
@@ -869,7 +869,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
869869 store, _ := newMockStore(t)
870870 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
871871 incarnation := domain.IncarnationID{1}
872- require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9, RecoveryState: domain.RecoveryFresh}))
872+ require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))
873873
874874 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
875875 d.mu.Lock()
@@ -880,8 +880,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
880880 require.Equal(t, int64(7), stopped.createdAt)
881881 require.Equal(t, incarnation, stopped.incarnation)
882882 require.Equal(t, uint64(9), stopped.lastUsedSeq)
883- require.Equal(t, domain.RecoveryFresh, stopped.record.RecoveryState)
884- require.Equal(t, runtimeFresh, stopped.state)
883+ require.Equal(t, ports.SessionStopped, stopped.state)
885884 require.NotNil(t, stopped.restoreDone)
886885 require.Equal(t, uint64(9), d.mruSeq.Load())
887886 }
@@ -951,7 +950,7 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
951950 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
952951 WithStore(t, store)(d)
953952 named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}
954- require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
953+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
955954
956955 d.touchMRU(named)
957956 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
@@ -994,7 +993,6 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
994993 require.True(t, state.has("work"))
995994 created := state.record(t, "work")
996995 require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID)
997- require.Equal(t, domain.RecoveryFresh, created.RecoveryState)
998996 require.NoError(t, created.Validate())
999997 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
1000998 require.NoError(t, d.createTab(sess, sz))
@@ -1142,7 +1140,7 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) {
11421140 store, _ := newMockStore(t)
11431141 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
11441142 WithStore(t, store)(d)
1145- require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}, RecoveryState: domain.RecoveryFresh}))
1143+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
11461144 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
11471145 tr := portsmocks.NewMockTransport(t)
11481146 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1356,7 +1354,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
13561354 store, state := newMockStore(t)
13571355 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
13581356 WithStore(t, store)(d)
1359- require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))
1357+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
13601358 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
13611359 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
13621360 require.False(t, state.has("old"))
@@ -1837,15 +1835,15 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
18371835 alphaRef := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
18381836 workRef := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
18391837 records := []domain.CatalogueRecord{
1840- {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, RecoveryState: domain.RecoveryHealthy, Committed: alphaRef},
1841- {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, RecoveryState: domain.RecoveryDegraded, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"},
1838+ {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, Committed: alphaRef},
1839+ {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"},
18421840 }
18431841
18441842 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records))
18451843
18461844 alpha := d.stopped["alpha"]
18471845 require.Equal(t, records[0], alpha.record)
1848- require.Equal(t, runtimeRestoring, alpha.state)
1846+ require.Equal(t, ports.SessionStopped, alpha.state)
18491847 require.NotNil(t, alpha.restoreDone)
18501848 select {
18511849 case <-alpha.restoreDone:
@@ -1854,7 +1852,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
18541852 }
18551853 work := d.stopped["work"]
18561854 require.Equal(t, records[1], work.record)
1857- require.Equal(t, runtimeDegraded, work.state)
1855+ require.Equal(t, ports.SessionBroken, work.state)
18581856 require.NotNil(t, work.restoreDone)
18591857 select {
18601858 case <-work.restoreDone:
@@ -1868,7 +1866,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
18681866 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
18691867 store, _ := newMockStore(t)
18701868 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
1871- require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900, RecoveryState: domain.RecoveryFresh}))
1869+ require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
18721870
18731871 p, release := newBlockingPTY(t)
18741872 defer release()
@@ -869,7 +869,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
869 store, _ := newMockStore(t)869 store, _ := newMockStore(t)
870 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))870 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
871 incarnation := domain.IncarnationID{1}871 incarnation := domain.IncarnationID{1}
872- require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9, RecoveryState: domain.RecoveryFresh}))872+ require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "work", IncarnationID: incarnation, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, LastUsedSeq: 9}))
873 873
874 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))874 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
875 d.mu.Lock()875 d.mu.Lock()
@@ -880,8 +880,7 @@ func TestDaemonLoadsPersistedSessionsAsStopped(t *testing.T) {
880 require.Equal(t, int64(7), stopped.createdAt)880 require.Equal(t, int64(7), stopped.createdAt)
881 require.Equal(t, incarnation, stopped.incarnation)881 require.Equal(t, incarnation, stopped.incarnation)
882 require.Equal(t, uint64(9), stopped.lastUsedSeq)882 require.Equal(t, uint64(9), stopped.lastUsedSeq)
883- require.Equal(t, domain.RecoveryFresh, stopped.record.RecoveryState)883+ require.Equal(t, ports.SessionStopped, stopped.state)
884- require.Equal(t, runtimeFresh, stopped.state)
885 require.NotNil(t, stopped.restoreDone)884 require.NotNil(t, stopped.restoreDone)
886 require.Equal(t, uint64(9), d.mruSeq.Load())885 require.Equal(t, uint64(9), d.mruSeq.Load())
887 }886 }
@@ -951,7 +950,7 @@ func TestTouchMRUPersistsNamedButNotEphemeral(t *testing.T) {
951 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})950 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
952 WithStore(t, store)(d)951 WithStore(t, store)(d)
953 named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}952 named := &session{name: "work", tabs: []*tab{{}}, createdAt: 1, incarnation: domain.IncarnationID{1}}
954- require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))953+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: named.incarnation, Cwd: "/work", CreatedAt: 1, UpdatedAt: 1}))
955 954
956 d.touchMRU(named)955 d.touchMRU(named)
957 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)956 require.Equal(t, named.mruAt.Load(), state.record(t, "work").LastUsedSeq)
@@ -994,7 +993,6 @@ func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
994 require.True(t, state.has("work"))993 require.True(t, state.has("work"))
995 created := state.record(t, "work")994 created := state.record(t, "work")
996 require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID)995 require.NotEqual(t, domain.IncarnationID{}, created.IncarnationID)
997- require.Equal(t, domain.RecoveryFresh, created.RecoveryState)
998 require.NoError(t, created.Validate())996 require.NoError(t, created.Validate())
999 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))997 require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
1000 require.NoError(t, d.createTab(sess, sz))998 require.NoError(t, d.createTab(sess, sz))
@@ -1142,7 +1140,7 @@ func TestAttachRestoresPersistedTabNames(t *testing.T) {
1142 store, _ := newMockStore(t)1140 store, _ := newMockStore(t)
1143 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})1141 d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
1144 WithStore(t, store)(d)1142 WithStore(t, store)(d)
1145- require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}, RecoveryState: domain.RecoveryFresh}))1143+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
1146 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}1144 d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
1147 tr := portsmocks.NewMockTransport(t)1145 tr := portsmocks.NewMockTransport(t)
1148 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()1146 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
@@ -1356,7 +1354,7 @@ func TestPickerStoppedTargetKillPurges(t *testing.T) {
1356 store, state := newMockStore(t)1354 store, state := newMockStore(t)
1357 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})1355 d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
1358 WithStore(t, store)(d)1356 WithStore(t, store)(d)
1359- require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1, RecoveryState: domain.RecoveryFresh}))1357+ require.NoError(t, testPersister(t, d).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 1, UpdatedAt: 1}))
1360 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}1358 d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp", createdAt: 1}
1361 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))1359 require.NoError(t, d.killPickerTarget(picker.Target{Name: "old", Stopped: true}))
1362 require.False(t, state.has("old"))1360 require.False(t, state.has("old"))
@@ -1837,15 +1835,15 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
1837 alphaRef := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}1835 alphaRef := &domain.CheckpointRef{Generation: 1, ManifestDigest: [32]byte{1}}
1838 workRef := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}1836 workRef := &domain.CheckpointRef{Generation: 2, ManifestDigest: [32]byte{2}}
1839 records := []domain.CatalogueRecord{1837 records := []domain.CatalogueRecord{
1840- {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, RecoveryState: domain.RecoveryHealthy, Committed: alphaRef},1838+ {Name: "alpha", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp/alpha", CreatedAt: 7, LastUsedSeq: 11, TabNames: []string{"shell"}, Committed: alphaRef},
1841- {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, RecoveryState: domain.RecoveryDegraded, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"},1839+ {Name: "work", IncarnationID: domain.IncarnationID{2}, Cwd: "/tmp/work", CreatedAt: 9, LastUsedSeq: 13, TabNames: []string{"editor", "logs"}, Committed: workRef, DegradedReason: "uncertain legacy checkpoint"},
1842 }1840 }
1843 1841
1844 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records))1842 d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithCatalogue(persist.New(nil), records))
1845 1843
1846 alpha := d.stopped["alpha"]1844 alpha := d.stopped["alpha"]
1847 require.Equal(t, records[0], alpha.record)1845 require.Equal(t, records[0], alpha.record)
1848- require.Equal(t, runtimeRestoring, alpha.state)1846+ require.Equal(t, ports.SessionStopped, alpha.state)
1849 require.NotNil(t, alpha.restoreDone)1847 require.NotNil(t, alpha.restoreDone)
1850 select {1848 select {
1851 case <-alpha.restoreDone:1849 case <-alpha.restoreDone:
@@ -1854,7 +1852,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
1854 }1852 }
1855 work := d.stopped["work"]1853 work := d.stopped["work"]
1856 require.Equal(t, records[1], work.record)1854 require.Equal(t, records[1], work.record)
1857- require.Equal(t, runtimeDegraded, work.state)1855+ require.Equal(t, ports.SessionBroken, work.state)
1858 require.NotNil(t, work.restoreDone)1856 require.NotNil(t, work.restoreDone)
1859 select {1857 select {
1860 case <-work.restoreDone:1858 case <-work.restoreDone:
@@ -1868,7 +1866,7 @@ func TestCatalogueRecordsConstructExpectedSessionRegistry(t *testing.T) {
1868 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {1866 func TestNamedSessionLifecycleTimestampStartsAfterPersistedHighWaterMark(t *testing.T) {
1869 store, _ := newMockStore(t)1867 store, _ := newMockStore(t)
1870 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))1868 seed := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithStore(t, store))
1871- require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900, RecoveryState: domain.RecoveryFresh}))1869+ require.NoError(t, testPersister(t, seed).Save(persist.Record{Name: "old", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp", CreatedAt: 900, UpdatedAt: 900}))
1872 1870
1873 p, release := newBlockingPTY(t)1871 p, release := newBlockingPTY(t)
1874 defer release()1872 defer release()
M internal/usecase/daemon/snapshot_restore.go +8 -3
@@ -8,6 +8,7 @@ import (
88 "math"
99
1010 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
1112 "github.com/bnema/vev/internal/usecase/layout"
1213 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
1314 "github.com/bnema/vev/pkg/vt"
@@ -16,11 +17,15 @@ import (
1617 func (d *Daemon) closeRestoreDone() {
1718 d.mu.Lock()
1819 for name, entry := range d.stopped {
19- if entry.state != runtimeRestoring {
20+ if entry.restoreDone == nil {
2021 continue
2122 }
22- entry.state = runtimeDegraded
23- entry.record.RecoveryState = domain.RecoveryDegraded
23+ select {
24+ case <-entry.restoreDone:
25+ continue
26+ default:
27+ }
28+ entry.state = ports.SessionBroken
2429 if entry.record.DegradedReason == "" {
2530 entry.record.DegradedReason = "restore unavailable"
2631 }
@@ -8,6 +8,7 @@ import (
8 "math"8 "math"
9 9
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
11 "github.com/bnema/vev/internal/usecase/layout"12 "github.com/bnema/vev/internal/usecase/layout"
12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"13 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
13 "github.com/bnema/vev/pkg/vt"14 "github.com/bnema/vev/pkg/vt"
@@ -16,11 +17,15 @@ import (
16 func (d *Daemon) closeRestoreDone() {17 func (d *Daemon) closeRestoreDone() {
17 d.mu.Lock()18 d.mu.Lock()
18 for name, entry := range d.stopped {19 for name, entry := range d.stopped {
19- if entry.state != runtimeRestoring {20+ if entry.restoreDone == nil {
20 continue21 continue
21 }22 }
22- entry.state = runtimeDegraded23+ select {
23- entry.record.RecoveryState = domain.RecoveryDegraded24+ case <-entry.restoreDone:
25+ continue
26+ default:
27+ }
28+ entry.state = ports.SessionBroken
24 if entry.record.DegradedReason == "" {29 if entry.record.DegradedReason == "" {
25 entry.record.DegradedReason = "restore unavailable"30 entry.record.DegradedReason = "restore unavailable"
26 }31 }
M internal/usecase/daemon/snapshot_restore_incremental.go +16 -34
@@ -18,23 +18,13 @@ const catalogueRestoreConcurrency = 8
1818
1919 var errRetryableRestoreLoad = errors.New("snapshot: retryable checkpoint load")
2020
21-func initialRuntimeRecoveryState(record domain.CatalogueRecord) (runtimeRecoveryState, chan struct{}) {
21+func initialSessionState(record domain.CatalogueRecord) (ports.SessionState, chan struct{}) {
2222 done := make(chan struct{})
23- var state runtimeRecoveryState
24- switch record.RecoveryState {
25- case domain.RecoveryFresh:
26- state = runtimeFresh
23+ state := ports.SessionStopped
24+ if record.DegradedReason != "" {
25+ state = ports.SessionBroken
2726 close(done)
28- case domain.RecoveryHealthy:
29- state = runtimeRestoring
30- case domain.RecoveryDegraded:
31- state = runtimeDegraded
32- close(done)
33- case domain.RecoveryDeleting:
34- state = runtimeDeleting
35- close(done)
36- default:
37- state = runtimeDegraded
27+ } else if record.Committed == nil {
3828 close(done)
3929 }
4030 return state, done
@@ -84,7 +74,7 @@ func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) {
8474 if entry, ok := d.stopped[record.Name]; ok && entry.restoreDone != nil {
8575 return
8676 }
87- state, done := initialRuntimeRecoveryState(record)
77+ state, done := initialSessionState(record)
8878 d.stopped[record.Name] = stoppedSession{
8979 name: record.Name,
9080 cwd: record.Cwd,
@@ -117,7 +107,7 @@ func closeRuntimeRestoreDoneLocked(done chan struct{}) {
117107 }
118108 }
119109
120-func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state runtimeRecoveryState) {
110+func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) {
121111 d.mu.Lock()
122112 defer d.mu.Unlock()
123113 entry, ok := d.stopped[record.Name]
@@ -144,7 +134,6 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
144134 d.log.Error("reading catalogue before degradation failed", "session", record.Name, "err", readErr)
145135 case ok && current.IncarnationID == record.IncarnationID:
146136 record = current
147- record.RecoveryState = domain.RecoveryDegraded
148137 record.DegradedReason = "checkpoint validation failed"
149138 switch {
150139 case retryable:
@@ -165,7 +154,7 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
165154 switch {
166155 case degraded:
167156 entry.record = record
168- entry.state = runtimeDegraded
157+ entry.state = ports.SessionBroken
169158 entry.cwd = record.Cwd
170159 entry.createdAt = record.CreatedAt
171160 entry.incarnation = record.IncarnationID
@@ -189,22 +178,15 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
189178 }
190179
191180 func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error {
192- switch record.RecoveryState {
193- case domain.RecoveryFresh:
194- d.setStoppedRecovery(record, runtimeFresh)
195- d.logSessionRestoreComplete(record, 0, false)
196- return nil
197- case domain.RecoveryDegraded:
198- d.setStoppedRecovery(record, runtimeDegraded)
199- d.logSessionDegraded(record, "persisted-degraded")
181+ if record.DegradedReason != "" {
182+ d.setStoppedRecovery(record, ports.SessionBroken)
183+ d.logSessionDegraded(record, "persisted-broken")
200184 return nil
201- case domain.RecoveryDeleting:
202- d.setStoppedRecovery(record, runtimeDeleting)
185+ }
186+ if record.Committed == nil {
187+ d.setStoppedRecovery(record, ports.SessionStopped)
188+ d.logSessionRestoreComplete(record, 0, false)
203189 return nil
204- case domain.RecoveryHealthy:
205- // Continue below.
206- default:
207- return errors.New("snapshot: invalid catalogue recovery state")
208190 }
209191 if d.snapshotRepository == nil {
210192 return errors.New("snapshot: repository unavailable")
@@ -251,7 +233,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
251233 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil {
252234 return err
253235 }
254- d.setStoppedRecovery(record, runtimeHealthy)
236+ d.setStoppedRecovery(record, ports.SessionStopped)
255237 d.logSessionRestoreComplete(record, selected.Generation, false)
256238 return nil
257239 }
@@ -18,23 +18,13 @@ const catalogueRestoreConcurrency = 8
18 18
19 var errRetryableRestoreLoad = errors.New("snapshot: retryable checkpoint load")19 var errRetryableRestoreLoad = errors.New("snapshot: retryable checkpoint load")
20 20
21-func initialRuntimeRecoveryState(record domain.CatalogueRecord) (runtimeRecoveryState, chan struct{}) {21+func initialSessionState(record domain.CatalogueRecord) (ports.SessionState, chan struct{}) {
22 done := make(chan struct{})22 done := make(chan struct{})
23- var state runtimeRecoveryState23+ state := ports.SessionStopped
24- switch record.RecoveryState {24+ if record.DegradedReason != "" {
25- case domain.RecoveryFresh:25+ state = ports.SessionBroken
26- state = runtimeFresh
27 close(done)26 close(done)
28- case domain.RecoveryHealthy:27+ } else if record.Committed == nil {
29- state = runtimeRestoring
30- case domain.RecoveryDegraded:
31- state = runtimeDegraded
32- close(done)
33- case domain.RecoveryDeleting:
34- state = runtimeDeleting
35- close(done)
36- default:
37- state = runtimeDegraded
38 close(done)28 close(done)
39 }29 }
40 return state, done30 return state, done
@@ -84,7 +74,7 @@ func (d *Daemon) ensureCatalogueRegistryEntry(record domain.CatalogueRecord) {
84 if entry, ok := d.stopped[record.Name]; ok && entry.restoreDone != nil {74 if entry, ok := d.stopped[record.Name]; ok && entry.restoreDone != nil {
85 return75 return
86 }76 }
87- state, done := initialRuntimeRecoveryState(record)77+ state, done := initialSessionState(record)
88 d.stopped[record.Name] = stoppedSession{78 d.stopped[record.Name] = stoppedSession{
89 name: record.Name,79 name: record.Name,
90 cwd: record.Cwd,80 cwd: record.Cwd,
@@ -117,7 +107,7 @@ func closeRuntimeRestoreDoneLocked(done chan struct{}) {
117 }107 }
118 }108 }
119 109
120-func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state runtimeRecoveryState) {110+func (d *Daemon) setStoppedRecovery(record domain.CatalogueRecord, state ports.SessionState) {
121 d.mu.Lock()111 d.mu.Lock()
122 defer d.mu.Unlock()112 defer d.mu.Unlock()
123 entry, ok := d.stopped[record.Name]113 entry, ok := d.stopped[record.Name]
@@ -144,7 +134,6 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
144 d.log.Error("reading catalogue before degradation failed", "session", record.Name, "err", readErr)134 d.log.Error("reading catalogue before degradation failed", "session", record.Name, "err", readErr)
145 case ok && current.IncarnationID == record.IncarnationID:135 case ok && current.IncarnationID == record.IncarnationID:
146 record = current136 record = current
147- record.RecoveryState = domain.RecoveryDegraded
148 record.DegradedReason = "checkpoint validation failed"137 record.DegradedReason = "checkpoint validation failed"
149 switch {138 switch {
150 case retryable:139 case retryable:
@@ -165,7 +154,7 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
165 switch {154 switch {
166 case degraded:155 case degraded:
167 entry.record = record156 entry.record = record
168- entry.state = runtimeDegraded157+ entry.state = ports.SessionBroken
169 entry.cwd = record.Cwd158 entry.cwd = record.Cwd
170 entry.createdAt = record.CreatedAt159 entry.createdAt = record.CreatedAt
171 entry.incarnation = record.IncarnationID160 entry.incarnation = record.IncarnationID
@@ -189,22 +178,15 @@ func (d *Daemon) finishRecordRestore(record domain.CatalogueRecord, restoreErr e
189 }178 }
190 179
191 func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error {180 func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecord) error {
192- switch record.RecoveryState {181+ if record.DegradedReason != "" {
193- case domain.RecoveryFresh:182+ d.setStoppedRecovery(record, ports.SessionBroken)
194- d.setStoppedRecovery(record, runtimeFresh)183+ d.logSessionDegraded(record, "persisted-broken")
195- d.logSessionRestoreComplete(record, 0, false)
196- return nil
197- case domain.RecoveryDegraded:
198- d.setStoppedRecovery(record, runtimeDegraded)
199- d.logSessionDegraded(record, "persisted-degraded")
200 return nil184 return nil
201- case domain.RecoveryDeleting:185+ }
202- d.setStoppedRecovery(record, runtimeDeleting)186+ if record.Committed == nil {
187+ d.setStoppedRecovery(record, ports.SessionStopped)
188+ d.logSessionRestoreComplete(record, 0, false)
203 return nil189 return nil
204- case domain.RecoveryHealthy:
205- // Continue below.
206- default:
207- return errors.New("snapshot: invalid catalogue recovery state")
208 }190 }
209 if d.snapshotRepository == nil {191 if d.snapshotRepository == nil {
210 return errors.New("snapshot: repository unavailable")192 return errors.New("snapshot: repository unavailable")
@@ -251,7 +233,7 @@ func (d *Daemon) restoreRecord(ctx context.Context, record domain.CatalogueRecor
251 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil {233 if err := d.restoreSession(ctx, selectedSnapshot, selectedGeneration.Generation, selected); err != nil {
252 return err234 return err
253 }235 }
254- d.setStoppedRecovery(record, runtimeHealthy)236+ d.setStoppedRecovery(record, ports.SessionStopped)
255 d.logSessionRestoreComplete(record, selected.Generation, false)237 d.logSessionRestoreComplete(record, selected.Generation, false)
256 return nil238 return nil
257 }239 }
M internal/usecase/daemon/snapshot_restore_test.go +2 -4
@@ -219,7 +219,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
219219 pty, release := newBlockingPTY(t)
220220 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
221221 checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
222- record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), RecoveryState: domain.RecoveryHealthy, Committed: &checkpoint}
222+ record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint}
223223 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
224224 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
225225 WithSnapshotRepository(repository)(d)
@@ -265,8 +265,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
265265 record := domain.CatalogueRecord{
266266 Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd",
267267 CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17,
268- TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy,
269- Committed: &committed,
268+ TabNames: []string{"before"}, Committed: &committed,
270269 }
271270 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
272271
@@ -292,7 +291,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
292291 require.NoError(t, err)
293292 require.True(t, ok)
294293 require.Equal(t, record.IncarnationID, updated.IncarnationID)
295- require.Equal(t, record.RecoveryState, updated.RecoveryState)
296294 require.Equal(t, record.Committed, updated.Committed)
297295 require.Equal(t, record.DegradedReason, updated.DegradedReason)
298296 require.Equal(t, []string{"after"}, updated.TabNames)
@@ -219,7 +219,7 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
219 pty, release := newBlockingPTY(t)219 pty, release := newBlockingPTY(t)
220 d := newTestDaemon(t, newFactory(t, pty), stubClock{})220 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
221 checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}221 checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
222- record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), RecoveryState: domain.RecoveryHealthy, Committed: &checkpoint}222+ record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint}
223 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})223 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
224 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)224 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
225 WithSnapshotRepository(repository)(d)225 WithSnapshotRepository(repository)(d)
@@ -265,8 +265,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
265 record := domain.CatalogueRecord{265 record := domain.CatalogueRecord{
266 Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd",266 Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd",
267 CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17,267 CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17,
268- TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy,268+ TabNames: []string{"before"}, Committed: &committed,
269- Committed: &committed,
270 }269 }
271 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})270 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
272 271
@@ -292,7 +291,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
292 require.NoError(t, err)291 require.NoError(t, err)
293 require.True(t, ok)292 require.True(t, ok)
294 require.Equal(t, record.IncarnationID, updated.IncarnationID)293 require.Equal(t, record.IncarnationID, updated.IncarnationID)
295- require.Equal(t, record.RecoveryState, updated.RecoveryState)
296 require.Equal(t, record.Committed, updated.Committed)294 require.Equal(t, record.Committed, updated.Committed)
297 require.Equal(t, record.DegradedReason, updated.DegradedReason)295 require.Equal(t, record.DegradedReason, updated.DegradedReason)
298 require.Equal(t, []string{"after"}, updated.TabNames)296 require.Equal(t, []string{"after"}, updated.TabNames)
M internal/usecase/daemon/snapshot_test.go +0 -1
@@ -46,7 +46,6 @@ func TestCheckpointCatalogueFailureKeepsCaptureRetryable(t *testing.T) {
4646 record := domain.CatalogueRecord{
4747 Name: "work",
4848 IncarnationID: domain.IncarnationID{1},
49- RecoveryState: domain.RecoveryFresh,
5049 }
5150 catalogue.EXPECT().Record("work").Return(record, true, nil).Once()
5251 repository.EXPECT().Publish(mock.Anything, mock.Anything).Return(nil).Once()
@@ -46,7 +46,6 @@ func TestCheckpointCatalogueFailureKeepsCaptureRetryable(t *testing.T) {
46 record := domain.CatalogueRecord{46 record := domain.CatalogueRecord{
47 Name: "work",47 Name: "work",
48 IncarnationID: domain.IncarnationID{1},48 IncarnationID: domain.IncarnationID{1},
49- RecoveryState: domain.RecoveryFresh,
50 }49 }
51 catalogue.EXPECT().Record("work").Return(record, true, nil).Once()50 catalogue.EXPECT().Record("work").Return(record, true, nil).Once()
52 repository.EXPECT().Publish(mock.Anything, mock.Anything).Return(nil).Once()51 repository.EXPECT().Publish(mock.Anything, mock.Anything).Return(nil).Once()
M internal/usecase/recovery/checkpoint.go +0 -1
@@ -43,7 +43,6 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public
4343
4444 ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)}
4545 next := record
46- next.RecoveryState = domain.RecoveryHealthy
4746 next.Committed = copyCheckpointRef(&ref)
4847 next.DegradedReason = ""
4948 if err := next.Validate(); err != nil {
@@ -43,7 +43,6 @@ func (c *Coordinator) PublishCheckpoint(ctx context.Context, name string, public
43 43
44 ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)}44 ref := domain.CheckpointRef{Generation: publication.Generation, ManifestDigest: sha256.Sum256(publication.Manifest)}
45 next := record45 next := record
46- next.RecoveryState = domain.RecoveryHealthy
47 next.Committed = copyCheckpointRef(&ref)46 next.Committed = copyCheckpointRef(&ref)
48 next.DegradedReason = ""47 next.DegradedReason = ""
49 if err := next.Validate(); err != nil {48 if err := next.Validate(); err != nil {
M internal/usecase/recovery/checkpoint_test.go +0 -3
@@ -93,7 +93,6 @@ func checkpointRecord() domain.CatalogueRecord {
9393 return domain.CatalogueRecord{
9494 Name: "work",
9595 IncarnationID: domain.IncarnationID{1},
96- RecoveryState: domain.RecoveryFresh,
9796 }
9897 }
9998
@@ -138,7 +137,6 @@ func TestCheckpointCommits(t *testing.T) {
138137 t.Run(tc.name, func(t *testing.T) {
139138 record, catalogue, _ := runCheckpointCommit(t, tc.count)
140139 require.Equal(t, tc.wantCommitted, record.Committed.Generation)
141- require.Equal(t, domain.RecoveryHealthy, record.RecoveryState)
142140 require.Empty(t, record.DegradedReason)
143141 require.Equal(t, tc.count, catalogue.replaces)
144142 })
@@ -148,7 +146,6 @@ func TestCheckpointCommits(t *testing.T) {
148146 func TestCheckpointCommitPublishOrphan(t *testing.T) {
149147 prior := checkpointRecord()
150148 priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
151- prior.RecoveryState = domain.RecoveryHealthy
152149 prior.Committed = &priorRef
153150 cause := errors.New("catalogue sync failed")
154151 catalogue := &checkpointCatalogue{record: prior, replaceErr: cause}
@@ -93,7 +93,6 @@ func checkpointRecord() domain.CatalogueRecord {
93 return domain.CatalogueRecord{93 return domain.CatalogueRecord{
94 Name: "work",94 Name: "work",
95 IncarnationID: domain.IncarnationID{1},95 IncarnationID: domain.IncarnationID{1},
96- RecoveryState: domain.RecoveryFresh,
97 }96 }
98 }97 }
99 98
@@ -138,7 +137,6 @@ func TestCheckpointCommits(t *testing.T) {
138 t.Run(tc.name, func(t *testing.T) {137 t.Run(tc.name, func(t *testing.T) {
139 record, catalogue, _ := runCheckpointCommit(t, tc.count)138 record, catalogue, _ := runCheckpointCommit(t, tc.count)
140 require.Equal(t, tc.wantCommitted, record.Committed.Generation)139 require.Equal(t, tc.wantCommitted, record.Committed.Generation)
141- require.Equal(t, domain.RecoveryHealthy, record.RecoveryState)
142 require.Empty(t, record.DegradedReason)140 require.Empty(t, record.DegradedReason)
143 require.Equal(t, tc.count, catalogue.replaces)141 require.Equal(t, tc.count, catalogue.replaces)
144 })142 })
@@ -148,7 +146,6 @@ func TestCheckpointCommits(t *testing.T) {
148 func TestCheckpointCommitPublishOrphan(t *testing.T) {146 func TestCheckpointCommitPublishOrphan(t *testing.T) {
149 prior := checkpointRecord()147 prior := checkpointRecord()
150 priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}148 priorRef := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
151- prior.RecoveryState = domain.RecoveryHealthy
152 prior.Committed = &priorRef149 prior.Committed = &priorRef
153 cause := errors.New("catalogue sync failed")150 cause := errors.New("catalogue sync failed")
154 catalogue := &checkpointCatalogue{record: prior, replaceErr: cause}151 catalogue := &checkpointCatalogue{record: prior, replaceErr: cause}
M internal/usecase/recovery/coordinator.go +0 -1
@@ -113,7 +113,6 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord)
113113 return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
114114 }
115115 record.IncarnationID = id
116- record.RecoveryState = domain.RecoveryFresh
117116 record.Committed = nil
118117 record.DegradedReason = ""
119118 if err := record.Validate(); err != nil {
@@ -113,7 +113,6 @@ func (c *Coordinator) Create(ctx context.Context, record domain.CatalogueRecord)
113 return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)113 return domain.CatalogueRecord{}, fmt.Errorf("recovery: generate incarnation: %w", err)
114 }114 }
115 record.IncarnationID = id115 record.IncarnationID = id
116- record.RecoveryState = domain.RecoveryFresh
117 record.Committed = nil116 record.Committed = nil
118 record.DegradedReason = ""117 record.DegradedReason = ""
119 if err := record.Validate(); err != nil {118 if err := record.Validate(); err != nil {
M internal/usecase/recovery/discard.go +4 -5
@@ -10,7 +10,7 @@ import (
1010
1111 var (
1212 ErrRecoveryRecordNotFound = errors.New("recovery: session not found")
13- ErrSessionNotDegraded = errors.New("recovery: session is not degraded")
13+ ErrSessionNotBroken = errors.New("recovery: session is not broken")
1414 )
1515
1616 // Discard throws away a broken session's persisted state and replaces it with a
@@ -34,11 +34,11 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error {
3434 if !ok {
3535 return fmt.Errorf("discard %q: %w", name, ErrRecoveryRecordNotFound)
3636 }
37- if record.RecoveryState == domain.RecoveryFresh {
37+ if record.Committed == nil {
3838 return nil
3939 }
40- if record.RecoveryState != domain.RecoveryDegraded {
41- return ErrSessionNotDegraded
40+ if record.DegradedReason == "" {
41+ return ErrSessionNotBroken
4242 }
4343
4444 old := record.IncarnationID
@@ -48,7 +48,6 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error {
4848 }
4949 fresh := record
5050 fresh.IncarnationID = next
51- fresh.RecoveryState = domain.RecoveryFresh
5251 fresh.Committed = nil
5352 fresh.DegradedReason = ""
5453 if err := fresh.Validate(); err != nil {
@@ -10,7 +10,7 @@ import (
10 10
11 var (11 var (
12 ErrRecoveryRecordNotFound = errors.New("recovery: session not found")12 ErrRecoveryRecordNotFound = errors.New("recovery: session not found")
13- ErrSessionNotDegraded = errors.New("recovery: session is not degraded")13+ ErrSessionNotBroken = errors.New("recovery: session is not broken")
14 )14 )
15 15
16 // Discard throws away a broken session's persisted state and replaces it with a16 // Discard throws away a broken session's persisted state and replaces it with a
@@ -34,11 +34,11 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error {
34 if !ok {34 if !ok {
35 return fmt.Errorf("discard %q: %w", name, ErrRecoveryRecordNotFound)35 return fmt.Errorf("discard %q: %w", name, ErrRecoveryRecordNotFound)
36 }36 }
37- if record.RecoveryState == domain.RecoveryFresh {37+ if record.Committed == nil {
38 return nil38 return nil
39 }39 }
40- if record.RecoveryState != domain.RecoveryDegraded {40+ if record.DegradedReason == "" {
41- return ErrSessionNotDegraded41+ return ErrSessionNotBroken
42 }42 }
43 43
44 old := record.IncarnationID44 old := record.IncarnationID
@@ -48,7 +48,6 @@ func (c *Coordinator) Discard(ctx context.Context, name string) error {
48 }48 }
49 fresh := record49 fresh := record
50 fresh.IncarnationID = next50 fresh.IncarnationID = next
51- fresh.RecoveryState = domain.RecoveryFresh
52 fresh.Committed = nil51 fresh.Committed = nil
53 fresh.DegradedReason = ""52 fresh.DegradedReason = ""
54 if err := fresh.Validate(); err != nil {53 if err := fresh.Validate(); err != nil {
M internal/usecase/recovery/transactions_test.go +1 -3
@@ -119,8 +119,7 @@ func (r *transactionRepository) DeleteIncarnation(_ context.Context, id domain.I
119119
120120 func degradedTransactionRecord() domain.CatalogueRecord {
121121 return domain.CatalogueRecord{
122- Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded,
123- Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}},
122+ Name: "broken", IncarnationID: domain.IncarnationID{1}, Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}},
124123 DegradedReason: "checkpoint unreadable",
125124 }
126125 }
@@ -157,7 +156,6 @@ func TestDiscardIsRetryIdempotent(t *testing.T) {
157156 require.NoError(t, err)
158157 require.True(t, ok)
159158 require.Nil(t, got.Committed)
160- require.Equal(t, domain.RecoveryFresh, got.RecoveryState)
161159 require.NotEqual(t, old.IncarnationID, got.IncarnationID)
162160 if tt.failBefore {
163161 require.Contains(t, repository.deletedIDs, old.IncarnationID)
@@ -119,8 +119,7 @@ func (r *transactionRepository) DeleteIncarnation(_ context.Context, id domain.I
119 119
120 func degradedTransactionRecord() domain.CatalogueRecord {120 func degradedTransactionRecord() domain.CatalogueRecord {
121 return domain.CatalogueRecord{121 return domain.CatalogueRecord{
122- Name: "broken", IncarnationID: domain.IncarnationID{1}, RecoveryState: domain.RecoveryDegraded,122+ Name: "broken", IncarnationID: domain.IncarnationID{1}, Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}},
123- Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}},
124 DegradedReason: "checkpoint unreadable",123 DegradedReason: "checkpoint unreadable",
125 }124 }
126 }125 }
@@ -157,7 +156,6 @@ func TestDiscardIsRetryIdempotent(t *testing.T) {
157 require.NoError(t, err)156 require.NoError(t, err)
158 require.True(t, ok)157 require.True(t, ok)
159 require.Nil(t, got.Committed)158 require.Nil(t, got.Committed)
160- require.Equal(t, domain.RecoveryFresh, got.RecoveryState)
161 require.NotEqual(t, old.IncarnationID, got.IncarnationID)159 require.NotEqual(t, old.IncarnationID, got.IncarnationID)
162 if tt.failBefore {160 if tt.failBefore {
163 require.Contains(t, repository.deletedIDs, old.IncarnationID)161 require.Contains(t, repository.deletedIDs, old.IncarnationID)

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