feat(vt): restore transcripts into terminal history
- commit
- 35ddca20efd73d13e141452a20f694911739f0a9
- author
- brice · · 2026-07-27 11:54 UTC
- parent
- e95e63a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +137 -0
M pkg/vt/recovery_transcript_test.go +109 -0
| @@ -187,6 +187,115 @@ func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) { | |||
| 187 | 187 | require.True(t, view.Row(0)[1].Continuation) | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | + | func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) { | |
| 191 | + | sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"}) | |
| 192 | + | transcript := recoveryTranscriptBlob(t, | |
| 193 | + | []string{"primary", "alternate"}, | |
| 194 | + | []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}}, | |
| 195 | + | ) | |
| 196 | + | ||
| 197 | + | screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | ||
| 200 | + | view := screen.History().View() | |
| 201 | + | require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view)) | |
| 202 | + | require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3)) | |
| 203 | + | require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam") | |
| 204 | + | for y := range screen.Frame.Height { | |
| 205 | + | for x := range screen.Frame.Width { | |
| 206 | + | require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | require.Equal(t, []LineBound{{}, {}}, screen.LineBounds()) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) { | |
| 213 | + | tests := []struct { | |
| 214 | + | name string | |
| 215 | + | config HistoryConfig | |
| 216 | + | history []string | |
| 217 | + | transcript []string | |
| 218 | + | want []string | |
| 219 | + | }{ | |
| 220 | + | { | |
| 221 | + | name: "row budget", | |
| 222 | + | config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2}, | |
| 223 | + | history: []string{"old-1", "old-2", "old-3"}, | |
| 224 | + | transcript: []string{"new-1", "new-2"}, | |
| 225 | + | want: []string{"old-3", "new-1", "new-2"}, | |
| 226 | + | }, | |
| 227 | + | { | |
| 228 | + | name: "cell budget", | |
| 229 | + | config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4}, | |
| 230 | + | history: []string{"aa", "b", "ccc"}, | |
| 231 | + | transcript: []string{"d", "ee"}, | |
| 232 | + | want: []string{"d", "ee"}, | |
| 233 | + | }, | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | for _, tt := range tests { | |
| 237 | + | t.Run(tt.name, func(t *testing.T) { | |
| 238 | + | sealed, tail := recoveryHistoryBlobs(t, tt.history) | |
| 239 | + | transcript := recoveryTranscriptBlob(t, tt.transcript, nil) | |
| 240 | + | ||
| 241 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript) | |
| 242 | + | require.NoError(t, err) | |
| 243 | + | require.Equal(t, tt.want, historyViewTexts(screen.History().View())) | |
| 244 | + | }) | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) { | |
| 249 | + | sealed, tail := recoveryHistoryBlobs(t, []string{"ok"}) | |
| 250 | + | transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil) | |
| 251 | + | ||
| 252 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript) | |
| 253 | + | ||
| 254 | + | require.ErrorIs(t, err, ErrHistoryRowTooWide) | |
| 255 | + | require.Nil(t, screen) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | |
| 259 | + | t.Helper() | |
| 260 | + | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | |
| 261 | + | for _, text := range texts { | |
| 262 | + | require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)})) | |
| 263 | + | } | |
| 264 | + | view := history.SnapshotView() | |
| 265 | + | sealed := make([][]byte, view.ChunkCount()) | |
| 266 | + | for i := range sealed { | |
| 267 | + | var err error | |
| 268 | + | sealed[i], err = MarshalHistoryChunk(view.Chunk(i)) | |
| 269 | + | require.NoError(t, err) | |
| 270 | + | } | |
| 271 | + | tail, err := MarshalHistoryTail(view) | |
| 272 | + | require.NoError(t, err) | |
| 273 | + | return sealed, tail | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte { | |
| 277 | + | t.Helper() | |
| 278 | + | rows := make([][]renderer.Cell, len(texts)) | |
| 279 | + | cells := 0 | |
| 280 | + | for i, text := range texts { | |
| 281 | + | rows[i] = historyRow(text) | |
| 282 | + | cells += len(rows[i]) | |
| 283 | + | } | |
| 284 | + | if bounds == nil { | |
| 285 | + | bounds = make([]LineBound, len(rows)) | |
| 286 | + | for i, row := range rows { | |
| 287 | + | bounds[i].End = len(row) | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | view := HistoryView{rows: len(rows), cells: cells} | |
| 291 | + | if len(rows) > 0 { | |
| 292 | + | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}} | |
| 293 | + | } | |
| 294 | + | blob, err := MarshalHistory(view) | |
| 295 | + | require.NoError(t, err) | |
| 296 | + | return blob | |
| 297 | + | } | |
| 298 | + | ||
| 190 | 299 | func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView { | |
| 191 | 300 | t.Helper() | |
| 192 | 301 | blob, err := snapshot.Marshal() | |
| @@ -187,6 +187,115 @@ func TestRecoveryTranscriptSnapshotPreservesWideCells(t *testing.T) { | |||||
| 187 | require.True(t, view.Row(0)[1].Continuation) | 187 | require.True(t, view.Row(0)[1].Continuation) | ||
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | + | func TestNewScreenWithRecoveryTranscriptRestoresHistoryThenTranscriptAndStartsBlank(t *testing.T) { | |||
| 191 | + | sealed, tail := recoveryHistoryBlobs(t, []string{"sealed-a", "sealed-b", "tail"}) | |||
| 192 | + | transcript := recoveryTranscriptBlob(t, | |||
| 193 | + | []string{"primary", "alternate"}, | |||
| 194 | + | []LineBound{{End: 7, Soft: true}, {End: 9, Soft: true}}, | |||
| 195 | + | ) | |||
| 196 | + | ||||
| 197 | + | screen, err := NewScreenWithRecoveryTranscript(5, 2, HistoryConfig{MaxRows: 8, MaxCells: 128, ChunkRows: 2}, sealed, tail, transcript) | |||
| 198 | + | require.NoError(t, err) | |||
| 199 | + | ||||
| 200 | + | view := screen.History().View() | |||
| 201 | + | require.Equal(t, []string{"sealed-a", "sealed-b", "tail", "primary", "alternate"}, historyViewTexts(view)) | |||
| 202 | + | require.Equal(t, LineBound{End: 7, Soft: true}, view.Bound(3)) | |||
| 203 | + | require.Equal(t, LineBound{End: 9}, view.Bound(4), "the restored transcript must end at a hard seam") | |||
| 204 | + | for y := range screen.Frame.Height { | |||
| 205 | + | for x := range screen.Frame.Width { | |||
| 206 | + | require.True(t, screen.Frame.At(x, y).Equal(renderer.BlankCell()), "cell (%d,%d) must start blank", x, y) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | require.Equal(t, []LineBound{{}, {}}, screen.LineBounds()) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | func TestNewScreenWithRecoveryTranscriptEvictsOldestRowsWithinBounds(t *testing.T) { | |||
| 213 | + | tests := []struct { | |||
| 214 | + | name string | |||
| 215 | + | config HistoryConfig | |||
| 216 | + | history []string | |||
| 217 | + | transcript []string | |||
| 218 | + | want []string | |||
| 219 | + | }{ | |||
| 220 | + | { | |||
| 221 | + | name: "row budget", | |||
| 222 | + | config: HistoryConfig{MaxRows: 3, MaxCells: 128, ChunkRows: 2}, | |||
| 223 | + | history: []string{"old-1", "old-2", "old-3"}, | |||
| 224 | + | transcript: []string{"new-1", "new-2"}, | |||
| 225 | + | want: []string{"old-3", "new-1", "new-2"}, | |||
| 226 | + | }, | |||
| 227 | + | { | |||
| 228 | + | name: "cell budget", | |||
| 229 | + | config: HistoryConfig{MaxRows: 10, MaxCells: 4, ChunkRows: 4}, | |||
| 230 | + | history: []string{"aa", "b", "ccc"}, | |||
| 231 | + | transcript: []string{"d", "ee"}, | |||
| 232 | + | want: []string{"d", "ee"}, | |||
| 233 | + | }, | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | for _, tt := range tests { | |||
| 237 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 238 | + | sealed, tail := recoveryHistoryBlobs(t, tt.history) | |||
| 239 | + | transcript := recoveryTranscriptBlob(t, tt.transcript, nil) | |||
| 240 | + | ||||
| 241 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, tt.config, sealed, tail, transcript) | |||
| 242 | + | require.NoError(t, err) | |||
| 243 | + | require.Equal(t, tt.want, historyViewTexts(screen.History().View())) | |||
| 244 | + | }) | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | ||||
| 248 | + | func TestNewScreenWithRecoveryTranscriptRejectsOversizedTranscriptRow(t *testing.T) { | |||
| 249 | + | sealed, tail := recoveryHistoryBlobs(t, []string{"ok"}) | |||
| 250 | + | transcript := recoveryTranscriptBlob(t, []string{"wide"}, nil) | |||
| 251 | + | ||||
| 252 | + | screen, err := NewScreenWithRecoveryTranscript(4, 2, HistoryConfig{MaxRows: 4, MaxCells: 3}, sealed, tail, transcript) | |||
| 253 | + | ||||
| 254 | + | require.ErrorIs(t, err, ErrHistoryRowTooWide) | |||
| 255 | + | require.Nil(t, screen) | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | func recoveryHistoryBlobs(t testing.TB, texts []string) ([][]byte, []byte) { | |||
| 259 | + | t.Helper() | |||
| 260 | + | history := NewHistory(HistoryConfig{MaxRows: len(texts) + 1, MaxCells: 1024, ChunkRows: 2}) | |||
| 261 | + | for _, text := range texts { | |||
| 262 | + | require.NoError(t, history.Append(historyRow(text), LineBound{End: len(text)})) | |||
| 263 | + | } | |||
| 264 | + | view := history.SnapshotView() | |||
| 265 | + | sealed := make([][]byte, view.ChunkCount()) | |||
| 266 | + | for i := range sealed { | |||
| 267 | + | var err error | |||
| 268 | + | sealed[i], err = MarshalHistoryChunk(view.Chunk(i)) | |||
| 269 | + | require.NoError(t, err) | |||
| 270 | + | } | |||
| 271 | + | tail, err := MarshalHistoryTail(view) | |||
| 272 | + | require.NoError(t, err) | |||
| 273 | + | return sealed, tail | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | func recoveryTranscriptBlob(t testing.TB, texts []string, bounds []LineBound) []byte { | |||
| 277 | + | t.Helper() | |||
| 278 | + | rows := make([][]renderer.Cell, len(texts)) | |||
| 279 | + | cells := 0 | |||
| 280 | + | for i, text := range texts { | |||
| 281 | + | rows[i] = historyRow(text) | |||
| 282 | + | cells += len(rows[i]) | |||
| 283 | + | } | |||
| 284 | + | if bounds == nil { | |||
| 285 | + | bounds = make([]LineBound, len(rows)) | |||
| 286 | + | for i, row := range rows { | |||
| 287 | + | bounds[i].End = len(row) | |||
| 288 | + | } | |||
| 289 | + | } | |||
| 290 | + | view := HistoryView{rows: len(rows), cells: cells} | |||
| 291 | + | if len(rows) > 0 { | |||
| 292 | + | view.chunks = []*HistoryChunk{{rows: rows, bounds: bounds}} | |||
| 293 | + | } | |||
| 294 | + | blob, err := MarshalHistory(view) | |||
| 295 | + | require.NoError(t, err) | |||
| 296 | + | return blob | |||
| 297 | + | } | |||
| 298 | + | ||||
| 190 | func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView { | 299 | func decodeRecoveryTranscript(t testing.TB, snapshot RecoveryTranscriptSnapshot) HistoryView { | ||
| 191 | t.Helper() | 300 | t.Helper() | ||
| 192 | blob, err := snapshot.Marshal() | 301 | blob, err := snapshot.Marshal() | ||
M pkg/vt/snapshot.go +28 -0
| @@ -111,6 +111,34 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||
| 111 | 111 | return h, nil | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | + | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | |
| 115 | + | // history contains the restored bounded history followed by the recovery | |
| 116 | + | // transcript. The transcript is decoded in full before history is restored. | |
| 117 | + | func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) { | |
| 118 | + | transcriptView, err := UnmarshalHistory(transcript) | |
| 119 | + | if err != nil { | |
| 120 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | history, err := HistoryFromBlobs(config, sealed, tail) | |
| 124 | + | if err != nil { | |
| 125 | + | return nil, err | |
| 126 | + | } | |
| 127 | + | for i := range transcriptView.Len() { | |
| 128 | + | bound := transcriptView.Bound(i) | |
| 129 | + | if i == transcriptView.Len()-1 { | |
| 130 | + | bound.Soft = false | |
| 131 | + | } | |
| 132 | + | if err := history.Append(transcriptView.BorrowedRow(i), bound); err != nil { | |
| 133 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |
| 134 | + | } | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | screen := NewScreenWithHistory(width, height, config) | |
| 138 | + | screen.history = history | |
| 139 | + | return screen, nil | |
| 140 | + | } | |
| 141 | + | ||
| 114 | 142 | // NewScreenWithRestoredHistory constructs a screen that owns history restored | |
| 115 | 143 | // directly from canonical blobs. Call RestorePrimaryVisible to install a | |
| 116 | 144 | // persisted primary frame before making the screen live. | |
| @@ -111,6 +111,34 @@ func HistoryFromBlobs(config HistoryConfig, sealed [][]byte, tail []byte) (*Hist | |||||
| 111 | return h, nil | 111 | return h, nil | ||
| 112 | } | 112 | } | ||
| 113 | 113 | ||||
| 114 | + | // NewScreenWithRecoveryTranscript constructs a fresh blank screen whose | |||
| 115 | + | // history contains the restored bounded history followed by the recovery | |||
| 116 | + | // transcript. The transcript is decoded in full before history is restored. | |||
| 117 | + | func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, sealed [][]byte, tail, transcript []byte) (*Screen, error) { | |||
| 118 | + | transcriptView, err := UnmarshalHistory(transcript) | |||
| 119 | + | if err != nil { | |||
| 120 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | history, err := HistoryFromBlobs(config, sealed, tail) | |||
| 124 | + | if err != nil { | |||
| 125 | + | return nil, err | |||
| 126 | + | } | |||
| 127 | + | for i := range transcriptView.Len() { | |||
| 128 | + | bound := transcriptView.Bound(i) | |||
| 129 | + | if i == transcriptView.Len()-1 { | |||
| 130 | + | bound.Soft = false | |||
| 131 | + | } | |||
| 132 | + | if err := history.Append(transcriptView.BorrowedRow(i), bound); err != nil { | |||
| 133 | + | return nil, fmt.Errorf("restore recovery transcript: %w", err) | |||
| 134 | + | } | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | screen := NewScreenWithHistory(width, height, config) | |||
| 138 | + | screen.history = history | |||
| 139 | + | return screen, nil | |||
| 140 | + | } | |||
| 141 | + | ||||
| 114 | // NewScreenWithRestoredHistory constructs a screen that owns history restored | 142 | // NewScreenWithRestoredHistory constructs a screen that owns history restored | ||
| 115 | // directly from canonical blobs. Call RestorePrimaryVisible to install a | 143 | // directly from canonical blobs. Call RestorePrimaryVisible to install a | ||
| 116 | // persisted primary frame before making the screen live. | 144 | // persisted primary frame before making the screen live. | ||