detached ff3aadc

refactor(daemon): route metadata through catalogue

commit
ff3aadc7bf087d3ca8232b5dc1410a911cdc142a
author
brice · · 2026-07-26 00:35 UTC
parent
7579215
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +204 -88
M internal/domain/recovery.go +22 -0
@@ -42,6 +42,28 @@ type CatalogueRecord struct {
4242 DegradedReason string
4343 }
4444
45+// CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue
46+// incarnation without carrying authority-owned recovery or checkpoint fields.
47+type CatalogueMetadataUpdate struct {
48+ Name string
49+ IncarnationID IncarnationID
50+ Cwd *string
51+ UpdatedAt *int64
52+ LastUsedSeq *uint64
53+ TabNames *[]string
54+}
55+
56+// MetadataUpdate returns a complete mutable-metadata update for r. Catalogue
57+// implementations retain all recovery and checkpoint fields they own.
58+func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate {
59+ cwd, updatedAt, lastUsedSeq := r.Cwd, r.UpdatedAt, r.LastUsedSeq
60+ tabNames := append([]string(nil), r.TabNames...)
61+ return CatalogueMetadataUpdate{
62+ Name: r.Name, IncarnationID: r.IncarnationID,
63+ Cwd: &cwd, UpdatedAt: &updatedAt, LastUsedSeq: &lastUsedSeq, TabNames: &tabNames,
64+ }
65+}
66+
4567 // DiscardIntent records a requested replacement of uncertain persisted state.
4668 type DiscardIntent struct {
4769 OldRecord CatalogueRecord
@@ -42,6 +42,28 @@ type CatalogueRecord struct {
42 DegradedReason string42 DegradedReason string
43 }43 }
44 44
45+// CatalogueMetadataUpdate changes mutable runtime metadata for one catalogue
46+// incarnation without carrying authority-owned recovery or checkpoint fields.
47+type CatalogueMetadataUpdate struct {
48+ Name string
49+ IncarnationID IncarnationID
50+ Cwd *string
51+ UpdatedAt *int64
52+ LastUsedSeq *uint64
53+ TabNames *[]string
54+}
55+
56+// MetadataUpdate returns a complete mutable-metadata update for r. Catalogue
57+// implementations retain all recovery and checkpoint fields they own.
58+func (r CatalogueRecord) MetadataUpdate() CatalogueMetadataUpdate {
59+ cwd, updatedAt, lastUsedSeq := r.Cwd, r.UpdatedAt, r.LastUsedSeq
60+ tabNames := append([]string(nil), r.TabNames...)
61+ return CatalogueMetadataUpdate{
62+ Name: r.Name, IncarnationID: r.IncarnationID,
63+ Cwd: &cwd, UpdatedAt: &updatedAt, LastUsedSeq: &lastUsedSeq, TabNames: &tabNames,
64+ }
65+}
66+
45 // DiscardIntent records a requested replacement of uncertain persisted state.67 // DiscardIntent records a requested replacement of uncertain persisted state.
46 type DiscardIntent struct {68 type DiscardIntent struct {
47 OldRecord CatalogueRecord69 OldRecord CatalogueRecord
M internal/persist/catalogue_test.go +12 -2
@@ -105,14 +105,24 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
105105 }
106106 require.NoError(t, p.Create(original))
107107
108- update := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}
108+ next := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}
109+ update := next.MetadataUpdate()
109110 require.NoError(t, p.UpdateMetadata(update))
110111 got, ok := p.Record("work")
111112 require.True(t, ok)
112113 expected := original
113- expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = update.Cwd, update.UpdatedAt, update.LastUsedSeq, update.TabNames
114+ expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = next.Cwd, next.UpdatedAt, next.LastUsedSeq, next.TabNames
114115 require.Equal(t, expected, got)
115116
117+ lastUsedSeq := uint64(24)
118+ require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
119+ Name: "work", IncarnationID: original.IncarnationID, LastUsedSeq: &lastUsedSeq,
120+ }))
121+ expected.LastUsedSeq = lastUsedSeq
122+ partiallyUpdated, ok := p.Record("work")
123+ require.True(t, ok)
124+ require.Equal(t, expected, partiallyUpdated, "a partial metadata update must not clear other mutable or authority-owned fields")
125+
116126 stale := update
117127 stale.IncarnationID = domain.IncarnationID{2}
118128 require.Error(t, p.UpdateMetadata(stale))
@@ -105,14 +105,24 @@ func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
105 }105 }
106 require.NoError(t, p.Create(original))106 require.NoError(t, p.Create(original))
107 107
108- update := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}108+ next := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}
109+ update := next.MetadataUpdate()
109 require.NoError(t, p.UpdateMetadata(update))110 require.NoError(t, p.UpdateMetadata(update))
110 got, ok := p.Record("work")111 got, ok := p.Record("work")
111 require.True(t, ok)112 require.True(t, ok)
112 expected := original113 expected := original
113- expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = update.Cwd, update.UpdatedAt, update.LastUsedSeq, update.TabNames114+ expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = next.Cwd, next.UpdatedAt, next.LastUsedSeq, next.TabNames
114 require.Equal(t, expected, got)115 require.Equal(t, expected, got)
115 116
117+ lastUsedSeq := uint64(24)
118+ require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
119+ Name: "work", IncarnationID: original.IncarnationID, LastUsedSeq: &lastUsedSeq,
120+ }))
121+ expected.LastUsedSeq = lastUsedSeq
122+ partiallyUpdated, ok := p.Record("work")
123+ require.True(t, ok)
124+ require.Equal(t, expected, partiallyUpdated, "a partial metadata update must not clear other mutable or authority-owned fields")
125+
116 stale := update126 stale := update
117 stale.IncarnationID = domain.IncarnationID{2}127 stale.IncarnationID = domain.IncarnationID{2}
118 require.Error(t, p.UpdateMetadata(stale))128 require.Error(t, p.UpdateMetadata(stale))
M internal/persist/persist.go +14 -55
@@ -208,79 +208,38 @@ func (p *Persister) Replace(name string, next domain.CatalogueRecord) error {
208208 // UpdateMetadata atomically updates ordinary session metadata on the existing
209209 // authoritative incarnation. Recovery and transaction fields are retained from
210210 // the stored record rather than accepted from a potentially stale runtime copy.
211-func (p *Persister) UpdateMetadata(next domain.CatalogueRecord) error {
211+func (p *Persister) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
212212 if p == nil || p.store == nil {
213213 return nil
214214 }
215215 p.mu.Lock()
216216 defer p.mu.Unlock()
217- value, ok := p.store.Get([]byte(next.Name))
217+ value, ok := p.store.Get([]byte(update.Name))
218218 if !ok {
219219 return errors.New("persist: session not found")
220220 }
221- current, err := decodeRecordValue(next.Name, value)
221+ current, err := decodeRecordValue(update.Name, value)
222222 if err != nil {
223223 return err
224224 }
225- if next.IncarnationID == (domain.IncarnationID{}) || current.IncarnationID != next.IncarnationID {
225+ if update.IncarnationID == (domain.IncarnationID{}) || current.IncarnationID != update.IncarnationID {
226226 return errors.New("persist: session incarnation changed")
227227 }
228- current.Cwd = next.Cwd
229- current.UpdatedAt = next.UpdatedAt
230- current.LastUsedSeq = next.LastUsedSeq
231- current.TabNames = append([]string(nil), next.TabNames...)
232- return p.applyLocked(map[string]*domain.CatalogueRecord{current.Name: &current})
233-}
234-
235-// Touch updates metadata while retaining the durable identity and recovery state.
236-func (p *Persister) Touch(name string, incarnation domain.IncarnationID, cwd string, at int64) error {
237- if p == nil || p.store == nil {
238- return nil
239- }
240- p.mu.Lock()
241- defer p.mu.Unlock()
242- value, ok := p.store.Get([]byte(name))
243- if !ok {
244- return errors.New("persist: session not found")
245- }
246- record, err := decodeRecordValue(name, value)
247- if err != nil {
248- return err
249- }
250- if incarnation == (domain.IncarnationID{}) || record.IncarnationID != incarnation {
251- return errors.New("persist: session incarnation changed")
252- }
253- record.Cwd, record.UpdatedAt = cwd, at
254- encoded, err := encodeRecordValue(record)
255- if err != nil {
256- return err
228+ if update.Cwd != nil {
229+ current.Cwd = *update.Cwd
257230 }
258- return p.store.Set([]byte(name), encoded)
259-}
260-func (p *Persister) TouchMRU(name string, incarnation domain.IncarnationID, sequence uint64) error {
261- if p == nil || p.store == nil {
262- return nil
231+ if update.UpdatedAt != nil {
232+ current.UpdatedAt = *update.UpdatedAt
263233 }
264- p.mu.Lock()
265- defer p.mu.Unlock()
266- value, ok := p.store.Get([]byte(name))
267- if !ok {
268- return errors.New("persist: session not found")
234+ if update.LastUsedSeq != nil {
235+ current.LastUsedSeq = *update.LastUsedSeq
269236 }
270- record, err := decodeRecordValue(name, value)
271- if err != nil {
272- return err
237+ if update.TabNames != nil {
238+ current.TabNames = append([]string(nil), (*update.TabNames)...)
273239 }
274- if incarnation == (domain.IncarnationID{}) || record.IncarnationID != incarnation {
275- return errors.New("persist: session incarnation changed")
276- }
277- record.LastUsedSeq = sequence
278- encoded, err := encodeRecordValue(record)
279- if err != nil {
280- return err
281- }
282- return p.store.Set([]byte(name), encoded)
240+ return p.applyLocked(map[string]*domain.CatalogueRecord{current.Name: &current})
283241 }
242+
284243 func (p *Persister) Delete(name string) error {
285244 return p.Apply(map[string]*domain.CatalogueRecord{name: nil})
286245 }
@@ -208,79 +208,38 @@ func (p *Persister) Replace(name string, next domain.CatalogueRecord) error {
208 // UpdateMetadata atomically updates ordinary session metadata on the existing208 // UpdateMetadata atomically updates ordinary session metadata on the existing
209 // authoritative incarnation. Recovery and transaction fields are retained from209 // authoritative incarnation. Recovery and transaction fields are retained from
210 // the stored record rather than accepted from a potentially stale runtime copy.210 // the stored record rather than accepted from a potentially stale runtime copy.
211-func (p *Persister) UpdateMetadata(next domain.CatalogueRecord) error {211+func (p *Persister) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
212 if p == nil || p.store == nil {212 if p == nil || p.store == nil {
213 return nil213 return nil
214 }214 }
215 p.mu.Lock()215 p.mu.Lock()
216 defer p.mu.Unlock()216 defer p.mu.Unlock()
217- value, ok := p.store.Get([]byte(next.Name))217+ value, ok := p.store.Get([]byte(update.Name))
218 if !ok {218 if !ok {
219 return errors.New("persist: session not found")219 return errors.New("persist: session not found")
220 }220 }
221- current, err := decodeRecordValue(next.Name, value)221+ current, err := decodeRecordValue(update.Name, value)
222 if err != nil {222 if err != nil {
223 return err223 return err
224 }224 }
225- if next.IncarnationID == (domain.IncarnationID{}) || current.IncarnationID != next.IncarnationID {225+ if update.IncarnationID == (domain.IncarnationID{}) || current.IncarnationID != update.IncarnationID {
226 return errors.New("persist: session incarnation changed")226 return errors.New("persist: session incarnation changed")
227 }227 }
228- current.Cwd = next.Cwd228+ if update.Cwd != nil {
229- current.UpdatedAt = next.UpdatedAt229+ current.Cwd = *update.Cwd
230- current.LastUsedSeq = next.LastUsedSeq
231- current.TabNames = append([]string(nil), next.TabNames...)
232- return p.applyLocked(map[string]*domain.CatalogueRecord{current.Name: &current})
233-}
234-
235-// Touch updates metadata while retaining the durable identity and recovery state.
236-func (p *Persister) Touch(name string, incarnation domain.IncarnationID, cwd string, at int64) error {
237- if p == nil || p.store == nil {
238- return nil
239- }
240- p.mu.Lock()
241- defer p.mu.Unlock()
242- value, ok := p.store.Get([]byte(name))
243- if !ok {
244- return errors.New("persist: session not found")
245- }
246- record, err := decodeRecordValue(name, value)
247- if err != nil {
248- return err
249- }
250- if incarnation == (domain.IncarnationID{}) || record.IncarnationID != incarnation {
251- return errors.New("persist: session incarnation changed")
252- }
253- record.Cwd, record.UpdatedAt = cwd, at
254- encoded, err := encodeRecordValue(record)
255- if err != nil {
256- return err
257 }230 }
258- return p.store.Set([]byte(name), encoded)231+ if update.UpdatedAt != nil {
259-}232+ current.UpdatedAt = *update.UpdatedAt
260-func (p *Persister) TouchMRU(name string, incarnation domain.IncarnationID, sequence uint64) error {
261- if p == nil || p.store == nil {
262- return nil
263 }233 }
264- p.mu.Lock()234+ if update.LastUsedSeq != nil {
265- defer p.mu.Unlock()235+ current.LastUsedSeq = *update.LastUsedSeq
266- value, ok := p.store.Get([]byte(name))
267- if !ok {
268- return errors.New("persist: session not found")
269 }236 }
270- record, err := decodeRecordValue(name, value)237+ if update.TabNames != nil {
271- if err != nil {238+ current.TabNames = append([]string(nil), (*update.TabNames)...)
272- return err
273 }239 }
274- if incarnation == (domain.IncarnationID{}) || record.IncarnationID != incarnation {240+ return p.applyLocked(map[string]*domain.CatalogueRecord{current.Name: &current})
275- return errors.New("persist: session incarnation changed")
276- }
277- record.LastUsedSeq = sequence
278- encoded, err := encodeRecordValue(record)
279- if err != nil {
280- return err
281- }
282- return p.store.Set([]byte(name), encoded)
283 }241 }
242+
284 func (p *Persister) Delete(name string) error {243 func (p *Persister) Delete(name string) error {
285 return p.Apply(map[string]*domain.CatalogueRecord{name: nil})244 return p.Apply(map[string]*domain.CatalogueRecord{name: nil})
286 }245 }
M internal/persist/persist_test.go +1 -2
@@ -69,8 +69,7 @@ func TestCatalogueNilStoreIsNoOp(t *testing.T) {
6969 for _, p := range persisters {
7070 require.NoError(t, p.Save(validRecord("one", 1)))
7171 require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}))
72- require.NoError(t, p.Touch("one", domain.IncarnationID{1}, "/tmp", 1))
73- require.NoError(t, p.TouchMRU("one", domain.IncarnationID{1}, 1))
72+ require.NoError(t, p.UpdateMetadata(validRecord("one", 1).MetadataUpdate()))
7473 require.NoError(t, p.Delete("one"))
7574 records, err := p.LoadAll()
7675 require.NoError(t, err)
@@ -69,8 +69,7 @@ func TestCatalogueNilStoreIsNoOp(t *testing.T) {
69 for _, p := range persisters {69 for _, p := range persisters {
70 require.NoError(t, p.Save(validRecord("one", 1)))70 require.NoError(t, p.Save(validRecord("one", 1)))
71 require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}))71 require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": nil}))
72- require.NoError(t, p.Touch("one", domain.IncarnationID{1}, "/tmp", 1))72+ require.NoError(t, p.UpdateMetadata(validRecord("one", 1).MetadataUpdate()))
73- require.NoError(t, p.TouchMRU("one", domain.IncarnationID{1}, 1))
74 require.NoError(t, p.Delete("one"))73 require.NoError(t, p.Delete("one"))
75 records, err := p.LoadAll()74 records, err := p.LoadAll()
76 require.NoError(t, err)75 require.NoError(t, err)
M internal/ports/mocks/recovery_mock.go +51 -0
@@ -998,6 +998,57 @@ func (_c *MockCatalogue_Replace_Call) RunAndReturn(run func(s string, catalogueR
998998 return _c
999999 }
10001000
1001+// UpdateMetadata provides a mock function for the type MockCatalogue
1002+func (_mock *MockCatalogue) UpdateMetadata(catalogueMetadataUpdate domain.CatalogueMetadataUpdate) error {
1003+ ret := _mock.Called(catalogueMetadataUpdate)
1004+
1005+ if len(ret) == 0 {
1006+ panic("no return value specified for UpdateMetadata")
1007+ }
1008+
1009+ var r0 error
1010+ if returnFunc, ok := ret.Get(0).(func(domain.CatalogueMetadataUpdate) error); ok {
1011+ r0 = returnFunc(catalogueMetadataUpdate)
1012+ } else {
1013+ r0 = ret.Error(0)
1014+ }
1015+ return r0
1016+}
1017+
1018+// MockCatalogue_UpdateMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateMetadata'
1019+type MockCatalogue_UpdateMetadata_Call struct {
1020+ *mock.Call
1021+}
1022+
1023+// UpdateMetadata is a helper method to define mock.On call
1024+// - catalogueMetadataUpdate domain.CatalogueMetadataUpdate
1025+func (_e *MockCatalogue_Expecter) UpdateMetadata(catalogueMetadataUpdate any) *MockCatalogue_UpdateMetadata_Call {
1026+ return &MockCatalogue_UpdateMetadata_Call{Call: _e.mock.On("UpdateMetadata", catalogueMetadataUpdate)}
1027+}
1028+
1029+func (_c *MockCatalogue_UpdateMetadata_Call) Run(run func(catalogueMetadataUpdate domain.CatalogueMetadataUpdate)) *MockCatalogue_UpdateMetadata_Call {
1030+ _c.Call.Run(func(args mock.Arguments) {
1031+ var arg0 domain.CatalogueMetadataUpdate
1032+ if args[0] != nil {
1033+ arg0 = args[0].(domain.CatalogueMetadataUpdate)
1034+ }
1035+ run(
1036+ arg0,
1037+ )
1038+ })
1039+ return _c
1040+}
1041+
1042+func (_c *MockCatalogue_UpdateMetadata_Call) Return(err error) *MockCatalogue_UpdateMetadata_Call {
1043+ _c.Call.Return(err)
1044+ return _c
1045+}
1046+
1047+func (_c *MockCatalogue_UpdateMetadata_Call) RunAndReturn(run func(catalogueMetadataUpdate domain.CatalogueMetadataUpdate) error) *MockCatalogue_UpdateMetadata_Call {
1048+ _c.Call.Return(run)
1049+ return _c
1050+}
1051+
10011052 // NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10021053 // The first argument is typically a *testing.T value.
10031054 func NewMockSnapshotRepository(t interface {
@@ -998,6 +998,57 @@ func (_c *MockCatalogue_Replace_Call) RunAndReturn(run func(s string, catalogueR
998 return _c998 return _c
999 }999 }
1000 1000
1001+// UpdateMetadata provides a mock function for the type MockCatalogue
1002+func (_mock *MockCatalogue) UpdateMetadata(catalogueMetadataUpdate domain.CatalogueMetadataUpdate) error {
1003+ ret := _mock.Called(catalogueMetadataUpdate)
1004+
1005+ if len(ret) == 0 {
1006+ panic("no return value specified for UpdateMetadata")
1007+ }
1008+
1009+ var r0 error
1010+ if returnFunc, ok := ret.Get(0).(func(domain.CatalogueMetadataUpdate) error); ok {
1011+ r0 = returnFunc(catalogueMetadataUpdate)
1012+ } else {
1013+ r0 = ret.Error(0)
1014+ }
1015+ return r0
1016+}
1017+
1018+// MockCatalogue_UpdateMetadata_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateMetadata'
1019+type MockCatalogue_UpdateMetadata_Call struct {
1020+ *mock.Call
1021+}
1022+
1023+// UpdateMetadata is a helper method to define mock.On call
1024+// - catalogueMetadataUpdate domain.CatalogueMetadataUpdate
1025+func (_e *MockCatalogue_Expecter) UpdateMetadata(catalogueMetadataUpdate any) *MockCatalogue_UpdateMetadata_Call {
1026+ return &MockCatalogue_UpdateMetadata_Call{Call: _e.mock.On("UpdateMetadata", catalogueMetadataUpdate)}
1027+}
1028+
1029+func (_c *MockCatalogue_UpdateMetadata_Call) Run(run func(catalogueMetadataUpdate domain.CatalogueMetadataUpdate)) *MockCatalogue_UpdateMetadata_Call {
1030+ _c.Call.Run(func(args mock.Arguments) {
1031+ var arg0 domain.CatalogueMetadataUpdate
1032+ if args[0] != nil {
1033+ arg0 = args[0].(domain.CatalogueMetadataUpdate)
1034+ }
1035+ run(
1036+ arg0,
1037+ )
1038+ })
1039+ return _c
1040+}
1041+
1042+func (_c *MockCatalogue_UpdateMetadata_Call) Return(err error) *MockCatalogue_UpdateMetadata_Call {
1043+ _c.Call.Return(err)
1044+ return _c
1045+}
1046+
1047+func (_c *MockCatalogue_UpdateMetadata_Call) RunAndReturn(run func(catalogueMetadataUpdate domain.CatalogueMetadataUpdate) error) *MockCatalogue_UpdateMetadata_Call {
1048+ _c.Call.Return(run)
1049+ return _c
1050+}
1051+
1001 // NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1052 // NewMockSnapshotRepository creates a new instance of MockSnapshotRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1002 // The first argument is typically a *testing.T value.1053 // The first argument is typically a *testing.T value.
1003 func NewMockSnapshotRepository(t interface {1054 func NewMockSnapshotRepository(t interface {
M internal/ports/recovery.go +1 -0
@@ -138,6 +138,7 @@ type Catalogue interface {
138138 Records() []domain.CatalogueRecord
139139 Record(string) (domain.CatalogueRecord, bool)
140140 Create(domain.CatalogueRecord) error
141+ UpdateMetadata(domain.CatalogueMetadataUpdate) error
141142 Replace(string, domain.CatalogueRecord) error
142143 Rename(string, domain.CatalogueRecord) error
143144 Delete(string) error
@@ -138,6 +138,7 @@ type Catalogue interface {
138 Records() []domain.CatalogueRecord138 Records() []domain.CatalogueRecord
139 Record(string) (domain.CatalogueRecord, bool)139 Record(string) (domain.CatalogueRecord, bool)
140 Create(domain.CatalogueRecord) error140 Create(domain.CatalogueRecord) error
141+ UpdateMetadata(domain.CatalogueMetadataUpdate) error
141 Replace(string, domain.CatalogueRecord) error142 Replace(string, domain.CatalogueRecord) error
142 Rename(string, domain.CatalogueRecord) error143 Rename(string, domain.CatalogueRecord) error
143 Delete(string) error144 Delete(string) error
A internal/usecase/daemon/catalogue.go +36 -0
@@ -0,0 +1,36 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+)
6+
7+// Catalogue helpers keep persistence optional for ephemeral and focused unit
8+// tests while ensuring every durable named-session mutation uses the authority
9+// port when one is configured.
10+func (d *Daemon) catalogueRecord(name string) (domain.CatalogueRecord, bool) {
11+ if d == nil || d.catalogue == nil {
12+ return domain.CatalogueRecord{}, false
13+ }
14+ return d.catalogue.Record(name)
15+}
16+
17+func (d *Daemon) createCatalogueRecord(record domain.CatalogueRecord) error {
18+ if d == nil || d.catalogue == nil {
19+ return nil
20+ }
21+ return d.catalogue.Create(record)
22+}
23+
24+func (d *Daemon) updateCatalogueMetadata(update domain.CatalogueMetadataUpdate) error {
25+ if d == nil || d.catalogue == nil {
26+ return nil
27+ }
28+ return d.catalogue.UpdateMetadata(update)
29+}
30+
31+func (d *Daemon) deleteCatalogueRecord(name string) error {
32+ if d == nil || d.catalogue == nil {
33+ return nil
34+ }
35+ return d.catalogue.Delete(name)
36+}
@@ -0,0 +1,36 @@
1+package daemon
2+
3+import (
4+ "github.com/bnema/vev/internal/domain"
5+)
6+
7+// Catalogue helpers keep persistence optional for ephemeral and focused unit
8+// tests while ensuring every durable named-session mutation uses the authority
9+// port when one is configured.
10+func (d *Daemon) catalogueRecord(name string) (domain.CatalogueRecord, bool) {
11+ if d == nil || d.catalogue == nil {
12+ return domain.CatalogueRecord{}, false
13+ }
14+ return d.catalogue.Record(name)
15+}
16+
17+func (d *Daemon) createCatalogueRecord(record domain.CatalogueRecord) error {
18+ if d == nil || d.catalogue == nil {
19+ return nil
20+ }
21+ return d.catalogue.Create(record)
22+}
23+
24+func (d *Daemon) updateCatalogueMetadata(update domain.CatalogueMetadataUpdate) error {
25+ if d == nil || d.catalogue == nil {
26+ return nil
27+ }
28+ return d.catalogue.UpdateMetadata(update)
29+}
30+
31+func (d *Daemon) deleteCatalogueRecord(name string) error {
32+ if d == nil || d.catalogue == nil {
33+ return nil
34+ }
35+ return d.catalogue.Delete(name)
36+}
M internal/usecase/daemon/daemon.go +1 -3
@@ -256,6 +256,7 @@ func WithShell(cmd string, args []string) Option {
256256 func WithStore(store ports.Store) Option {
257257 return func(d *Daemon) {
258258 d.persist = persist.New(store)
259+ d.catalogue = d.persist
259260 d.persistEnabled = store != nil
260261 }
261262 }
@@ -265,9 +266,6 @@ func WithStore(store ports.Store) Option {
265266 func WithCatalogue(catalogue ports.Catalogue, records []domain.CatalogueRecord) Option {
266267 return func(d *Daemon) {
267268 d.catalogue = catalogue
268- if persister, ok := catalogue.(*persist.Persister); ok {
269- d.persist = persister
270- }
271269 d.persistEnabled = catalogue != nil
272270 d.catalogueRecords = append([]domain.CatalogueRecord(nil), records...)
273271 d.catalogueRecordsProvided = true
@@ -256,6 +256,7 @@ func WithShell(cmd string, args []string) Option {
256 func WithStore(store ports.Store) Option {256 func WithStore(store ports.Store) Option {
257 return func(d *Daemon) {257 return func(d *Daemon) {
258 d.persist = persist.New(store)258 d.persist = persist.New(store)
259+ d.catalogue = d.persist
259 d.persistEnabled = store != nil260 d.persistEnabled = store != nil
260 }261 }
261 }262 }
@@ -265,9 +266,6 @@ func WithStore(store ports.Store) Option {
265 func WithCatalogue(catalogue ports.Catalogue, records []domain.CatalogueRecord) Option {266 func WithCatalogue(catalogue ports.Catalogue, records []domain.CatalogueRecord) Option {
266 return func(d *Daemon) {267 return func(d *Daemon) {
267 d.catalogue = catalogue268 d.catalogue = catalogue
268- if persister, ok := catalogue.(*persist.Persister); ok {
269- d.persist = persister
270- }
271 d.persistEnabled = catalogue != nil269 d.persistEnabled = catalogue != nil
272 d.catalogueRecords = append([]domain.CatalogueRecord(nil), records...)270 d.catalogueRecords = append([]domain.CatalogueRecord(nil), records...)
273 d.catalogueRecordsProvided = true271 d.catalogueRecordsProvided = true
M internal/usecase/daemon/durable_recovery_test.go +36 -2
@@ -22,8 +22,9 @@ import (
2222
2323 type durableRecoveryCatalogue struct {
2424 ports.Catalogue
25- mu sync.Mutex
26- records map[string]domain.CatalogueRecord
25+ mu sync.Mutex
26+ records map[string]domain.CatalogueRecord
27+ metadataUpdates []domain.CatalogueMetadataUpdate
2728 }
2829
2930 func newDurableRecoveryCatalogue(records []domain.CatalogueRecord) *durableRecoveryCatalogue {
@@ -61,6 +62,39 @@ func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueR
6162 return nil
6263 }
6364
65+func (c *durableRecoveryCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
66+ c.mu.Lock()
67+ defer c.mu.Unlock()
68+ record, ok := c.records[update.Name]
69+ if !ok {
70+ return errors.New("catalogue record not found")
71+ }
72+ if update.IncarnationID == (domain.IncarnationID{}) || record.IncarnationID != update.IncarnationID {
73+ return errors.New("catalogue incarnation changed")
74+ }
75+ if update.Cwd != nil {
76+ record.Cwd = *update.Cwd
77+ }
78+ if update.UpdatedAt != nil {
79+ record.UpdatedAt = *update.UpdatedAt
80+ }
81+ if update.LastUsedSeq != nil {
82+ record.LastUsedSeq = *update.LastUsedSeq
83+ }
84+ if update.TabNames != nil {
85+ record.TabNames = append([]string(nil), (*update.TabNames)...)
86+ }
87+ c.records[update.Name] = record
88+ c.metadataUpdates = append(c.metadataUpdates, update)
89+ return nil
90+}
91+
92+func (c *durableRecoveryCatalogue) MetadataUpdates() []domain.CatalogueMetadataUpdate {
93+ c.mu.Lock()
94+ defer c.mu.Unlock()
95+ return append([]domain.CatalogueMetadataUpdate(nil), c.metadataUpdates...)
96+}
97+
6498 func (c *durableRecoveryCatalogue) Close() error { return nil }
6599
66100 type durableRecoveryRepository struct {
@@ -22,8 +22,9 @@ import (
22 22
23 type durableRecoveryCatalogue struct {23 type durableRecoveryCatalogue struct {
24 ports.Catalogue24 ports.Catalogue
25- mu sync.Mutex25+ mu sync.Mutex
26- records map[string]domain.CatalogueRecord26+ records map[string]domain.CatalogueRecord
27+ metadataUpdates []domain.CatalogueMetadataUpdate
27 }28 }
28 29
29 func newDurableRecoveryCatalogue(records []domain.CatalogueRecord) *durableRecoveryCatalogue {30 func newDurableRecoveryCatalogue(records []domain.CatalogueRecord) *durableRecoveryCatalogue {
@@ -61,6 +62,39 @@ func (c *durableRecoveryCatalogue) Replace(name string, record domain.CatalogueR
61 return nil62 return nil
62 }63 }
63 64
65+func (c *durableRecoveryCatalogue) UpdateMetadata(update domain.CatalogueMetadataUpdate) error {
66+ c.mu.Lock()
67+ defer c.mu.Unlock()
68+ record, ok := c.records[update.Name]
69+ if !ok {
70+ return errors.New("catalogue record not found")
71+ }
72+ if update.IncarnationID == (domain.IncarnationID{}) || record.IncarnationID != update.IncarnationID {
73+ return errors.New("catalogue incarnation changed")
74+ }
75+ if update.Cwd != nil {
76+ record.Cwd = *update.Cwd
77+ }
78+ if update.UpdatedAt != nil {
79+ record.UpdatedAt = *update.UpdatedAt
80+ }
81+ if update.LastUsedSeq != nil {
82+ record.LastUsedSeq = *update.LastUsedSeq
83+ }
84+ if update.TabNames != nil {
85+ record.TabNames = append([]string(nil), (*update.TabNames)...)
86+ }
87+ c.records[update.Name] = record
88+ c.metadataUpdates = append(c.metadataUpdates, update)
89+ return nil
90+}
91+
92+func (c *durableRecoveryCatalogue) MetadataUpdates() []domain.CatalogueMetadataUpdate {
93+ c.mu.Lock()
94+ defer c.mu.Unlock()
95+ return append([]domain.CatalogueMetadataUpdate(nil), c.metadataUpdates...)
96+}
97+
64 func (c *durableRecoveryCatalogue) Close() error { return nil }98 func (c *durableRecoveryCatalogue) Close() error { return nil }
65 99
66 type durableRecoveryRepository struct {100 type durableRecoveryRepository struct {
M internal/usecase/daemon/session.go +16 -15
@@ -186,8 +186,8 @@ func (d *Daemon) touchMRU(sess *session) {
186186 incarnation := sess.incarnation
187187 ephemeral := sess.ephemeral
188188 sess.mu.Unlock()
189- if !ephemeral && d.persist != nil {
190- if err := d.persist.TouchMRU(name, incarnation, seq); err != nil {
189+ if !ephemeral {
190+ if err := d.updateCatalogueMetadata(domain.CatalogueMetadataUpdate{Name: name, IncarnationID: incarnation, LastUsedSeq: &seq}); err != nil {
191191 d.log.Warn("touching persisted session recency failed", "err", err, "session", name)
192192 }
193193 }
@@ -204,7 +204,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
204204 // the persisted identity rather than receive a fresh timestamp.
205205 createdAt = stopped.createdAt
206206 incarnation = stopped.incarnation
207- if authoritative, ok := d.persist.Record(name); ok {
207+ if authoritative, ok := d.catalogueRecord(name); ok {
208208 incarnation = authoritative.IncarnationID
209209 createdAt = authoritative.CreatedAt
210210 }
@@ -290,13 +290,13 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
290290 record := persist.Record{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}
291291 var err error
292292 if resuming {
293- if _, ok := d.persist.Record(name); ok {
294- err = d.persist.UpdateMetadata(record)
293+ if _, ok := d.catalogueRecord(name); ok {
294+ err = d.updateCatalogueMetadata(record.MetadataUpdate())
295295 } else {
296- err = d.persist.Create(record)
296+ err = d.createCatalogueRecord(record)
297297 }
298298 } else {
299- err = d.persist.Create(record)
299+ err = d.createCatalogueRecord(record)
300300 }
301301 if err != nil {
302302 closeTabs(tabs)
@@ -444,7 +444,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
444444 record := sess.persistRecordLocked(time.Now().UnixNano())
445445 ephemeral := sess.ephemeral
446446 if !ephemeral {
447- if err := d.persist.UpdateMetadata(record); err != nil {
447+ if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
448448 sess.tabs = sess.tabs[:len(sess.tabs)-1]
449449 sess.active = oldActive
450450 if tb.cancel != nil {
@@ -714,7 +714,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
714714 // the old tombstone transaction. Ephemeral promotion has no old durable
715715 // identity and can create its first record directly.
716716 if (wasEphemeral || oldName != name) && d.persistEnabled {
717- if err := d.persist.Create(record); err != nil {
717+ if err := d.createCatalogueRecord(record); err != nil {
718718 return rollback(err)
719719 }
720720 }
@@ -774,7 +774,7 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
774774 record := sess.persistRecordLocked(time.Now().UnixNano())
775775 ephemeral := sess.ephemeral
776776 if !ephemeral {
777- if err := d.persist.UpdateMetadata(record); err != nil {
777+ if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
778778 tb.name = oldName
779779 sess.mu.Unlock()
780780 d.mu.Unlock()
@@ -853,7 +853,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
853853 record := sess.persistRecordLocked(time.Now().UnixNano())
854854 ephemeral := sess.ephemeral
855855 if !ephemeral {
856- if err := d.persist.UpdateMetadata(record); err != nil {
856+ if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
857857 d.log.Warn("persisting closed tab failed", "err", err, "session", name)
858858 }
859859 }
@@ -910,12 +910,12 @@ func (d *Daemon) beginSnapshotPurge(name string, incarnation domain.IncarnationI
910910 // a later live/offline kill can retry the idempotent source deletes.
911911 func (d *Daemon) finishSnapshotPurge(name string, incarnation domain.IncarnationID) error {
912912 if d.snapshotRepository == nil {
913- return d.persist.Delete(name)
913+ return d.deleteCatalogueRecord(name)
914914 }
915915 if err := d.snapshotRepository.QuarantineDeletionSources(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation}, false); err != nil {
916916 return err
917917 }
918- if err := d.persist.Delete(name); err != nil {
918+ if err := d.deleteCatalogueRecord(name); err != nil {
919919 return err
920920 }
921921 return d.snapshotRepository.DeleteDeletionTombstone(context.Background(), incarnation)
@@ -935,7 +935,7 @@ func (d *Daemon) retryStoppedPurge(name string) error {
935935 // stopped record retains the metadata-only deletion behavior.
936936 if d.snapshotRepository == nil {
937937 d.mu.Unlock()
938- if err := d.persist.Delete(name); err != nil {
938+ if err := d.deleteCatalogueRecord(name); err != nil {
939939 return err
940940 }
941941 d.mu.Lock()
@@ -1358,7 +1358,8 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
13581358 ephemeral := sess.ephemeral
13591359 sess.mu.Unlock()
13601360 if !ephemeral {
1361- if err := d.persist.Touch(name, incarnation, cwd, time.Now().UnixNano()); err != nil {
1361+ updatedAt := time.Now().UnixNano()
1362+ if err := d.updateCatalogueMetadata(domain.CatalogueMetadataUpdate{Name: name, IncarnationID: incarnation, Cwd: &cwd, UpdatedAt: &updatedAt}); err != nil {
13621363 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
13631364 }
13641365 markSnapshotDirty(sess)
@@ -186,8 +186,8 @@ func (d *Daemon) touchMRU(sess *session) {
186 incarnation := sess.incarnation186 incarnation := sess.incarnation
187 ephemeral := sess.ephemeral187 ephemeral := sess.ephemeral
188 sess.mu.Unlock()188 sess.mu.Unlock()
189- if !ephemeral && d.persist != nil {189+ if !ephemeral {
190- if err := d.persist.TouchMRU(name, incarnation, seq); err != nil {190+ if err := d.updateCatalogueMetadata(domain.CatalogueMetadataUpdate{Name: name, IncarnationID: incarnation, LastUsedSeq: &seq}); err != nil {
191 d.log.Warn("touching persisted session recency failed", "err", err, "session", name)191 d.log.Warn("touching persisted session recency failed", "err", err, "session", name)
192 }192 }
193 }193 }
@@ -204,7 +204,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
204 // the persisted identity rather than receive a fresh timestamp.204 // the persisted identity rather than receive a fresh timestamp.
205 createdAt = stopped.createdAt205 createdAt = stopped.createdAt
206 incarnation = stopped.incarnation206 incarnation = stopped.incarnation
207- if authoritative, ok := d.persist.Record(name); ok {207+ if authoritative, ok := d.catalogueRecord(name); ok {
208 incarnation = authoritative.IncarnationID208 incarnation = authoritative.IncarnationID
209 createdAt = authoritative.CreatedAt209 createdAt = authoritative.CreatedAt
210 }210 }
@@ -290,13 +290,13 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
290 record := persist.Record{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}290 record := persist.Record{Name: name, IncarnationID: incarnation, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names, RecoveryState: domain.RecoveryFresh}
291 var err error291 var err error
292 if resuming {292 if resuming {
293- if _, ok := d.persist.Record(name); ok {293+ if _, ok := d.catalogueRecord(name); ok {
294- err = d.persist.UpdateMetadata(record)294+ err = d.updateCatalogueMetadata(record.MetadataUpdate())
295 } else {295 } else {
296- err = d.persist.Create(record)296+ err = d.createCatalogueRecord(record)
297 }297 }
298 } else {298 } else {
299- err = d.persist.Create(record)299+ err = d.createCatalogueRecord(record)
300 }300 }
301 if err != nil {301 if err != nil {
302 closeTabs(tabs)302 closeTabs(tabs)
@@ -444,7 +444,7 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
444 record := sess.persistRecordLocked(time.Now().UnixNano())444 record := sess.persistRecordLocked(time.Now().UnixNano())
445 ephemeral := sess.ephemeral445 ephemeral := sess.ephemeral
446 if !ephemeral {446 if !ephemeral {
447- if err := d.persist.UpdateMetadata(record); err != nil {447+ if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
448 sess.tabs = sess.tabs[:len(sess.tabs)-1]448 sess.tabs = sess.tabs[:len(sess.tabs)-1]
449 sess.active = oldActive449 sess.active = oldActive
450 if tb.cancel != nil {450 if tb.cancel != nil {
@@ -714,7 +714,7 @@ func (d *Daemon) renameSession(sess *session, name string) error {
714 // the old tombstone transaction. Ephemeral promotion has no old durable714 // the old tombstone transaction. Ephemeral promotion has no old durable
715 // identity and can create its first record directly.715 // identity and can create its first record directly.
716 if (wasEphemeral || oldName != name) && d.persistEnabled {716 if (wasEphemeral || oldName != name) && d.persistEnabled {
717- if err := d.persist.Create(record); err != nil {717+ if err := d.createCatalogueRecord(record); err != nil {
718 return rollback(err)718 return rollback(err)
719 }719 }
720 }720 }
@@ -774,7 +774,7 @@ func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
774 record := sess.persistRecordLocked(time.Now().UnixNano())774 record := sess.persistRecordLocked(time.Now().UnixNano())
775 ephemeral := sess.ephemeral775 ephemeral := sess.ephemeral
776 if !ephemeral {776 if !ephemeral {
777- if err := d.persist.UpdateMetadata(record); err != nil {777+ if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
778 tb.name = oldName778 tb.name = oldName
779 sess.mu.Unlock()779 sess.mu.Unlock()
780 d.mu.Unlock()780 d.mu.Unlock()
@@ -853,7 +853,7 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) error {
853 record := sess.persistRecordLocked(time.Now().UnixNano())853 record := sess.persistRecordLocked(time.Now().UnixNano())
854 ephemeral := sess.ephemeral854 ephemeral := sess.ephemeral
855 if !ephemeral {855 if !ephemeral {
856- if err := d.persist.UpdateMetadata(record); err != nil {856+ if err := d.updateCatalogueMetadata(record.MetadataUpdate()); err != nil {
857 d.log.Warn("persisting closed tab failed", "err", err, "session", name)857 d.log.Warn("persisting closed tab failed", "err", err, "session", name)
858 }858 }
859 }859 }
@@ -910,12 +910,12 @@ func (d *Daemon) beginSnapshotPurge(name string, incarnation domain.IncarnationI
910 // a later live/offline kill can retry the idempotent source deletes.910 // a later live/offline kill can retry the idempotent source deletes.
911 func (d *Daemon) finishSnapshotPurge(name string, incarnation domain.IncarnationID) error {911 func (d *Daemon) finishSnapshotPurge(name string, incarnation domain.IncarnationID) error {
912 if d.snapshotRepository == nil {912 if d.snapshotRepository == nil {
913- return d.persist.Delete(name)913+ return d.deleteCatalogueRecord(name)
914 }914 }
915 if err := d.snapshotRepository.QuarantineDeletionSources(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation}, false); err != nil {915 if err := d.snapshotRepository.QuarantineDeletionSources(context.Background(), domain.DeletionTombstone{Name: name, IncarnationID: incarnation}, false); err != nil {
916 return err916 return err
917 }917 }
918- if err := d.persist.Delete(name); err != nil {918+ if err := d.deleteCatalogueRecord(name); err != nil {
919 return err919 return err
920 }920 }
921 return d.snapshotRepository.DeleteDeletionTombstone(context.Background(), incarnation)921 return d.snapshotRepository.DeleteDeletionTombstone(context.Background(), incarnation)
@@ -935,7 +935,7 @@ func (d *Daemon) retryStoppedPurge(name string) error {
935 // stopped record retains the metadata-only deletion behavior.935 // stopped record retains the metadata-only deletion behavior.
936 if d.snapshotRepository == nil {936 if d.snapshotRepository == nil {
937 d.mu.Unlock()937 d.mu.Unlock()
938- if err := d.persist.Delete(name); err != nil {938+ if err := d.deleteCatalogueRecord(name); err != nil {
939 return err939 return err
940 }940 }
941 d.mu.Lock()941 d.mu.Lock()
@@ -1358,7 +1358,8 @@ func (d *Daemon) refreshSessionCwd(sess *session) {
1358 ephemeral := sess.ephemeral1358 ephemeral := sess.ephemeral
1359 sess.mu.Unlock()1359 sess.mu.Unlock()
1360 if !ephemeral {1360 if !ephemeral {
1361- if err := d.persist.Touch(name, incarnation, cwd, time.Now().UnixNano()); err != nil {1361+ updatedAt := time.Now().UnixNano()
1362+ if err := d.updateCatalogueMetadata(domain.CatalogueMetadataUpdate{Name: name, IncarnationID: incarnation, Cwd: &cwd, UpdatedAt: &updatedAt}); err != nil {
1362 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)1363 d.log.Warn("touching persisted session cwd failed", "err", err, "session", name)
1363 }1364 }
1364 markSnapshotDirty(sess)1365 markSnapshotDirty(sess)
M internal/usecase/daemon/snapshot_restore.go +7 -5
@@ -268,11 +268,13 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
268268 }
269269 }
270270 }
271- // The catalogue checkpoint already authorizes this runtime. Rewriting it
272- // during restoration would discard committed/fallback recovery metadata.
273- if d.catalogue == nil && d.persistEnabled {
274- if err := d.persist.Create(sess.persistRecordLocked(sess.createdAt)); err != nil {
275- return false, err
271+ // An existing catalogue checkpoint already authorizes this runtime. Only a
272+ // legacy snapshot without a catalogue record creates fresh metadata here.
273+ if d.persistEnabled {
274+ if _, ok := d.catalogueRecord(sess.name); !ok {
275+ if err := d.createCatalogueRecord(sess.persistRecordLocked(sess.createdAt)); err != nil {
276+ return false, err
277+ }
276278 }
277279 }
278280 d.mu.Lock()
@@ -268,11 +268,13 @@ func (d *Daemon) persistAndRegisterRestoredSession(ctx context.Context, sess *se
268 }268 }
269 }269 }
270 }270 }
271- // The catalogue checkpoint already authorizes this runtime. Rewriting it271+ // An existing catalogue checkpoint already authorizes this runtime. Only a
272- // during restoration would discard committed/fallback recovery metadata.272+ // legacy snapshot without a catalogue record creates fresh metadata here.
273- if d.catalogue == nil && d.persistEnabled {273+ if d.persistEnabled {
274- if err := d.persist.Create(sess.persistRecordLocked(sess.createdAt)); err != nil {274+ if _, ok := d.catalogueRecord(sess.name); !ok {
275- return false, err275+ if err := d.createCatalogueRecord(sess.persistRecordLocked(sess.createdAt)); err != nil {
276+ return false, err
277+ }
276 }278 }
277 }279 }
278 d.mu.Lock()280 d.mu.Lock()
M internal/usecase/daemon/snapshot_restore_test.go +4 -4
@@ -10,7 +10,6 @@ import (
1010 "github.com/stretchr/testify/require"
1111
1212 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/persist"
1413 "github.com/bnema/vev/internal/ports"
1514 "github.com/bnema/vev/internal/usecase/layout"
1615 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
@@ -263,8 +262,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
263262 snapshot := restoreAcceptanceSession(t, "restored")
264263 generation := acceptanceGeneration(t, snapshot, 9)
265264 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
266- store, _ := newMockStore(t)
267- catalogue := persist.New(store)
268265 committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
269266 fallback1 := domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{8}}
270267 fallback2 := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
@@ -274,7 +271,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
274271 TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy,
275272 Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2},
276273 }
277- require.NoError(t, catalogue.Create(record))
274+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
278275
279276 pty, release := newBlockingPTY(t)
280277 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -291,6 +288,9 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
291288 require.NotNil(t, restored)
292289 require.NoError(t, d.renameTab(restored, restored.tabs[0], "after"))
293290
291+ updates := catalogue.MetadataUpdates()
292+ require.Len(t, updates, 1, "the authoritative Catalogue port must receive restored-session metadata updates")
293+ require.Equal(t, record.IncarnationID, updates[0].IncarnationID)
294294 updated, ok := catalogue.Record(record.Name)
295295 require.True(t, ok)
296296 require.Equal(t, record.IncarnationID, updated.IncarnationID)
@@ -10,7 +10,6 @@ import (
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13- "github.com/bnema/vev/internal/persist"
14 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
15 "github.com/bnema/vev/internal/usecase/layout"14 "github.com/bnema/vev/internal/usecase/layout"
16 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"15 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
@@ -263,8 +262,6 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
263 snapshot := restoreAcceptanceSession(t, "restored")262 snapshot := restoreAcceptanceSession(t, "restored")
264 generation := acceptanceGeneration(t, snapshot, 9)263 generation := acceptanceGeneration(t, snapshot, 9)
265 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}264 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
266- store, _ := newMockStore(t)
267- catalogue := persist.New(store)
268 committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}265 committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
269 fallback1 := domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{8}}266 fallback1 := domain.CheckpointRef{Generation: 8, ManifestDigest: [32]byte{8}}
270 fallback2 := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}267 fallback2 := domain.CheckpointRef{Generation: 7, ManifestDigest: [32]byte{7}}
@@ -274,7 +271,7 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
274 TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy,271 TabNames: []string{"before"}, RecoveryState: domain.RecoveryHealthy,
275 Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2},272 Committed: &committed, Fallbacks: [2]*domain.CheckpointRef{&fallback1, &fallback2},
276 }273 }
277- require.NoError(t, catalogue.Create(record))274+ catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
278 275
279 pty, release := newBlockingPTY(t)276 pty, release := newBlockingPTY(t)
280 d := newTestDaemon(t, newFactory(t, pty), stubClock{})277 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
@@ -291,6 +288,9 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
291 require.NotNil(t, restored)288 require.NotNil(t, restored)
292 require.NoError(t, d.renameTab(restored, restored.tabs[0], "after"))289 require.NoError(t, d.renameTab(restored, restored.tabs[0], "after"))
293 290
291+ updates := catalogue.MetadataUpdates()
292+ require.Len(t, updates, 1, "the authoritative Catalogue port must receive restored-session metadata updates")
293+ require.Equal(t, record.IncarnationID, updates[0].IncarnationID)
294 updated, ok := catalogue.Record(record.Name)294 updated, ok := catalogue.Record(record.Name)
295 require.True(t, ok)295 require.True(t, ok)
296 require.Equal(t, record.IncarnationID, updated.IncarnationID)296 require.Equal(t, record.IncarnationID, updated.IncarnationID)
M internal/usecase/recovery/checkpoint_test.go +3 -0
@@ -29,6 +29,9 @@ func (c *checkpointCatalogue) Record(name string) (domain.CatalogueRecord, bool)
2929 return c.record, c.record.Name == name
3030 }
3131 func (c *checkpointCatalogue) Create(domain.CatalogueRecord) error { return nil }
32+func (c *checkpointCatalogue) UpdateMetadata(domain.CatalogueMetadataUpdate) error {
33+ return nil
34+}
3235 func (c *checkpointCatalogue) Replace(name string, record domain.CatalogueRecord) error {
3336 c.mu.Lock()
3437 defer c.mu.Unlock()
@@ -29,6 +29,9 @@ func (c *checkpointCatalogue) Record(name string) (domain.CatalogueRecord, bool)
29 return c.record, c.record.Name == name29 return c.record, c.record.Name == name
30 }30 }
31 func (c *checkpointCatalogue) Create(domain.CatalogueRecord) error { return nil }31 func (c *checkpointCatalogue) Create(domain.CatalogueRecord) error { return nil }
32+func (c *checkpointCatalogue) UpdateMetadata(domain.CatalogueMetadataUpdate) error {
33+ return nil
34+}
32 func (c *checkpointCatalogue) Replace(name string, record domain.CatalogueRecord) error {35 func (c *checkpointCatalogue) Replace(name string, record domain.CatalogueRecord) error {
33 c.mu.Lock()36 c.mu.Lock()
34 defer c.mu.Unlock()37 defer c.mu.Unlock()

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