detached 792ed4c

feat(snapshot): restore safely

commit
792ed4c22ec2a1df168a5deb6f7dc361248b3acd
author
brice · · 2026-07-21 21:50 UTC
parent
0abd08b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +207 -0
A internal/usecase/daemon/snapshot_legacy_import.go +51 -0
@@ -0,0 +1,51 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
9+)
10+
11+func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
12+ manifest := snapcodec.Manifest{Generation: 1, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
13+ objects := make([]ports.SnapshotObject, 0)
14+ for _, tab := range snapshot.Tabs {
15+ outTab := snapcodec.ManifestTab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.Panes))}
16+ for _, pane := range tab.Panes {
17+ outPane := snapcodec.ManifestPane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}
18+ for _, payload := range pane.SealedChunks {
19+ object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, payload)
20+ if err != nil {
21+ return ports.SnapshotPublication{}, err
22+ }
23+ outPane.Sealed = append(outPane.Sealed, objectRef(snapcodec.HistoryChunk, object))
24+ objects = append(objects, object)
25+ }
26+ tail, err := snapcodec.MarshalObject(snapcodec.HistoryTail, pane.Tail)
27+ if err != nil {
28+ return ports.SnapshotPublication{}, err
29+ }
30+ visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible)
31+ if err != nil {
32+ return ports.SnapshotPublication{}, err
33+ }
34+ outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible)
35+ objects = append(objects, tail, visible)
36+ outTab.Panes = append(outTab.Panes, outPane)
37+ }
38+ manifest.Tabs = append(manifest.Tabs, outTab)
39+ }
40+ encoded, err := snapcodec.MarshalManifest(manifest)
41+ if err != nil {
42+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot: marshal legacy import manifest: %w", err)
43+ }
44+ return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil
45+}
46+
47+func sameSnapshotSession(left, right snapcodec.Session) bool {
48+ leftBytes, leftErr := snapcodec.Marshal(left)
49+ rightBytes, rightErr := snapcodec.Marshal(right)
50+ return leftErr == nil && rightErr == nil && bytes.Equal(leftBytes, rightBytes)
51+}
@@ -0,0 +1,51 @@
1+package daemon
2+
3+import (
4+ "bytes"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
9+)
10+
11+func legacyPublication(snapshot snapcodec.Session) (ports.SnapshotPublication, error) {
12+ manifest := snapcodec.Manifest{Generation: 1, Name: snapshot.Name, CreatedAt: snapshot.CreatedAt, Active: snapshot.Active, Tabs: make([]snapcodec.ManifestTab, 0, len(snapshot.Tabs))}
13+ objects := make([]ports.SnapshotObject, 0)
14+ for _, tab := range snapshot.Tabs {
15+ outTab := snapcodec.ManifestTab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.ManifestPane, 0, len(tab.Panes))}
16+ for _, pane := range tab.Panes {
17+ outPane := snapcodec.ManifestPane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}
18+ for _, payload := range pane.SealedChunks {
19+ object, err := snapcodec.MarshalObject(snapcodec.HistoryChunk, payload)
20+ if err != nil {
21+ return ports.SnapshotPublication{}, err
22+ }
23+ outPane.Sealed = append(outPane.Sealed, objectRef(snapcodec.HistoryChunk, object))
24+ objects = append(objects, object)
25+ }
26+ tail, err := snapcodec.MarshalObject(snapcodec.HistoryTail, pane.Tail)
27+ if err != nil {
28+ return ports.SnapshotPublication{}, err
29+ }
30+ visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible)
31+ if err != nil {
32+ return ports.SnapshotPublication{}, err
33+ }
34+ outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible)
35+ objects = append(objects, tail, visible)
36+ outTab.Panes = append(outTab.Panes, outPane)
37+ }
38+ manifest.Tabs = append(manifest.Tabs, outTab)
39+ }
40+ encoded, err := snapcodec.MarshalManifest(manifest)
41+ if err != nil {
42+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot: marshal legacy import manifest: %w", err)
43+ }
44+ return ports.SnapshotPublication{Name: snapshot.Name, Generation: 1, Manifest: encoded, Objects: objects}, nil
45+}
46+
47+func sameSnapshotSession(left, right snapcodec.Session) bool {
48+ leftBytes, leftErr := snapcodec.Marshal(left)
49+ rightBytes, rightErr := snapcodec.Marshal(right)
50+ return leftErr == nil && rightErr == nil && bytes.Equal(leftBytes, rightBytes)
51+}
A internal/usecase/daemon/snapshot_restore_incremental.go +156 -0
@@ -0,0 +1,156 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
10+)
11+
12+// restoreIncrementalSnapshots imports v3 only before listing repository
13+// sessions, then restores each independently. A corrupt session therefore
14+// cannot prevent an unrelated saved session from starting.
15+func (d *Daemon) restoreIncrementalSnapshots(ctx context.Context) {
16+ d.importLegacySnapshots(ctx)
17+ names, err := d.snapshotRepository.List(ctx)
18+ if err != nil {
19+ d.log.Warn("listing session snapshots failed", "err", err)
20+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't load saved sessions after restart", err)
21+ return
22+ }
23+ for _, name := range names {
24+ if err := ctx.Err(); err != nil {
25+ return
26+ }
27+ generation, err := d.snapshotRepository.Load(ctx, name)
28+ if err != nil {
29+ d.snapshotRestoreFailure(name, err)
30+ continue
31+ }
32+ if generation.Fallback {
33+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeSnapshotRestore, "restored an older saved checkpoint for session "+name, nil)
34+ }
35+ snapshot, err := sessionFromGeneration(generation)
36+ if err != nil {
37+ d.snapshotRestoreFailure(name, err)
38+ continue
39+ }
40+ if err := d.restoreSession(ctx, snapshot); err != nil {
41+ if ctx.Err() == nil {
42+ d.snapshotRestoreFailure(name, err)
43+ }
44+ }
45+ }
46+}
47+
48+func (d *Daemon) snapshotRestoreFailure(name string, err error) {
49+ d.log.Warn("restoring session snapshot failed", "err", err, "session", name)
50+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't restore session "+name+" after restart", err)
51+}
52+
53+func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) {
54+ manifest, err := snapcodec.UnmarshalManifest(generation.Manifest)
55+ if err != nil {
56+ return snapcodec.Session{}, err
57+ }
58+ if manifest.Name != generation.Name || manifest.Generation != generation.Generation {
59+ return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")
60+ }
61+ result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}
62+ for _, tab := range manifest.Tabs {
63+ outTab := snapcodec.Tab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.Pane, 0, len(tab.Panes))}
64+ for _, pane := range tab.Panes {
65+ outPane := snapcodec.Pane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}
66+ for _, ref := range pane.Sealed {
67+ data, err := generationObject(generation, ref, snapcodec.HistoryChunk)
68+ if err != nil {
69+ return snapcodec.Session{}, err
70+ }
71+ outPane.SealedChunks = append(outPane.SealedChunks, data)
72+ }
73+ if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil {
74+ return snapcodec.Session{}, err
75+ }
76+ if outPane.Visible, err = generationObject(generation, pane.Visible, snapcodec.Visible); err != nil {
77+ return snapcodec.Session{}, err
78+ }
79+ outTab.Panes = append(outTab.Panes, outPane)
80+ }
81+ result.Tabs = append(result.Tabs, outTab)
82+ }
83+ return result, nil
84+}
85+
86+func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectRef, kind snapcodec.ObjectKind) ([]byte, error) {
87+ if ref.Kind != kind {
88+ return nil, fmt.Errorf("snapshot: object kind mismatch")
89+ }
90+ data, ok := generation.Objects[ref.Digest]
91+ if !ok || uint32(len(data)) != ref.Size {
92+ return nil, fmt.Errorf("snapshot: missing object")
93+ }
94+ gotKind, payload, err := snapcodec.UnmarshalObject(data)
95+ if err != nil || gotKind != kind {
96+ return nil, fmt.Errorf("snapshot: invalid object")
97+ }
98+ return append([]byte(nil), payload...), nil
99+}
100+
101+// importLegacySnapshots is intentionally best effort per session. It deletes
102+// a legacy blob only after publication and a full repository reload verify the
103+// exact converted session, leaving failures retryable on the next startup.
104+func (d *Daemon) importLegacySnapshots(ctx context.Context) {
105+ if d.legacySnapshots == nil || d.snapshotRepository == nil {
106+ return
107+ }
108+ existing, err := d.snapshotRepository.List(ctx)
109+ if err != nil {
110+ d.log.Warn("listing incremental snapshots before import failed", "err", err)
111+ return
112+ }
113+ present := make(map[string]struct{}, len(existing))
114+ for _, name := range existing {
115+ present[name] = struct{}{}
116+ }
117+ legacy, err := d.legacySnapshots.LoadLegacy(ctx)
118+ if err != nil {
119+ d.log.Warn("loading legacy snapshots failed", "err", err)
120+ return
121+ }
122+ for _, blob := range legacy {
123+ if _, ok := present[blob.Name]; ok {
124+ continue
125+ }
126+ snapshot, err := snapcodec.Unmarshal(blob.Data)
127+ if err != nil || snapshot.Name != blob.Name {
128+ d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
129+ continue
130+ }
131+ publication, err := legacyPublication(snapshot)
132+ if err == nil {
133+ err = d.snapshotRepository.Publish(ctx, publication)
134+ }
135+ if err == nil {
136+ var generation ports.SnapshotGeneration
137+ generation, err = d.snapshotRepository.Load(ctx, snapshot.Name)
138+ if err == nil {
139+ var restored snapcodec.Session
140+ restored, err = sessionFromGeneration(generation)
141+ if err == nil && !sameSnapshotSession(snapshot, restored) {
142+ err = fmt.Errorf("snapshot: legacy import verification mismatch")
143+ }
144+ }
145+ }
146+ if err != nil {
147+ d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
148+ continue
149+ }
150+ if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil {
151+ d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
152+ continue
153+ }
154+ present[blob.Name] = struct{}{}
155+ }
156+}
@@ -0,0 +1,156 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
10+)
11+
12+// restoreIncrementalSnapshots imports v3 only before listing repository
13+// sessions, then restores each independently. A corrupt session therefore
14+// cannot prevent an unrelated saved session from starting.
15+func (d *Daemon) restoreIncrementalSnapshots(ctx context.Context) {
16+ d.importLegacySnapshots(ctx)
17+ names, err := d.snapshotRepository.List(ctx)
18+ if err != nil {
19+ d.log.Warn("listing session snapshots failed", "err", err)
20+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't load saved sessions after restart", err)
21+ return
22+ }
23+ for _, name := range names {
24+ if err := ctx.Err(); err != nil {
25+ return
26+ }
27+ generation, err := d.snapshotRepository.Load(ctx, name)
28+ if err != nil {
29+ d.snapshotRestoreFailure(name, err)
30+ continue
31+ }
32+ if generation.Fallback {
33+ d.NotifyGlobal(domain.NoticeWarn, domain.NoticeSnapshotRestore, "restored an older saved checkpoint for session "+name, nil)
34+ }
35+ snapshot, err := sessionFromGeneration(generation)
36+ if err != nil {
37+ d.snapshotRestoreFailure(name, err)
38+ continue
39+ }
40+ if err := d.restoreSession(ctx, snapshot); err != nil {
41+ if ctx.Err() == nil {
42+ d.snapshotRestoreFailure(name, err)
43+ }
44+ }
45+ }
46+}
47+
48+func (d *Daemon) snapshotRestoreFailure(name string, err error) {
49+ d.log.Warn("restoring session snapshot failed", "err", err, "session", name)
50+ d.NotifyGlobal(domain.NoticeError, domain.NoticeSnapshotRestore, "couldn't restore session "+name+" after restart", err)
51+}
52+
53+func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Session, error) {
54+ manifest, err := snapcodec.UnmarshalManifest(generation.Manifest)
55+ if err != nil {
56+ return snapcodec.Session{}, err
57+ }
58+ if manifest.Name != generation.Name || manifest.Generation != generation.Generation {
59+ return snapcodec.Session{}, fmt.Errorf("snapshot: generation identity mismatch")
60+ }
61+ result := snapcodec.Session{Name: manifest.Name, CreatedAt: manifest.CreatedAt, Active: manifest.Active, Tabs: make([]snapcodec.Tab, 0, len(manifest.Tabs))}
62+ for _, tab := range manifest.Tabs {
63+ outTab := snapcodec.Tab{StableID: tab.StableID, Cols: tab.Cols, Rows: tab.Rows, NextPaneID: tab.NextPaneID, Focus: tab.Focus, Tree: tab.Tree, Panes: make([]snapcodec.Pane, 0, len(tab.Panes))}
64+ for _, pane := range tab.Panes {
65+ outPane := snapcodec.Pane{ID: pane.ID, StableID: pane.StableID, Cwd: pane.Cwd, Process: pane.Process}
66+ for _, ref := range pane.Sealed {
67+ data, err := generationObject(generation, ref, snapcodec.HistoryChunk)
68+ if err != nil {
69+ return snapcodec.Session{}, err
70+ }
71+ outPane.SealedChunks = append(outPane.SealedChunks, data)
72+ }
73+ if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil {
74+ return snapcodec.Session{}, err
75+ }
76+ if outPane.Visible, err = generationObject(generation, pane.Visible, snapcodec.Visible); err != nil {
77+ return snapcodec.Session{}, err
78+ }
79+ outTab.Panes = append(outTab.Panes, outPane)
80+ }
81+ result.Tabs = append(result.Tabs, outTab)
82+ }
83+ return result, nil
84+}
85+
86+func generationObject(generation ports.SnapshotGeneration, ref snapcodec.ObjectRef, kind snapcodec.ObjectKind) ([]byte, error) {
87+ if ref.Kind != kind {
88+ return nil, fmt.Errorf("snapshot: object kind mismatch")
89+ }
90+ data, ok := generation.Objects[ref.Digest]
91+ if !ok || uint32(len(data)) != ref.Size {
92+ return nil, fmt.Errorf("snapshot: missing object")
93+ }
94+ gotKind, payload, err := snapcodec.UnmarshalObject(data)
95+ if err != nil || gotKind != kind {
96+ return nil, fmt.Errorf("snapshot: invalid object")
97+ }
98+ return append([]byte(nil), payload...), nil
99+}
100+
101+// importLegacySnapshots is intentionally best effort per session. It deletes
102+// a legacy blob only after publication and a full repository reload verify the
103+// exact converted session, leaving failures retryable on the next startup.
104+func (d *Daemon) importLegacySnapshots(ctx context.Context) {
105+ if d.legacySnapshots == nil || d.snapshotRepository == nil {
106+ return
107+ }
108+ existing, err := d.snapshotRepository.List(ctx)
109+ if err != nil {
110+ d.log.Warn("listing incremental snapshots before import failed", "err", err)
111+ return
112+ }
113+ present := make(map[string]struct{}, len(existing))
114+ for _, name := range existing {
115+ present[name] = struct{}{}
116+ }
117+ legacy, err := d.legacySnapshots.LoadLegacy(ctx)
118+ if err != nil {
119+ d.log.Warn("loading legacy snapshots failed", "err", err)
120+ return
121+ }
122+ for _, blob := range legacy {
123+ if _, ok := present[blob.Name]; ok {
124+ continue
125+ }
126+ snapshot, err := snapcodec.Unmarshal(blob.Data)
127+ if err != nil || snapshot.Name != blob.Name {
128+ d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
129+ continue
130+ }
131+ publication, err := legacyPublication(snapshot)
132+ if err == nil {
133+ err = d.snapshotRepository.Publish(ctx, publication)
134+ }
135+ if err == nil {
136+ var generation ports.SnapshotGeneration
137+ generation, err = d.snapshotRepository.Load(ctx, snapshot.Name)
138+ if err == nil {
139+ var restored snapcodec.Session
140+ restored, err = sessionFromGeneration(generation)
141+ if err == nil && !sameSnapshotSession(snapshot, restored) {
142+ err = fmt.Errorf("snapshot: legacy import verification mismatch")
143+ }
144+ }
145+ }
146+ if err != nil {
147+ d.log.Warn("importing legacy snapshot failed", "session", blob.Name, "err", err)
148+ continue
149+ }
150+ if err := d.legacySnapshots.DeleteLegacy(ctx, blob.Name); err != nil {
151+ d.log.Warn("deleting imported legacy snapshot failed", "session", blob.Name, "err", err)
152+ continue
153+ }
154+ present[blob.Name] = struct{}{}
155+ }
156+}

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