detached 790011a

feat(snapshot): persist recovery transcripts

commit
790011a05285e4333791612bdd219369cdf15305
author
brice · · 2026-07-27 12:33 UTC
parent
8c784f8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
28 files · +341 -249
M internal/adapters/snapshot/direct_load_test.go +4 -4
@@ -185,20 +185,20 @@ func (c *forwardOrphanCatalogue) Replace(name string, record domain.CatalogueRec
185185
186186 func incarnationPublication(t *testing.T, id domain.IncarnationID, name string, generation uint64, parent *domain.CheckpointRef) ports.SnapshotPublication {
187187 t.Helper()
188- tail, err := codec.MarshalObject(codec.HistoryTail, []byte("tail"))
188+ tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, "tail"))
189189 if err != nil {
190190 t.Fatal(err)
191191 }
192- visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
192+ transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, "transcript"))
193193 if err != nil {
194194 t.Fatal(err)
195195 }
196196 manifest, err := codec.MarshalManifest(codec.Manifest{
197197 Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name,
198- Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}},
198+ Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}},
199199 })
200200 if err != nil {
201201 t.Fatal(err)
202202 }
203- return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
203+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, transcript}}
204204 }
@@ -185,20 +185,20 @@ func (c *forwardOrphanCatalogue) Replace(name string, record domain.CatalogueRec
185 185
186 func incarnationPublication(t *testing.T, id domain.IncarnationID, name string, generation uint64, parent *domain.CheckpointRef) ports.SnapshotPublication {186 func incarnationPublication(t *testing.T, id domain.IncarnationID, name string, generation uint64, parent *domain.CheckpointRef) ports.SnapshotPublication {
187 t.Helper()187 t.Helper()
188- tail, err := codec.MarshalObject(codec.HistoryTail, []byte("tail"))188+ tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, "tail"))
189 if err != nil {189 if err != nil {
190 t.Fatal(err)190 t.Fatal(err)
191 }191 }
192- visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))192+ transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, "transcript"))
193 if err != nil {193 if err != nil {
194 t.Fatal(err)194 t.Fatal(err)
195 }195 }
196 manifest, err := codec.MarshalManifest(codec.Manifest{196 manifest, err := codec.MarshalManifest(codec.Manifest{
197 Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name,197 Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name,
198- Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}},198+ Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}},
199 })199 })
200 if err != nil {200 if err != nil {
201 t.Fatal(err)201 t.Fatal(err)
202 }202 }
203- return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}203+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: []ports.SnapshotObject{tail, transcript}}
204 }204 }
M internal/adapters/snapshot/publish.go +1 -1
@@ -320,7 +320,7 @@ func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.Object
320320 return nil
321321 }
322322 }
323- if !add(pane.Tail) || !add(pane.Visible) {
323+ if !add(pane.Tail) || !add(pane.Transcript) {
324324 return nil
325325 }
326326 }
@@ -320,7 +320,7 @@ func manifestRefs(manifest codec.Manifest) map[ports.SnapshotDigest]codec.Object
320 return nil320 return nil
321 }321 }
322 }322 }
323- if !add(pane.Tail) || !add(pane.Visible) {323+ if !add(pane.Tail) || !add(pane.Transcript) {
324 return nil324 return nil
325 }325 }
326 }326 }
M internal/adapters/snapshot/publish_efficiency_test.go +8 -8
@@ -61,32 +61,32 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S
6161 sealed := make([]codec.ObjectRef, 0, count)
6262 objects := make([]ports.SnapshotObject, 0, count+2)
6363 for i := range count {
64- object, err := codec.MarshalObject(codec.HistoryChunk, fmt.Appendf(nil, "history-%d", i))
64+ object, err := codec.MarshalObject(codec.HistoryChunk, canonicalHistoryBlob(t, fmt.Sprintf("history-%d", i)))
6565 if err != nil {
6666 t.Fatal(err)
6767 }
6868 sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))})
6969 objects = append(objects, object)
7070 }
71- makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, visiblePayload string) ports.SnapshotPublication {
71+ makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, transcriptPayload string) ports.SnapshotPublication {
7272 t.Helper()
73- tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload))
73+ tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, tailPayload))
7474 if err != nil {
7575 t.Fatal(err)
7676 }
77- visible, err := codec.MarshalObject(codec.Visible, []byte(visiblePayload))
77+ transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, transcriptPayload))
7878 if err != nil {
7979 t.Fatal(err)
8080 }
81- manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})
81+ manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}})
8282 if err != nil {
8383 t.Fatal(err)
8484 }
85- return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)}
85+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, transcript)}
8686 }
87- first := makePublication(1, nil, "tail-1", "visible-1")
87+ first := makePublication(1, nil, "tail-1", "transcript-1")
8888 parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)}
89- return first, makePublication(2, parent, "tail-2", "visible-2")
89+ return first, makePublication(2, parent, "tail-2", "transcript-2")
9090 }
9191
9292 func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {
@@ -61,32 +61,32 @@ func largeIncrementalPublications(t testing.TB, name string, count int) (ports.S
61 sealed := make([]codec.ObjectRef, 0, count)61 sealed := make([]codec.ObjectRef, 0, count)
62 objects := make([]ports.SnapshotObject, 0, count+2)62 objects := make([]ports.SnapshotObject, 0, count+2)
63 for i := range count {63 for i := range count {
64- object, err := codec.MarshalObject(codec.HistoryChunk, fmt.Appendf(nil, "history-%d", i))64+ object, err := codec.MarshalObject(codec.HistoryChunk, canonicalHistoryBlob(t, fmt.Sprintf("history-%d", i)))
65 if err != nil {65 if err != nil {
66 t.Fatal(err)66 t.Fatal(err)
67 }67 }
68 sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))})68 sealed = append(sealed, codec.ObjectRef{Kind: codec.HistoryChunk, Digest: object.Digest, Size: uint32(len(object.Data))})
69 objects = append(objects, object)69 objects = append(objects, object)
70 }70 }
71- makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, visiblePayload string) ports.SnapshotPublication {71+ makePublication := func(generation uint64, parent *domain.CheckpointRef, tailPayload, transcriptPayload string) ports.SnapshotPublication {
72 t.Helper()72 t.Helper()
73- tail, err := codec.MarshalObject(codec.HistoryTail, []byte(tailPayload))73+ tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, tailPayload))
74 if err != nil {74 if err != nil {
75 t.Fatal(err)75 t.Fatal(err)
76 }76 }
77- visible, err := codec.MarshalObject(codec.Visible, []byte(visiblePayload))77+ transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, transcriptPayload))
78 if err != nil {78 if err != nil {
79 t.Fatal(err)79 t.Fatal(err)
80 }80 }
81- manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})81+ manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, ParentCheckpoint: parent, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Sealed: sealed, Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}})
82 if err != nil {82 if err != nil {
83 t.Fatal(err)83 t.Fatal(err)
84 }84 }
85- return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, visible)}85+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, ParentCheckpoint: parent, Manifest: manifest, Objects: append(append([]ports.SnapshotObject(nil), objects...), tail, transcript)}
86 }86 }
87- first := makePublication(1, nil, "tail-1", "visible-1")87+ first := makePublication(1, nil, "tail-1", "transcript-1")
88 parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)}88 parent := &domain.CheckpointRef{Generation: 1, ManifestDigest: sha256.Sum256(first.Manifest)}
89- return first, makePublication(2, parent, "tail-2", "visible-2")89+ return first, makePublication(2, parent, "tail-2", "transcript-2")
90 }90 }
91 91
92 func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {92 func seedCompletePublication(t testing.TB, repo *Repository, publication ports.SnapshotPublication) {
M internal/adapters/snapshot/repository_hardening_test.go +1 -1
@@ -69,7 +69,7 @@ func TestValidatePublicationRejectsUnloadableAggregate(t *testing.T) {
6969 t.Fatal(err)
7070 }
7171 manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead)
72- manifest.Tabs[0].Panes[0].Visible.Size = uint32(maxRepositoryRead)
72+ manifest.Tabs[0].Panes[0].Transcript.Size = uint32(maxRepositoryRead)
7373 pub.Manifest, err = codec.MarshalManifest(manifest)
7474 if err != nil {
7575 t.Fatal(err)
@@ -69,7 +69,7 @@ func TestValidatePublicationRejectsUnloadableAggregate(t *testing.T) {
69 t.Fatal(err)69 t.Fatal(err)
70 }70 }
71 manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead)71 manifest.Tabs[0].Panes[0].Tail.Size = uint32(maxRepositoryRead)
72- manifest.Tabs[0].Panes[0].Visible.Size = uint32(maxRepositoryRead)72+ manifest.Tabs[0].Panes[0].Transcript.Size = uint32(maxRepositoryRead)
73 pub.Manifest, err = codec.MarshalManifest(manifest)73 pub.Manifest, err = codec.MarshalManifest(manifest)
74 if err != nil {74 if err != nil {
75 t.Fatal(err)75 t.Fatal(err)
M internal/adapters/snapshot/repository_test.go +4 -4
@@ -69,19 +69,19 @@ func publicationWithCurrentParent(t *testing.T, repo *Repository, publication po
6969 func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication {
7070 t.Helper()
7171 id := testIncarnationID(name)
72- tail, err := codec.MarshalObject(codec.HistoryTail, payload)
72+ tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, string(payload)))
7373 if err != nil {
7474 t.Fatal(err)
7575 }
76- visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))
76+ transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, "transcript"))
7777 if err != nil {
7878 t.Fatal(err)
7979 }
80- manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})
80+ manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}})
8181 if err != nil {
8282 t.Fatal(err)
8383 }
84- return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}
84+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, transcript}}
8585 }
8686
8787 func testIncarnationID(name string) domain.IncarnationID {
@@ -69,19 +69,19 @@ func publicationWithCurrentParent(t *testing.T, repo *Repository, publication po
69 func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication {69 func repositoryPublication(t *testing.T, name string, generation uint64, payload []byte) ports.SnapshotPublication {
70 t.Helper()70 t.Helper()
71 id := testIncarnationID(name)71 id := testIncarnationID(name)
72- tail, err := codec.MarshalObject(codec.HistoryTail, payload)72+ tail, err := codec.MarshalObject(codec.HistoryTail, canonicalHistoryBlob(t, string(payload)))
73 if err != nil {73 if err != nil {
74 t.Fatal(err)74 t.Fatal(err)
75 }75 }
76- visible, err := codec.MarshalObject(codec.Visible, []byte("visible"))76+ transcript, err := codec.MarshalObject(codec.RecoveryTranscript, canonicalHistoryBlob(t, "transcript"))
77 if err != nil {77 if err != nil {
78 t.Fatal(err)78 t.Fatal(err)
79 }79 }
80- manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Visible: codec.ObjectRef{Kind: codec.Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))}}}}}})80+ manifest, err := codec.MarshalManifest(codec.Manifest{Generation: generation, IncarnationID: id, Name: name, Tabs: []codec.ManifestTab{{Cols: 1, Rows: 1, Panes: []codec.ManifestPane{{ID: "p", Tail: codec.ObjectRef{Kind: codec.HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))}, Transcript: codec.ObjectRef{Kind: codec.RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))}}}}}})
81 if err != nil {81 if err != nil {
82 t.Fatal(err)82 t.Fatal(err)
83 }83 }
84- return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, visible}}84+ return ports.SnapshotPublication{IncarnationID: id, Name: name, Generation: generation, Manifest: manifest, Objects: []ports.SnapshotObject{tail, transcript}}
85 }85 }
86 86
87 func testIncarnationID(name string) domain.IncarnationID {87 func testIncarnationID(name string) domain.IncarnationID {
M internal/adapters/snapshot/test_helpers_test.go +22 -0
@@ -3,9 +3,31 @@ package snapshot
33 import (
44 "path/filepath"
55 "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
69 )
710
811 func privateDir(t *testing.T) string {
912 t.Helper()
1013 return filepath.Join(t.TempDir(), "vev")
1114 }
15+
16+func canonicalHistoryBlob(t testing.TB, text string) []byte {
17+ t.Helper()
18+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 1, ChunkRows: 1})
19+ if text != "" {
20+ row := make([]renderer.Cell, 0, len(text))
21+ for _, r := range text {
22+ row = append(row, renderer.Cell{Rune: r})
23+ }
24+ if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil {
25+ t.Fatal(err)
26+ }
27+ }
28+ blob, err := vt.MarshalHistory(history.SealAndView())
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ return blob
33+}
@@ -3,9 +3,31 @@ package snapshot
3 import (3 import (
4 "path/filepath"4 "path/filepath"
5 "testing"5 "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+ "github.com/bnema/vev/pkg/vt"
6 )9 )
7 10
8 func privateDir(t *testing.T) string {11 func privateDir(t *testing.T) string {
9 t.Helper()12 t.Helper()
10 return filepath.Join(t.TempDir(), "vev")13 return filepath.Join(t.TempDir(), "vev")
11 }14 }
15+
16+func canonicalHistoryBlob(t testing.TB, text string) []byte {
17+ t.Helper()
18+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: 1, ChunkRows: 1})
19+ if text != "" {
20+ row := make([]renderer.Cell, 0, len(text))
21+ for _, r := range text {
22+ row = append(row, renderer.Cell{Rune: r})
23+ }
24+ if err := history.Append(row, vt.LineBound{End: len(row)}); err != nil {
25+ t.Fatal(err)
26+ }
27+ }
28+ blob, err := vt.MarshalHistory(history.SealAndView())
29+ if err != nil {
30+ t.Fatal(err)
31+ }
32+ return blob
33+}
M internal/usecase/daemon/performance_benchmark_test.go +7 -7
@@ -78,7 +78,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
7878 fixture.snapshots.reset()
7979 fixture.activePane.mu.Lock()
8080 fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged"))
81- frame := fixture.activePane.screen.PrimaryVisibleFrame()
81+ frame := fixture.activePane.screen.Frame.Clone()
8282 fixture.activePane.mu.Unlock()
8383 for i, r := range "changed" {
8484 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
@@ -91,7 +91,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
9191 require.Equal(t, uint64(1), metrics.writes)
9292 require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied")
9393 require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied")
94- require.Positive(t, metrics.suppliedObjectBytes, "changed tail and visible state must still be supplied")
94+ require.Positive(t, metrics.suppliedObjectBytes, "changed tail and recovery transcript must still be supplied")
9595 requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name)
9696 }
9797
@@ -105,7 +105,7 @@ func requireCompleteSnapshotManifest(t *testing.T, repository *countingSnapshotR
105105 require.NoError(t, err)
106106 for _, tab := range manifest.Tabs {
107107 for _, pane := range tab.Panes {
108- for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Visible) {
108+ for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Transcript) {
109109 _, ok := objects[ref.Digest]
110110 require.True(t, ok, "manifest reference %x is not retained", ref.Digest)
111111 }
@@ -523,7 +523,7 @@ type snapshotBenchmarkScenario struct {
523523 var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
524524 {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000},
525525 {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true},
526- {name: "visible-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkVisible},
526+ {name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkTranscript},
527527 // Leave one row below the retention cap so the mutation changes only the
528528 // mutable tail and cannot evict a sealed chunk.
529529 {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},
@@ -636,9 +636,9 @@ func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generat
636636 markSnapshotCaptureObjectsPublished(capture)
637637 }
638638
639-func mutateBenchmarkVisible(fixture *performanceFixture, operation int) {
639+func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) {
640640 fixture.activePane.mu.Lock()
641- fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Hvisible-%08d", operation))
641+ fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Htranscript-%08d", operation))
642642 fixture.activePane.mu.Unlock()
643643 }
644644
@@ -822,7 +822,7 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
822822 }
823823
824824 // Root-cause hypothesis (verified by this guard before the fix): S2 allocates
825-// one visible VT-frame copy during capture plus a new composed frame and its
825+// one pane-frame copy during capture plus a new composed frame and its
826826 // base-frame clone for every live paint. Those client-sized copies, rather
827827 // than history, account for the ~1 MiB/op regression in the pinned benchmark.
828828 func TestLivePaintAllocationBudget(t *testing.T) {
@@ -78,7 +78,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
78 fixture.snapshots.reset()78 fixture.snapshots.reset()
79 fixture.activePane.mu.Lock()79 fixture.activePane.mu.Lock()
80 fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged"))80 fixture.activePane.screen.Write([]byte("\x1b[1;1Hchanged"))
81- frame := fixture.activePane.screen.PrimaryVisibleFrame()81+ frame := fixture.activePane.screen.Frame.Clone()
82 fixture.activePane.mu.Unlock()82 fixture.activePane.mu.Unlock()
83 for i, r := range "changed" {83 for i, r := range "changed" {
84 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)84 require.Equalf(t, r, frame.At(i, 0).Rune, "fixture must overwrite at column %d", i)
@@ -91,7 +91,7 @@ func TestDaemonSnapshotDoesNotResupplyUnchangedTenThousandChunkHistory(t *testin
91 require.Equal(t, uint64(1), metrics.writes)91 require.Equal(t, uint64(1), metrics.writes)
92 require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied")92 require.Zero(t, metrics.suppliedHistoryBytes, "retained sealed history must not be resupplied")
93 require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied")93 require.Zero(t, copies.Load(), "retained sealed history must not be deep-copied")
94- require.Positive(t, metrics.suppliedObjectBytes, "changed tail and visible state must still be supplied")94+ require.Positive(t, metrics.suppliedObjectBytes, "changed tail and recovery transcript must still be supplied")
95 requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name)95 requireCompleteSnapshotManifest(t, fixture.snapshots, fixture.sess.name)
96 }96 }
97 97
@@ -105,7 +105,7 @@ func requireCompleteSnapshotManifest(t *testing.T, repository *countingSnapshotR
105 require.NoError(t, err)105 require.NoError(t, err)
106 for _, tab := range manifest.Tabs {106 for _, tab := range manifest.Tabs {
107 for _, pane := range tab.Panes {107 for _, pane := range tab.Panes {
108- for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Visible) {108+ for _, ref := range append(append([]snapcodec.ObjectRef(nil), pane.Sealed...), pane.Tail, pane.Transcript) {
109 _, ok := objects[ref.Digest]109 _, ok := objects[ref.Digest]
110 require.True(t, ok, "manifest reference %x is not retained", ref.Digest)110 require.True(t, ok, "manifest reference %x is not retained", ref.Digest)
111 }111 }
@@ -523,7 +523,7 @@ type snapshotBenchmarkScenario struct {
523 var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{523 var snapshotBenchmarkScenarios = []snapshotBenchmarkScenario{
524 {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000},524 {name: "initial-10k-x-120", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000},
525 {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true},525 {name: "unchanged", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, unchanged: true},
526- {name: "visible-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkVisible},526+ {name: "transcript-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 10_000, baseline: true, mutate: mutateBenchmarkTranscript},
527 // Leave one row below the retention cap so the mutation changes only the527 // Leave one row below the retention cap so the mutation changes only the
528 // mutable tail and cannot evict a sealed chunk.528 // mutable tail and cannot evict a sealed chunk.
529 {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},529 {name: "tail-only", size: domain.Size{Cols: 120, Rows: 40}, historyRows: 9_999, baseline: true, mutate: mutateBenchmarkTail},
@@ -636,9 +636,9 @@ func publishBenchmarkSnapshot(b testing.TB, fixture *performanceFixture, generat
636 markSnapshotCaptureObjectsPublished(capture)636 markSnapshotCaptureObjectsPublished(capture)
637 }637 }
638 638
639-func mutateBenchmarkVisible(fixture *performanceFixture, operation int) {639+func mutateBenchmarkTranscript(fixture *performanceFixture, operation int) {
640 fixture.activePane.mu.Lock()640 fixture.activePane.mu.Lock()
641- fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Hvisible-%08d", operation))641+ fixture.activePane.screen.Write(fmt.Appendf(nil, "\x1b[1;1Htranscript-%08d", operation))
642 fixture.activePane.mu.Unlock()642 fixture.activePane.mu.Unlock()
643 }643 }
644 644
@@ -822,7 +822,7 @@ func benchmarkReportMetrics(b *testing.B, metrics performanceMetrics, operations
822 }822 }
823 823
824 // Root-cause hypothesis (verified by this guard before the fix): S2 allocates824 // Root-cause hypothesis (verified by this guard before the fix): S2 allocates
825-// one visible VT-frame copy during capture plus a new composed frame and its825+// one pane-frame copy during capture plus a new composed frame and its
826 // base-frame clone for every live paint. Those client-sized copies, rather826 // base-frame clone for every live paint. Those client-sized copies, rather
827 // than history, account for the ~1 MiB/op regression in the pinned benchmark.827 // than history, account for the ~1 MiB/op regression in the pinned benchmark.
828 func TestLivePaintAllocationBudget(t *testing.T) {828 func TestLivePaintAllocationBudget(t *testing.T) {
M internal/usecase/daemon/snapshot.go +3 -3
@@ -71,10 +71,10 @@ type snapshotCapturePane struct {
7171 // capture never rotates a live mutable tail merely to persist it.
7272 sealed vt.HistorySnapshotView
7373 tail vt.HistoryView
74- // visible is an owned copy taken while pane.mu is held. It must be
74+ // transcript is an owned copy taken while pane.mu is held. It must be
7575 // marshaled only after that lock has been released.
76- visible vt.PrimaryVisibleSnapshot
77- process *snapcodec.Process
76+ transcript vt.RecoveryTranscriptSnapshot
77+ process *snapcodec.Process
7878 }
7979
8080 // snapshotFailureSignature classifies a persistence failure without retaining
@@ -71,10 +71,10 @@ type snapshotCapturePane struct {
71 // capture never rotates a live mutable tail merely to persist it.71 // capture never rotates a live mutable tail merely to persist it.
72 sealed vt.HistorySnapshotView72 sealed vt.HistorySnapshotView
73 tail vt.HistoryView73 tail vt.HistoryView
74- // visible is an owned copy taken while pane.mu is held. It must be74+ // transcript is an owned copy taken while pane.mu is held. It must be
75 // marshaled only after that lock has been released.75 // marshaled only after that lock has been released.
76- visible vt.PrimaryVisibleSnapshot76+ transcript vt.RecoveryTranscriptSnapshot
77- process *snapcodec.Process77+ process *snapcodec.Process
78 }78 }
79 79
80 // snapshotFailureSignature classifies a persistence failure without retaining80 // snapshotFailureSignature classifies a persistence failure without retaining
M internal/usecase/daemon/snapshot_acceptance_test.go +5 -5
@@ -72,11 +72,11 @@ func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generat
7272 cols: 1,
7373 rows: 1,
7474 panes: []snapshotCapturePane{{
75- id: "pane",
76- stableID: "pane",
77- sealed: history,
78- tail: history.Tail(),
79- visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(),
75+ id: "pane",
76+ stableID: "pane",
77+ sealed: history,
78+ tail: history.Tail(),
79+ transcript: vt.NewScreen(1, 1).RecoveryTranscriptSnapshot(),
8080 }},
8181 }},
8282 }
@@ -72,11 +72,11 @@ func snapshotCacheCapture(sess *session, history vt.HistorySnapshotView, generat
72 cols: 1,72 cols: 1,
73 rows: 1,73 rows: 1,
74 panes: []snapshotCapturePane{{74 panes: []snapshotCapturePane{{
75- id: "pane",75+ id: "pane",
76- stableID: "pane",76+ stableID: "pane",
77- sealed: history,77+ sealed: history,
78- tail: history.Tail(),78+ tail: history.Tail(),
79- visible: vt.NewScreen(1, 1).PrimaryVisibleSnapshot(),79+ transcript: vt.NewScreen(1, 1).RecoveryTranscriptSnapshot(),
80 }},80 }},
81 }},81 }},
82 }82 }
M internal/usecase/daemon/snapshot_capture.go +4 -4
@@ -2,7 +2,7 @@ package daemon
22
33 import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
44
5-// captureSession rotates history tails and clones visible frames while holding
5+// captureSession copies history tails and recovery transcripts while holding
66 // each pane lock. The returned capture contains only immutable state; encoding
77 // and persistence are deliberately deferred to snapshotEncodeWorker.
88 func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
@@ -51,9 +51,9 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
5151 pid = pty.Pid()
5252 }
5353 paneCapture := snapshotCapturePane{
54- id: p.id,
55- stableID: p.stableID,
56- visible: p.screen.PrimaryVisibleSnapshot(),
54+ id: p.id,
55+ stableID: p.stableID,
56+ transcript: p.screen.RecoveryTranscriptSnapshot(),
5757 }
5858 paneCapture.sealed = p.history.SnapshotView()
5959 paneCapture.tail = paneCapture.sealed.Tail()
@@ -2,7 +2,7 @@ package daemon
2 2
3 import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"3 import snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
4 4
5-// captureSession rotates history tails and clones visible frames while holding5+// captureSession copies history tails and recovery transcripts while holding
6 // each pane lock. The returned capture contains only immutable state; encoding6 // each pane lock. The returned capture contains only immutable state; encoding
7 // and persistence are deliberately deferred to snapshotEncodeWorker.7 // and persistence are deliberately deferred to snapshotEncodeWorker.
8 func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {8 func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapshotCapture, bool) {
@@ -51,9 +51,9 @@ func (d *Daemon) captureSnapshotState(sess *session, generation uint64) (*snapsh
51 pid = pty.Pid()51 pid = pty.Pid()
52 }52 }
53 paneCapture := snapshotCapturePane{53 paneCapture := snapshotCapturePane{
54- id: p.id,54+ id: p.id,
55- stableID: p.stableID,55+ stableID: p.stableID,
56- visible: p.screen.PrimaryVisibleSnapshot(),56+ transcript: p.screen.RecoveryTranscriptSnapshot(),
57 }57 }
58 paneCapture.sealed = p.history.SnapshotView()58 paneCapture.sealed = p.history.SnapshotView()
59 paneCapture.tail = paneCapture.sealed.Tail()59 paneCapture.tail = paneCapture.sealed.Tail()
M internal/usecase/daemon/snapshot_incremental.go +9 -9
@@ -104,9 +104,9 @@ func copySnapshotObject(object ports.SnapshotObject) ports.SnapshotObject {
104104
105105 // incrementalPublication converts one immutable capture into complete VEVO
106106 // objects plus its VEVM manifest. Encoding runs after pane locks are released.
107-// Sealed history uses this capture's named-session pointer identity cache;
108-// tails and visible frames are intentionally not cached because they are copied
109-// mutable state.
107+// Sealed history uses this capture's named-session pointer identity cache.
108+// Tails and recovery transcripts are generation-specific copied mutable state
109+// and are intentionally never admitted to that cache.
110110 func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.SnapshotPublication, error) {
111111 if capture == nil || capture.session == nil || capture.name == "" {
112112 return ports.SnapshotPublication{}, fmt.Errorf("snapshot: empty capture")
@@ -120,11 +120,11 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
120120 for _, tab := range capture.tabs {
121121 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))}
122122 for _, pane := range tab.panes {
123- // visible was copied under pane.mu. Marshal it here in the worker,
123+ // transcript was copied under pane.mu. Marshal it here in the worker,
124124 // after all pane and session locks have been released.
125- visible, err := pane.visible.Marshal()
125+ transcript, err := pane.transcript.Marshal()
126126 if err != nil {
127- return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err)
127+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot recovery transcript: %w", err)
128128 }
129129 outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process}
130130 for i := 0; i < pane.sealed.ChunkCount(); i++ {
@@ -146,13 +146,13 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
146146 if err != nil {
147147 return ports.SnapshotPublication{}, err
148148 }
149- visibleObject, err := snapcodec.MarshalObject(snapcodec.Visible, visible)
149+ transcriptObject, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, transcript)
150150 if err != nil {
151151 return ports.SnapshotPublication{}, err
152152 }
153153 outPane.Tail = objectRef(snapcodec.HistoryTail, tailObject)
154- outPane.Visible = objectRef(snapcodec.Visible, visibleObject)
155- objects = append(objects, tailObject, visibleObject)
154+ outPane.Transcript = objectRef(snapcodec.RecoveryTranscript, transcriptObject)
155+ objects = append(objects, tailObject, transcriptObject)
156156 outTab.Panes = append(outTab.Panes, outPane)
157157 }
158158 manifest.Tabs = append(manifest.Tabs, outTab)
@@ -104,9 +104,9 @@ func copySnapshotObject(object ports.SnapshotObject) ports.SnapshotObject {
104 104
105 // incrementalPublication converts one immutable capture into complete VEVO105 // incrementalPublication converts one immutable capture into complete VEVO
106 // objects plus its VEVM manifest. Encoding runs after pane locks are released.106 // objects plus its VEVM manifest. Encoding runs after pane locks are released.
107-// Sealed history uses this capture's named-session pointer identity cache;107+// Sealed history uses this capture's named-session pointer identity cache.
108-// tails and visible frames are intentionally not cached because they are copied108+// Tails and recovery transcripts are generation-specific copied mutable state
109-// mutable state.109+// and are intentionally never admitted to that cache.
110 func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.SnapshotPublication, error) {110 func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.SnapshotPublication, error) {
111 if capture == nil || capture.session == nil || capture.name == "" {111 if capture == nil || capture.session == nil || capture.name == "" {
112 return ports.SnapshotPublication{}, fmt.Errorf("snapshot: empty capture")112 return ports.SnapshotPublication{}, fmt.Errorf("snapshot: empty capture")
@@ -120,11 +120,11 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
120 for _, tab := range capture.tabs {120 for _, tab := range capture.tabs {
121 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))}121 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))}
122 for _, pane := range tab.panes {122 for _, pane := range tab.panes {
123- // visible was copied under pane.mu. Marshal it here in the worker,123+ // transcript was copied under pane.mu. Marshal it here in the worker,
124 // after all pane and session locks have been released.124 // after all pane and session locks have been released.
125- visible, err := pane.visible.Marshal()125+ transcript, err := pane.transcript.Marshal()
126 if err != nil {126 if err != nil {
127- return ports.SnapshotPublication{}, fmt.Errorf("snapshot visible: %w", err)127+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot recovery transcript: %w", err)
128 }128 }
129 outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process}129 outPane := snapcodec.ManifestPane{ID: pane.id, StableID: pane.stableID, Cwd: pane.cwd, Process: pane.process}
130 for i := 0; i < pane.sealed.ChunkCount(); i++ {130 for i := 0; i < pane.sealed.ChunkCount(); i++ {
@@ -146,13 +146,13 @@ func (d *Daemon) incrementalPublication(capture *snapshotCapture) (ports.Snapsho
146 if err != nil {146 if err != nil {
147 return ports.SnapshotPublication{}, err147 return ports.SnapshotPublication{}, err
148 }148 }
149- visibleObject, err := snapcodec.MarshalObject(snapcodec.Visible, visible)149+ transcriptObject, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, transcript)
150 if err != nil {150 if err != nil {
151 return ports.SnapshotPublication{}, err151 return ports.SnapshotPublication{}, err
152 }152 }
153 outPane.Tail = objectRef(snapcodec.HistoryTail, tailObject)153 outPane.Tail = objectRef(snapcodec.HistoryTail, tailObject)
154- outPane.Visible = objectRef(snapcodec.Visible, visibleObject)154+ outPane.Transcript = objectRef(snapcodec.RecoveryTranscript, transcriptObject)
155- objects = append(objects, tailObject, visibleObject)155+ objects = append(objects, tailObject, transcriptObject)
156 outTab.Panes = append(outTab.Panes, outPane)156 outTab.Panes = append(outTab.Panes, outPane)
157 }157 }
158 manifest.Tabs = append(manifest.Tabs, outTab)158 manifest.Tabs = append(manifest.Tabs, outTab)
M internal/usecase/daemon/snapshot_incremental_test.go +34 -3
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "strings"
45 "testing"
56 "time"
67
@@ -64,11 +65,31 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) {
6465 }
6566 case <-time.After(testWaitTimeout):
6667 pane.mu.Unlock()
67- t.Fatal("visible encoding waited for pane lock")
68+ t.Fatal("recovery transcript encoding waited for pane lock")
6869 }
6970 pane.mu.Unlock()
7071 }
7172
73+func TestSnapshotCaptureOwnsRecoveryTranscriptAcrossLiveMutations(t *testing.T) {
74+ d := New(nil, nil, nil)
75+ sess := newSnapshotTestSession(t, "work", false, "/work")
76+ capture, ok := d.captureSnapshotState(sess, 1)
77+ require.True(t, ok)
78+
79+ pane := sess.tabs[0].panes["pane-1"]
80+ pane.mu.Lock()
81+ pane.screen.Write([]byte("\x1b[1;1Hchanged"))
82+ pane.mu.Unlock()
83+
84+ publication, err := d.incrementalPublication(capture)
85+ require.NoError(t, err)
86+ restored, err := sessionFromGeneration(snapshotGeneration(publication))
87+ require.NoError(t, err)
88+ view, err := vt.UnmarshalHistory(restored.Tabs[0].Panes[0].Transcript)
89+ require.NoError(t, err)
90+ require.Equal(t, "hello", strings.TrimRight(cellsString(view.Row(0)), " "))
91+}
92+
7293 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
7394 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})
7495 for i := range 6 {
@@ -122,15 +143,17 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
122143 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
123144 appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()})
124145 view := history.SnapshotView()
125- visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()
146+ transcript := vt.NewScreen(1, 1).RecoveryTranscriptSnapshot()
126147 d := New(nil, nil, nil)
127148 sess := newSnapshotTestSession(t, "work", false, "/work")
128- capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}}
149+ capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), transcript: transcript}}}}}
129150 first, err := d.incrementalPublication(capture)
130151 if err != nil {
131152 t.Fatal(err)
132153 }
133154 firstRef := domain.CheckpointRef{Generation: first.Generation, ManifestDigest: snapcodec.ManifestDigest(first.Manifest)}
155+ capture.checkpoint = firstRef
156+ markSnapshotCaptureObjectsPublished(capture)
134157 capture.generation = 2
135158 capture.parentCheckpoint = &firstRef
136159 second, err := d.incrementalPublication(capture)
@@ -140,6 +163,14 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
140163 if len(first.Objects) == 0 || len(second.Objects) == 0 {
141164 t.Fatal("publication omitted required objects")
142165 }
166+ require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks")
167+ kinds := make(map[snapcodec.ObjectKind]int)
168+ for _, object := range second.Objects {
169+ kind, _, err := snapcodec.PreflightObject(object.Data)
170+ require.NoError(t, err)
171+ kinds[kind]++
172+ }
173+ require.Equal(t, map[snapcodec.ObjectKind]int{snapcodec.HistoryTail: 1, snapcodec.RecoveryTranscript: 1}, kinds)
143174 require.Equal(t, &firstRef, second.ParentCheckpoint)
144175 manifest, err := snapcodec.UnmarshalManifest(second.Manifest)
145176 require.NoError(t, err)
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "strings"
4 "testing"5 "testing"
5 "time"6 "time"
6 7
@@ -64,11 +65,31 @@ func TestIncrementalPublicationEncodesAfterPaneUnlock(t *testing.T) {
64 }65 }
65 case <-time.After(testWaitTimeout):66 case <-time.After(testWaitTimeout):
66 pane.mu.Unlock()67 pane.mu.Unlock()
67- t.Fatal("visible encoding waited for pane lock")68+ t.Fatal("recovery transcript encoding waited for pane lock")
68 }69 }
69 pane.mu.Unlock()70 pane.mu.Unlock()
70 }71 }
71 72
73+func TestSnapshotCaptureOwnsRecoveryTranscriptAcrossLiveMutations(t *testing.T) {
74+ d := New(nil, nil, nil)
75+ sess := newSnapshotTestSession(t, "work", false, "/work")
76+ capture, ok := d.captureSnapshotState(sess, 1)
77+ require.True(t, ok)
78+
79+ pane := sess.tabs[0].panes["pane-1"]
80+ pane.mu.Lock()
81+ pane.screen.Write([]byte("\x1b[1;1Hchanged"))
82+ pane.mu.Unlock()
83+
84+ publication, err := d.incrementalPublication(capture)
85+ require.NoError(t, err)
86+ restored, err := sessionFromGeneration(snapshotGeneration(publication))
87+ require.NoError(t, err)
88+ view, err := vt.UnmarshalHistory(restored.Tabs[0].Panes[0].Transcript)
89+ require.NoError(t, err)
90+ require.Equal(t, "hello", strings.TrimRight(cellsString(view.Row(0)), " "))
91+}
92+
72 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {93 func TestSnapshotChunkCacheResistsWarmedOverCapacityHistoryScans(t *testing.T) {
73 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})94 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 6, ChunkRows: 1})
74 for i := range 6 {95 for i := range 6 {
@@ -122,15 +143,17 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
122 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})143 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 2, ChunkRows: 1})
123 appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()})144 appendHistoryRow(t, history, []renderer.Cell{renderer.BlankCell()})
124 view := history.SnapshotView()145 view := history.SnapshotView()
125- visible := vt.NewScreen(1, 1).PrimaryVisibleSnapshot()146+ transcript := vt.NewScreen(1, 1).RecoveryTranscriptSnapshot()
126 d := New(nil, nil, nil)147 d := New(nil, nil, nil)
127 sess := newSnapshotTestSession(t, "work", false, "/work")148 sess := newSnapshotTestSession(t, "work", false, "/work")
128- capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), visible: visible}}}}}149+ capture := &snapshotCapture{session: sess, name: "work", incarnation: domain.IncarnationID{1}, generation: 1, tabs: []snapshotCaptureTab{{stableID: "t", cols: 1, rows: 1, panes: []snapshotCapturePane{{id: "p", stableID: "p", sealed: view, tail: view.Tail(), transcript: transcript}}}}}
129 first, err := d.incrementalPublication(capture)150 first, err := d.incrementalPublication(capture)
130 if err != nil {151 if err != nil {
131 t.Fatal(err)152 t.Fatal(err)
132 }153 }
133 firstRef := domain.CheckpointRef{Generation: first.Generation, ManifestDigest: snapcodec.ManifestDigest(first.Manifest)}154 firstRef := domain.CheckpointRef{Generation: first.Generation, ManifestDigest: snapcodec.ManifestDigest(first.Manifest)}
155+ capture.checkpoint = firstRef
156+ markSnapshotCaptureObjectsPublished(capture)
134 capture.generation = 2157 capture.generation = 2
135 capture.parentCheckpoint = &firstRef158 capture.parentCheckpoint = &firstRef
136 second, err := d.incrementalPublication(capture)159 second, err := d.incrementalPublication(capture)
@@ -140,6 +163,14 @@ func TestIncrementalPublicationReusesSealedChunkObject(t *testing.T) {
140 if len(first.Objects) == 0 || len(second.Objects) == 0 {163 if len(first.Objects) == 0 || len(second.Objects) == 0 {
141 t.Fatal("publication omitted required objects")164 t.Fatal("publication omitted required objects")
142 }165 }
166+ require.Len(t, second.Objects, 2, "a generation must supply its tail and transcript once without resupplying persisted sealed chunks")
167+ kinds := make(map[snapcodec.ObjectKind]int)
168+ for _, object := range second.Objects {
169+ kind, _, err := snapcodec.PreflightObject(object.Data)
170+ require.NoError(t, err)
171+ kinds[kind]++
172+ }
173+ require.Equal(t, map[snapcodec.ObjectKind]int{snapcodec.HistoryTail: 1, snapcodec.RecoveryTranscript: 1}, kinds)
143 require.Equal(t, &firstRef, second.ParentCheckpoint)174 require.Equal(t, &firstRef, second.ParentCheckpoint)
144 manifest, err := snapcodec.UnmarshalManifest(second.Manifest)175 manifest, err := snapcodec.UnmarshalManifest(second.Manifest)
145 require.NoError(t, err)176 require.NoError(t, err)
M internal/usecase/daemon/snapshot_restore.go +4 -7
@@ -347,17 +347,14 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
347347 if len(snap.Tail) == 0 {
348348 return fmt.Errorf("snapshot history: missing tail blob")
349349 }
350- if len(snap.Visible) == 0 {
351- return fmt.Errorf("snapshot visible: missing visible blob")
350+ if len(snap.Transcript) == 0 {
351+ return fmt.Errorf("snapshot recovery transcript: missing transcript blob")
352352 }
353353 p.mu.Lock()
354354 defer p.mu.Unlock()
355- screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail)
355+ screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail, snap.Transcript)
356356 if err != nil {
357- return fmt.Errorf("snapshot history: %w", err)
358- }
359- if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
360- return fmt.Errorf("snapshot visible: %w", err)
357+ return fmt.Errorf("snapshot recovery: %w", err)
361358 }
362359 p.screen = screen
363360 p.history = screen.History()
@@ -347,17 +347,14 @@ func restorePaneTerminal(p *pane, snap snapcodec.Pane) error {
347 if len(snap.Tail) == 0 {347 if len(snap.Tail) == 0 {
348 return fmt.Errorf("snapshot history: missing tail blob")348 return fmt.Errorf("snapshot history: missing tail blob")
349 }349 }
350- if len(snap.Visible) == 0 {350+ if len(snap.Transcript) == 0 {
351- return fmt.Errorf("snapshot visible: missing visible blob")351+ return fmt.Errorf("snapshot recovery transcript: missing transcript blob")
352 }352 }
353 p.mu.Lock()353 p.mu.Lock()
354 defer p.mu.Unlock()354 defer p.mu.Unlock()
355- screen, err := vt.NewScreenWithRestoredHistory(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail)355+ screen, err := vt.NewScreenWithRecoveryTranscript(p.screen.Frame.Width, p.screen.Frame.Height, vt.HistoryConfig{MaxRows: defaultScrollbackRows, MaxCells: defaultScrollbackCells}, snap.SealedChunks, snap.Tail, snap.Transcript)
356 if err != nil {356 if err != nil {
357- return fmt.Errorf("snapshot history: %w", err)357+ return fmt.Errorf("snapshot recovery: %w", err)
358- }
359- if err := screen.RestorePrimaryVisible(snap.Visible); err != nil {
360- return fmt.Errorf("snapshot visible: %w", err)
361 }358 }
362 p.screen = screen359 p.screen = screen
363 p.history = screen.History()360 p.history = screen.History()
M internal/usecase/daemon/snapshot_restore_fixture_test.go +4 -4
@@ -29,12 +29,12 @@ func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublicatio
2929 if err != nil {
3030 return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err)
3131 }
32- visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible)
32+ transcript, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, pane.Transcript)
3333 if err != nil {
34- return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q visible: marshal object: %w", tab.StableID, pane.ID, err)
34+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q recovery transcript: marshal object: %w", tab.StableID, pane.ID, err)
3535 }
36- outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible)
37- objects = append(objects, tail, visible)
36+ outPane.Tail, outPane.Transcript = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.RecoveryTranscript, transcript)
37+ objects = append(objects, tail, transcript)
3838 outTab.Panes = append(outTab.Panes, outPane)
3939 }
4040 manifest.Tabs = append(manifest.Tabs, outTab)
@@ -29,12 +29,12 @@ func acceptancePublication(snapshot snapcodec.Session) (ports.SnapshotPublicatio
29 if err != nil {29 if err != nil {
30 return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err)30 return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q tail: marshal object: %w", tab.StableID, pane.ID, err)
31 }31 }
32- visible, err := snapcodec.MarshalObject(snapcodec.Visible, pane.Visible)32+ transcript, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, pane.Transcript)
33 if err != nil {33 if err != nil {
34- return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q visible: marshal object: %w", tab.StableID, pane.ID, err)34+ return ports.SnapshotPublication{}, fmt.Errorf("snapshot fixture conversion: tab %q pane %q recovery transcript: marshal object: %w", tab.StableID, pane.ID, err)
35 }35 }
36- outPane.Tail, outPane.Visible = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.Visible, visible)36+ outPane.Tail, outPane.Transcript = objectRef(snapcodec.HistoryTail, tail), objectRef(snapcodec.RecoveryTranscript, transcript)
37- objects = append(objects, tail, visible)37+ objects = append(objects, tail, transcript)
38 outTab.Panes = append(outTab.Panes, outPane)38 outTab.Panes = append(outTab.Panes, outPane)
39 }39 }
40 manifest.Tabs = append(manifest.Tabs, outTab)40 manifest.Tabs = append(manifest.Tabs, outTab)
M internal/usecase/daemon/snapshot_restore_incremental.go +1 -1
@@ -274,7 +274,7 @@ func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Sessi
274274 if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil {
275275 return snapcodec.Session{}, err
276276 }
277- if outPane.Visible, err = generationObject(generation, pane.Visible, snapcodec.Visible); err != nil {
277+ if outPane.Transcript, err = generationObject(generation, pane.Transcript, snapcodec.RecoveryTranscript); err != nil {
278278 return snapcodec.Session{}, err
279279 }
280280 outTab.Panes = append(outTab.Panes, outPane)
@@ -274,7 +274,7 @@ func sessionFromGeneration(generation ports.SnapshotGeneration) (snapcodec.Sessi
274 if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil {274 if outPane.Tail, err = generationObject(generation, pane.Tail, snapcodec.HistoryTail); err != nil {
275 return snapcodec.Session{}, err275 return snapcodec.Session{}, err
276 }276 }
277- if outPane.Visible, err = generationObject(generation, pane.Visible, snapcodec.Visible); err != nil {277+ if outPane.Transcript, err = generationObject(generation, pane.Transcript, snapcodec.RecoveryTranscript); err != nil {
278 return snapcodec.Session{}, err278 return snapcodec.Session{}, err
279 }279 }
280 outTab.Panes = append(outTab.Panes, outPane)280 outTab.Panes = append(outTab.Panes, outPane)
M internal/usecase/daemon/snapshot_restore_test.go +66 -17
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "math"
7+ "strings"
78 "sync"
89 "testing"
910
@@ -156,7 +157,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
156157 screen.Write([]byte("primary-visible"))
157158 screen.Write([]byte("\x1b[?1049h"))
158159 screen.Write([]byte("alternate-active"))
159- visible, err := screen.PrimaryVisibleSnapshot().Marshal()
160+ transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()
160161 require.NoError(t, err)
161162
162163 paneID := layout.PaneID("pane-7")
@@ -166,7 +167,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
166167 StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID,
167168 Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID},
168169 Panes: []snapcodec.Pane{{
169- ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Visible: visible,
170+ ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Transcript: transcript,
170171 Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"},
171172 }},
172173 }},
@@ -242,11 +243,20 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
242243 require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus)
243244 pane := tab.panes[layout.PaneID("pane-7")]
244245 pane.mu.Lock()
245- require.Equal(t, "primary-visible", string([]rune{pane.screen.Frame.Row(0)[0].Rune, pane.screen.Frame.Row(0)[1].Rune, pane.screen.Frame.Row(0)[2].Rune, pane.screen.Frame.Row(0)[3].Rune, pane.screen.Frame.Row(0)[4].Rune, pane.screen.Frame.Row(0)[5].Rune, pane.screen.Frame.Row(0)[6].Rune, pane.screen.Frame.Row(0)[7].Rune, pane.screen.Frame.Row(0)[8].Rune, pane.screen.Frame.Row(0)[9].Rune, pane.screen.Frame.Row(0)[10].Rune, pane.screen.Frame.Row(0)[11].Rune, pane.screen.Frame.Row(0)[12].Rune, pane.screen.Frame.Row(0)[13].Rune, pane.screen.Frame.Row(0)[14].Rune}))
246- require.Equal(t, 5, pane.history.Len())
247- require.Equal(t, 5*293, pane.history.Cells())
246+ wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"}
247+ require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows")
248+ for y := range pane.screen.Frame.Height {
249+ for x := range pane.screen.Frame.Width {
250+ require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y)
251+ }
252+ }
253+ require.Equal(t, 7, pane.history.Len())
254+ require.Equal(t, 7*293, pane.history.Cells())
248255 require.Equal(t, defaultScrollbackRows, pane.history.Cap())
249256 require.Equal(t, defaultScrollbackCells, pane.history.CellCap())
257+ pane.screen.Write([]byte("new-output"))
258+ pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l"))
259+ require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history")
250260 pane.mu.Unlock()
251261
252262 startSnapshotEncodeWorker(t, d)
@@ -311,6 +321,43 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
311321 require.Equal(t, uint64(10), published.Committed.Generation)
312322 }
313323
324+func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *testing.T) {
325+ base := restoreAcceptanceSession(t, "restored").Tabs[0].Panes[0]
326+ for _, tt := range []struct {
327+ name string
328+ mutate func(*snapcodec.Pane)
329+ }{
330+ {name: "missing tail", mutate: func(snap *snapcodec.Pane) { snap.Tail = nil }},
331+ {name: "missing transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = nil }},
332+ {name: "invalid transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = []byte("invalid") }},
333+ } {
334+ t.Run(tt.name, func(t *testing.T) {
335+ snap := base
336+ tt.mutate(&snap)
337+ p := newPane("pane", nil, domain.Size{Cols: 293, Rows: 4})
338+ p.screen.Write([]byte("old-frame"))
339+ require.NoError(t, p.history.Append(testRow("old-history"), vt.LineBound{End: len("old-history")}))
340+ oldScreen, oldHistory := p.screen, p.history
341+
342+ err := restorePaneTerminal(p, snap)
343+
344+ require.Error(t, err)
345+ require.Same(t, oldScreen, p.screen)
346+ require.Same(t, oldHistory, p.history)
347+ require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame")
348+ require.Equal(t, "old-history", cellsString(p.history.View().Row(0)))
349+ })
350+ }
351+}
352+
353+func snapshotHistoryTexts(view vt.HistoryView) []string {
354+ rows := make([]string, view.Len())
355+ for i := range rows {
356+ rows[i] = strings.TrimRight(cellsString(view.Row(i)), " ")
357+ }
358+ return rows
359+}
360+
314361 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
315362 snapshot := restoreAcceptanceSession(t, "fallback")
316363 valid := acceptanceGeneration(t, snapshot, 3)
@@ -325,18 +372,20 @@ func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
325372 break
326373 }
327374 }},
328- {"wrong kind", func(g *ports.SnapshotGeneration) {
329- for digest, data := range g.Objects {
330- kind, payload, err := snapcodec.UnmarshalObject(data)
331- require.NoError(t, err)
332- if kind == snapcodec.HistoryTail {
333- replacement, err := snapcodec.MarshalObject(snapcodec.Visible, payload)
334- require.NoError(t, err)
335- delete(g.Objects, digest)
336- g.Objects[digest] = replacement.Data
337- break
338- }
339- }
375+ {"wrong role after digest verification", func(g *ports.SnapshotGeneration) {
376+ manifest, err := snapcodec.UnmarshalManifest(g.Manifest)
377+ require.NoError(t, err)
378+ tailRef := manifest.Tabs[0].Panes[0].Tail
379+ _, payload, err := snapcodec.UnmarshalObject(g.Objects[tailRef.Digest])
380+ require.NoError(t, err)
381+ replacement, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, payload)
382+ require.NoError(t, err)
383+ manifest.Tabs[0].Panes[0].Tail.Digest = replacement.Digest
384+ manifest.Tabs[0].Panes[0].Tail.Size = uint32(len(replacement.Data))
385+ g.Manifest, err = snapcodec.MarshalManifest(manifest)
386+ require.NoError(t, err)
387+ delete(g.Objects, tailRef.Digest)
388+ g.Objects[replacement.Digest] = replacement.Data
340389 }},
341390 {"wrong digest", func(g *ports.SnapshotGeneration) {
342391 for digest, data := range g.Objects {
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "math"6 "math"
7+ "strings"
7 "sync"8 "sync"
8 "testing"9 "testing"
9 10
@@ -156,7 +157,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
156 screen.Write([]byte("primary-visible"))157 screen.Write([]byte("primary-visible"))
157 screen.Write([]byte("\x1b[?1049h"))158 screen.Write([]byte("\x1b[?1049h"))
158 screen.Write([]byte("alternate-active"))159 screen.Write([]byte("alternate-active"))
159- visible, err := screen.PrimaryVisibleSnapshot().Marshal()160+ transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()
160 require.NoError(t, err)161 require.NoError(t, err)
161 162
162 paneID := layout.PaneID("pane-7")163 paneID := layout.PaneID("pane-7")
@@ -166,7 +167,7 @@ func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
166 StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID,167 StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID,
167 Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID},168 Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID},
168 Panes: []snapcodec.Pane{{169 Panes: []snapcodec.Pane{{
169- ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Visible: visible,170+ ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Transcript: transcript,
170 Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"},171 Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"},
171 }},172 }},
172 }},173 }},
@@ -242,11 +243,20 @@ func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
242 require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus)243 require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus)
243 pane := tab.panes[layout.PaneID("pane-7")]244 pane := tab.panes[layout.PaneID("pane-7")]
244 pane.mu.Lock()245 pane.mu.Lock()
245- require.Equal(t, "primary-visible", string([]rune{pane.screen.Frame.Row(0)[0].Rune, pane.screen.Frame.Row(0)[1].Rune, pane.screen.Frame.Row(0)[2].Rune, pane.screen.Frame.Row(0)[3].Rune, pane.screen.Frame.Row(0)[4].Rune, pane.screen.Frame.Row(0)[5].Rune, pane.screen.Frame.Row(0)[6].Rune, pane.screen.Frame.Row(0)[7].Rune, pane.screen.Frame.Row(0)[8].Rune, pane.screen.Frame.Row(0)[9].Rune, pane.screen.Frame.Row(0)[10].Rune, pane.screen.Frame.Row(0)[11].Rune, pane.screen.Frame.Row(0)[12].Rune, pane.screen.Frame.Row(0)[13].Rune, pane.screen.Frame.Row(0)[14].Rune}))246+ wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"}
246- require.Equal(t, 5, pane.history.Len())247+ require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows")
247- require.Equal(t, 5*293, pane.history.Cells())248+ for y := range pane.screen.Frame.Height {
249+ for x := range pane.screen.Frame.Width {
250+ require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y)
251+ }
252+ }
253+ require.Equal(t, 7, pane.history.Len())
254+ require.Equal(t, 7*293, pane.history.Cells())
248 require.Equal(t, defaultScrollbackRows, pane.history.Cap())255 require.Equal(t, defaultScrollbackRows, pane.history.Cap())
249 require.Equal(t, defaultScrollbackCells, pane.history.CellCap())256 require.Equal(t, defaultScrollbackCells, pane.history.CellCap())
257+ pane.screen.Write([]byte("new-output"))
258+ pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l"))
259+ require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history")
250 pane.mu.Unlock()260 pane.mu.Unlock()
251 261
252 startSnapshotEncodeWorker(t, d)262 startSnapshotEncodeWorker(t, d)
@@ -311,6 +321,43 @@ func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
311 require.Equal(t, uint64(10), published.Committed.Generation)321 require.Equal(t, uint64(10), published.Committed.Generation)
312 }322 }
313 323
324+func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *testing.T) {
325+ base := restoreAcceptanceSession(t, "restored").Tabs[0].Panes[0]
326+ for _, tt := range []struct {
327+ name string
328+ mutate func(*snapcodec.Pane)
329+ }{
330+ {name: "missing tail", mutate: func(snap *snapcodec.Pane) { snap.Tail = nil }},
331+ {name: "missing transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = nil }},
332+ {name: "invalid transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = []byte("invalid") }},
333+ } {
334+ t.Run(tt.name, func(t *testing.T) {
335+ snap := base
336+ tt.mutate(&snap)
337+ p := newPane("pane", nil, domain.Size{Cols: 293, Rows: 4})
338+ p.screen.Write([]byte("old-frame"))
339+ require.NoError(t, p.history.Append(testRow("old-history"), vt.LineBound{End: len("old-history")}))
340+ oldScreen, oldHistory := p.screen, p.history
341+
342+ err := restorePaneTerminal(p, snap)
343+
344+ require.Error(t, err)
345+ require.Same(t, oldScreen, p.screen)
346+ require.Same(t, oldHistory, p.history)
347+ require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame")
348+ require.Equal(t, "old-history", cellsString(p.history.View().Row(0)))
349+ })
350+ }
351+}
352+
353+func snapshotHistoryTexts(view vt.HistoryView) []string {
354+ rows := make([]string, view.Len())
355+ for i := range rows {
356+ rows[i] = strings.TrimRight(cellsString(view.Row(i)), " ")
357+ }
358+ return rows
359+}
360+
314 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {361 func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
315 snapshot := restoreAcceptanceSession(t, "fallback")362 snapshot := restoreAcceptanceSession(t, "fallback")
316 valid := acceptanceGeneration(t, snapshot, 3)363 valid := acceptanceGeneration(t, snapshot, 3)
@@ -325,18 +372,20 @@ func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
325 break372 break
326 }373 }
327 }},374 }},
328- {"wrong kind", func(g *ports.SnapshotGeneration) {375+ {"wrong role after digest verification", func(g *ports.SnapshotGeneration) {
329- for digest, data := range g.Objects {376+ manifest, err := snapcodec.UnmarshalManifest(g.Manifest)
330- kind, payload, err := snapcodec.UnmarshalObject(data)377+ require.NoError(t, err)
331- require.NoError(t, err)378+ tailRef := manifest.Tabs[0].Panes[0].Tail
332- if kind == snapcodec.HistoryTail {379+ _, payload, err := snapcodec.UnmarshalObject(g.Objects[tailRef.Digest])
333- replacement, err := snapcodec.MarshalObject(snapcodec.Visible, payload)380+ require.NoError(t, err)
334- require.NoError(t, err)381+ replacement, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, payload)
335- delete(g.Objects, digest)382+ require.NoError(t, err)
336- g.Objects[digest] = replacement.Data383+ manifest.Tabs[0].Panes[0].Tail.Digest = replacement.Digest
337- break384+ manifest.Tabs[0].Panes[0].Tail.Size = uint32(len(replacement.Data))
338- }385+ g.Manifest, err = snapcodec.MarshalManifest(manifest)
339- }386+ require.NoError(t, err)
387+ delete(g.Objects, tailRef.Digest)
388+ g.Objects[replacement.Digest] = replacement.Data
340 }},389 }},
341 {"wrong digest", func(g *ports.SnapshotGeneration) {390 {"wrong digest", func(g *ports.SnapshotGeneration) {
342 for digest, data := range g.Objects {391 for digest, data := range g.Objects {
M internal/usecase/snapshot/codec.go +3 -3
@@ -22,7 +22,7 @@ var (
2222
2323 const (
2424 magic = "VEVS"
25- version = uint16(3)
25+ version = uint16(4)
2626 maxDecodedBodySize = 256 << 20
2727 maxSnapshotObjects = 1 << 16
2828 maxSnapshotBytes = 256 << 20
@@ -428,7 +428,7 @@ func readPane(r *payloadReader) (Pane, error) {
428428 if err != nil {
429429 return Pane{}, err
430430 }
431- visible, err := r.getBlob()
431+ transcript, err := r.getBlob()
432432 if err != nil {
433433 return Pane{}, err
434434 }
@@ -436,7 +436,7 @@ func readPane(r *payloadReader) (Pane, error) {
436436 if err != nil {
437437 return Pane{}, err
438438 }
439- return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil
439+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: proc}, nil
440440 }
441441
442442 func readProcess(r *payloadReader) (*Process, error) {
@@ -22,7 +22,7 @@ var (
22 22
23 const (23 const (
24 magic = "VEVS"24 magic = "VEVS"
25- version = uint16(3)25+ version = uint16(4)
26 maxDecodedBodySize = 256 << 2026 maxDecodedBodySize = 256 << 20
27 maxSnapshotObjects = 1 << 1627 maxSnapshotObjects = 1 << 16
28 maxSnapshotBytes = 256 << 2028 maxSnapshotBytes = 256 << 20
@@ -428,7 +428,7 @@ func readPane(r *payloadReader) (Pane, error) {
428 if err != nil {428 if err != nil {
429 return Pane{}, err429 return Pane{}, err
430 }430 }
431- visible, err := r.getBlob()431+ transcript, err := r.getBlob()
432 if err != nil {432 if err != nil {
433 return Pane{}, err433 return Pane{}, err
434 }434 }
@@ -436,7 +436,7 @@ func readPane(r *payloadReader) (Pane, error) {
436 if err != nil {436 if err != nil {
437 return Pane{}, err437 return Pane{}, err
438 }438 }
439- return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Visible: visible, Process: proc}, nil439+ return Pane{ID: layout.PaneID(id), StableID: stableID, Cwd: cwd, SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: proc}, nil
440 }440 }
441 441
442 func readProcess(r *payloadReader) (*Process, error) {442 func readProcess(r *payloadReader) (*Process, error) {
M internal/usecase/snapshot/codec_encoder_test.go +6 -6
@@ -9,8 +9,8 @@ import (
99 "github.com/bnema/vev/internal/usecase/layout"
1010 )
1111
12-// marshalTest is a test-only v3 encoder. Production retains only the decoder
13-// required by the one-time legacy import.
12+// marshalTest is a test-only v4 encoder. Production retains only the decoder
13+// required by the one-time import.
1414 func marshalTest(s Session) ([]byte, error) {
1515 var w payloadWriter
1616 if err := writeSession(&w, s); err != nil {
@@ -104,10 +104,10 @@ func writePane(w *payloadWriter, p Pane) error {
104104 }
105105 w.putUint32(uint32(len(p.Tail)))
106106 w.b = append(w.b, p.Tail...)
107- if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {
108- return fmt.Errorf("%w: visible length", ErrInvalidData)
107+ if len(p.Transcript) == 0 || len(p.Transcript) > math.MaxUint32 {
108+ return fmt.Errorf("%w: transcript length", ErrInvalidData)
109109 }
110- w.putUint32(uint32(len(p.Visible)))
111- w.b = append(w.b, p.Visible...)
110+ w.putUint32(uint32(len(p.Transcript)))
111+ w.b = append(w.b, p.Transcript...)
112112 return writeProcess(w, p.Process)
113113 }
@@ -9,8 +9,8 @@ import (
9 "github.com/bnema/vev/internal/usecase/layout"9 "github.com/bnema/vev/internal/usecase/layout"
10 )10 )
11 11
12-// marshalTest is a test-only v3 encoder. Production retains only the decoder12+// marshalTest is a test-only v4 encoder. Production retains only the decoder
13-// required by the one-time legacy import.13+// required by the one-time import.
14 func marshalTest(s Session) ([]byte, error) {14 func marshalTest(s Session) ([]byte, error) {
15 var w payloadWriter15 var w payloadWriter
16 if err := writeSession(&w, s); err != nil {16 if err := writeSession(&w, s); err != nil {
@@ -104,10 +104,10 @@ func writePane(w *payloadWriter, p Pane) error {
104 }104 }
105 w.putUint32(uint32(len(p.Tail)))105 w.putUint32(uint32(len(p.Tail)))
106 w.b = append(w.b, p.Tail...)106 w.b = append(w.b, p.Tail...)
107- if len(p.Visible) == 0 || len(p.Visible) > math.MaxUint32 {107+ if len(p.Transcript) == 0 || len(p.Transcript) > math.MaxUint32 {
108- return fmt.Errorf("%w: visible length", ErrInvalidData)108+ return fmt.Errorf("%w: transcript length", ErrInvalidData)
109 }109 }
110- w.putUint32(uint32(len(p.Visible)))110+ w.putUint32(uint32(len(p.Transcript)))
111- w.b = append(w.b, p.Visible...)111+ w.b = append(w.b, p.Transcript...)
112 return writeProcess(w, p.Process)112 return writeProcess(w, p.Process)
113 }113 }
M internal/usecase/snapshot/codec_test.go +106 -91
@@ -43,7 +43,7 @@ func TestPayloadReaderGetBytes(t *testing.T) {
4343 }
4444 }
4545
46-func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
46+func TestV4SnapshotRoundTripPreservesCanonicalTerminalData(t *testing.T) {
4747 indexed := renderer.DefaultStyle()
4848 indexed.Bold, indexed.Inverse = true, true
4949 indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough
@@ -54,18 +54,18 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
5454 rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6}
5555 rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}
5656 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})
57- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})
57+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})
5858 sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}})
59- visible2 := visibleBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}})
60- want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{
61- {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},
62- {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},
59+ transcript2 := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}})
60+ want := Session{Name: "v4 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{
61+ {ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript},
62+ {ID: "p2", SealedChunks: sealed2, Tail: tail2, Transcript: transcript2},
6363 }}}}
6464 encoded, err := marshalTest(want)
6565 if err != nil {
6666 t.Fatal(err)
6767 }
68- requireV3Envelope(t, encoded)
68+ requireV4Envelope(t, encoded)
6969 got, err := Unmarshal(encoded)
7070 if err != nil {
7171 t.Fatal(err)
@@ -86,19 +86,19 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
8686 if !bytes.Equal(gotPane.Tail, wantPane.Tail) {
8787 t.Fatalf("pane %q tail changed during round trip", wantPane.ID)
8888 }
89- if !bytes.Equal(gotPane.Visible, wantPane.Visible) {
90- t.Fatalf("pane %q visible blob changed during round trip", wantPane.ID)
89+ if !bytes.Equal(gotPane.Transcript, wantPane.Transcript) {
90+ t.Fatalf("pane %q transcript blob changed during round trip", wantPane.ID)
9191 }
9292 }
9393 if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {
9494 t.Fatalf("history decode: %v", err)
9595 }
96- frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible)
96+ view, err := vt.UnmarshalHistory(got.Tabs[0].Panes[0].Transcript)
9797 if err != nil {
9898 t.Fatal(err)
9999 }
100- if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation {
101- t.Fatalf("visible terminal data lost: %#v", frame.Row(0))
100+ if !view.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !view.Row(0)[2].Continuation {
101+ t.Fatalf("transcript terminal data lost: %#v", view.Row(0))
102102 }
103103 }
104104
@@ -130,26 +130,26 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) {
130130 name string
131131 data []byte
132132 }{
133- {name: "zero tabs active nonzero", data: v3ActiveManifest(1, 0)},
134- {name: "nonempty tabs active past end", data: v3ActiveManifest(1, 1)},
133+ {name: "zero tabs active nonzero", data: v4ActiveManifest(1, 0)},
134+ {name: "nonempty tabs active past end", data: v4ActiveManifest(1, 1)},
135135 } {
136136 t.Run("unmarshal "+tc.name, func(t *testing.T) {
137- reject(t, v3Envelope(version, 0, tc.data), ErrInvalidData)
137+ reject(t, v4Envelope(version, 0, tc.data), ErrInvalidData)
138138 })
139139 }
140140 }
141141
142-func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {
143- for _, version := range []uint16{1, 2} {
144- if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
142+func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) {
143+ for _, version := range []uint16{1, 2, 3} {
144+ if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
145145 t.Fatalf("version %d: %v", version, err)
146146 }
147147 }
148148 }
149149
150-func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
151- body := v3Manifest(0, nil, nil, 0, nil, 0, nil)
152- valid := v3Envelope(version, 0, body)
150+func TestUnmarshalRejectsMalformedV4EnvelopeBeforeAllocation(t *testing.T) {
151+ body := v4Manifest(0, nil, nil, 0, nil, 0, nil)
152+ valid := v4Envelope(version, 0, body)
153153 badCRC := append([]byte(nil), valid...)
154154 badCRC[15] ^= 1
155155 for _, tc := range []struct {
@@ -158,31 +158,31 @@ func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
158158 want error
159159 }{
160160 {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic},
161- {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData},
162- {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData},
161+ {"future", v4Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v4Envelope(version, 1, body), ErrInvalidData},
162+ {"crc", badCRC, ErrBadCRC}, {"body", v4EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData},
163163 } {
164164 t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) })
165165 }
166166 }
167167
168-func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testing.T) {
168+func TestUnmarshalRejectsHostileV4ManifestDeclarationsBeforeAllocation(t *testing.T) {
169169 for _, tc := range []struct {
170170 name string
171171 body []byte
172172 }{
173- {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
174- {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
175- {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
176- {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
173+ {"count", v4Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
174+ {"sealed", v4Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
175+ {"tail", v4Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
176+ {"transcript", v4Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
177177 } {
178- t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) })
178+ t.Run(tc.name, func(t *testing.T) { reject(t, v4Envelope(version, 0, tc.body), ErrInvalidData) })
179179 }
180180 }
181181
182-func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
182+func TestUnmarshalRejectsEveryV4PrefixAndTrailingGarbage(t *testing.T) {
183183 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
184- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
185- encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
184+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
185+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}}})
186186 if err != nil {
187187 t.Fatal(err)
188188 }
@@ -194,13 +194,13 @@ func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
194194
195195 func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
196196 sealed, tail := historyBlobs(t, nil)
197- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
197+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
198198 for _, tc := range []struct {
199199 name string
200200 panes []Pane
201201 }{
202- {name: "empty", panes: []Pane{{ID: "", Tail: tail, Visible: visible}}},
203- {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},
202+ {name: "empty", panes: []Pane{{ID: "", Tail: tail, Transcript: transcript}}},
203+ {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}, {ID: "p", Tail: tail, Transcript: transcript}}},
204204 } {
205205 t.Run(tc.name, func(t *testing.T) {
206206 data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
@@ -212,8 +212,8 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
212212
213213 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
214214 sealed, tail := historyBlobs(t, nil)
215- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
216- encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})
215+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
216+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}}})
217217 if err != nil {
218218 t.Fatal(err)
219219 }
@@ -231,12 +231,12 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
231231 multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2)
232232 multiChunk = append(multiChunk, sealed[0][9:]...)
233233 multiChunk = append(multiChunk, sealed[0][9:]...)
234- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
235- emptyVisible := visibleBlob(t, nil)
234+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
235+ emptyTranscript := transcriptBlob(t, nil)
236236
237237 // Incremental captures retain one copied mutable tail chunk instead of
238238 // rotating it into the sealed history set. That one chunk is canonical and
239- // must remain a valid v3 import payload.
239+ // must remain a valid v4 import payload.
240240 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
241241 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {
242242 requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)}))
@@ -245,22 +245,32 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
245245 requireNoError(t, err)
246246 fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView())
247247 requireNoError(t, err)
248- valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}})
249- requireNoError(t, err)
250- roundTrip, err := Unmarshal(valid)
251- requireNoError(t, err)
252- if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) {
253- t.Fatal("mutable tail did not round trip")
248+ for _, tc := range []struct {
249+ name string
250+ transcript []byte
251+ }{
252+ {name: "single chunk", transcript: transcript},
253+ {name: "empty", transcript: emptyTranscript},
254+ {name: "multiple chunks", transcript: multiChunk},
255+ } {
256+ t.Run("valid "+tc.name, func(t *testing.T) {
257+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Transcript: tc.transcript}}}}})
258+ requireNoError(t, err)
259+ roundTrip, err := Unmarshal(data)
260+ requireNoError(t, err)
261+ if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) {
262+ t.Fatal("mutable tail did not round trip")
263+ }
264+ })
254265 }
255266
256267 for _, tc := range []struct {
257268 name string
258269 tab Tab
259270 }{
260- {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}},
261- {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}},
262- {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Visible: visible}}}},
263- {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}},
271+ {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Transcript: transcript}}}},
272+ {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Transcript: transcript}}}},
273+ {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Transcript: transcript}}}},
264274 } {
265275 t.Run(tc.name, func(t *testing.T) {
266276 data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})
@@ -270,13 +280,22 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
270280 }
271281 }
272282
273-func TestUnmarshalRejectsMalformedTerminalBlobWithoutPanic(t *testing.T) {
283+func TestUnmarshalRejectsMalformedCanonicalVTBlobsWithoutPanic(t *testing.T) {
274284 tail := mustHistoryTail(t)
275- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
276- // The visible cell's underline-style byte is outside the renderer enum.
277- visible[13+29] = 0xff
278- body := v3Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(visible)), visible)
279- reject(t, v3Envelope(version, 0, body), ErrInvalidData)
285+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
286+ for _, tc := range []struct {
287+ name string
288+ blob []byte
289+ }{
290+ {name: "malformed", blob: append([]byte("NOPE"), transcript[4:]...)},
291+ {name: "truncated", blob: transcript[:len(transcript)-1]},
292+ {name: "trailing", blob: append(append([]byte(nil), transcript...), 0)},
293+ } {
294+ t.Run(tc.name, func(t *testing.T) {
295+ body := v4Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(tc.blob)), tc.blob)
296+ reject(t, v4Envelope(version, 0, body), ErrInvalidData)
297+ })
298+ }
280299 }
281300
282301 func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
@@ -301,7 +320,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
301320
302321 func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
303322 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}})
304- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
323+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
305324 want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
306325 {
307326 StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2",
@@ -309,11 +328,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
309328 layout.NewLeaf("1"), layout.NewLeaf("2"),
310329 }}},
311330 Panes: []Pane{
312- {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Visible: visible, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}},
313- {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Visible: visible},
331+ {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}},
332+ {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Transcript: transcript},
314333 },
315334 },
316- {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}},
335+ {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Transcript: transcript}}},
317336 }}
318337 data, err := marshalTest(want)
319338 requireNoError(t, err)
@@ -339,7 +358,7 @@ func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
339358
340359 func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
341360 tail := mustHistoryTail(t)
342- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
361+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
343362 var w payloadWriter
344363 _ = w.putString("s")
345364 w.putUint64(0)
@@ -358,13 +377,13 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
358377 w.putUint32(0)
359378 w.putUint32(uint32(len(tail)))
360379 w.b = append(w.b, tail...)
361- w.putUint32(uint32(len(visible)))
362- w.b = append(w.b, visible...)
380+ w.putUint32(uint32(len(transcript)))
381+ w.b = append(w.b, transcript...)
363382 w.putUint8(1)
364383 w.putUint16(0) // argv count
365384 _ = w.putString("generic")
366385 _ = w.putString("")
367- if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) {
386+ if _, err := Unmarshal(v4Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) {
368387 t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
369388 }
370389 }
@@ -376,12 +395,12 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
376395 }
377396 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
378397 requireNoError(t, err)
379- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
398+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
380399
381400 const paneCount = maxSnapshotRows/256 + 1
382401 tabs := make([]Tab, paneCount)
383402 for i := range tabs {
384- tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}
403+ tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Transcript: transcript}}}
385404 }
386405 data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs})
387406 requireNoError(t, err)
@@ -407,15 +426,15 @@ func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) {
407426 }
408427
409428 func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) {
410- body := v3WideTreeManifest(32_769)
429+ body := v4WideTreeManifest(32_769)
411430 if err := preflightSession(body); !errors.Is(err, ErrInvalidData) {
412431 t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData)
413432 }
414433 }
415434
416435 func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) {
417- body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)
418- reject(t, v3Envelope(version, 0, body), ErrInvalidData)
436+ body := v4Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)
437+ reject(t, v4Envelope(version, 0, body), ErrInvalidData)
419438 }
420439
421440 func mustHistoryTail(t *testing.T) []byte {
@@ -443,26 +462,22 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
443462 }
444463 return sealed, tail
445464 }
446-func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte {
465+func transcriptBlob(t *testing.T, rows [][]renderer.Cell) []byte {
447466 t.Helper()
448- width := 0
467+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: max(1, len(rows)), ChunkRows: 2})
449468 for _, row := range rows {
450- width = max(width, len(row))
469+ requireNoError(t, history.Append(row, vt.LineBound{End: len(row)}))
451470 }
452- f := renderer.NewFrame(width, len(rows))
453- for y, row := range rows {
454- copy(f.Row(y), row)
455- }
456- b, err := vt.MarshalVisible(f)
471+ blob, err := vt.MarshalHistory(history.SealAndView())
457472 if err != nil {
458473 t.Fatal(err)
459474 }
460- return b
475+ return blob
461476 }
462-func requireV3Envelope(t *testing.T, data []byte) {
477+func requireV4Envelope(t *testing.T, data []byte) {
463478 t.Helper()
464- if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 {
465- t.Fatalf("not v3 envelope: % x", data)
479+ if version != 4 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 4 || binary.BigEndian.Uint16(data[6:8]) != 0 {
480+ t.Fatalf("not v4 envelope: % x", data)
466481 }
467482 }
468483 func reject(t *testing.T, data []byte, want error) {
@@ -480,10 +495,10 @@ func reject(t *testing.T, data []byte, want error) {
480495 t.Fatalf("error = %v, want %v", err, want)
481496 }
482497 }
483-func v3Envelope(v, flags uint16, body []byte) []byte {
484- return v3EnvelopeWithLength(v, flags, body, uint32(len(body)))
498+func v4Envelope(v, flags uint16, body []byte) []byte {
499+ return v4EnvelopeWithLength(v, flags, body, uint32(len(body)))
485500 }
486-func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
501+func v4EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
487502 out := make([]byte, 16+len(body))
488503 copy(out, magic)
489504 binary.BigEndian.PutUint16(out[4:6], v)
@@ -494,7 +509,7 @@ func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
494509 return out
495510 }
496511
497-func v3ActiveManifest(active, tabs uint16) []byte {
512+func v4ActiveManifest(active, tabs uint16) []byte {
498513 var b []byte
499514 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
500515 putS("s")
@@ -511,10 +526,10 @@ func v3ActiveManifest(active, tabs uint16) []byte {
511526 return b
512527 }
513528
514-// v3Manifest creates just enough session/tab/pane framing for hostile declarations.
515-// v3WideTreeManifest declares a broad but compact tree without allocating a
529+// v4Manifest creates just enough session/tab/pane framing for hostile declarations.
530+// v4WideTreeManifest declares a broad but compact tree without allocating a
516531 // matching layout.Node graph. It exercises preflight's manifest budget.
517-func v3WideTreeManifest(children uint16) []byte {
532+func v4WideTreeManifest(children uint16) []byte {
518533 var b []byte
519534 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
520535 putS("s")
@@ -533,7 +548,7 @@ func v3WideTreeManifest(children uint16) []byte {
533548 return b
534549 }
535550
536-func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte {
551+func v4Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, transcriptLen uint32, transcriptData []byte) []byte {
537552 var b []byte
538553 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
539554 putS("s")
@@ -555,7 +570,7 @@ func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen u
555570 b = append(b, sealedData...)
556571 b = binary.BigEndian.AppendUint32(b, tailLen)
557572 b = append(b, tailData...)
558- b = binary.BigEndian.AppendUint32(b, visibleLen)
559- b = append(b, visibleData...)
573+ b = binary.BigEndian.AppendUint32(b, transcriptLen)
574+ b = append(b, transcriptData...)
560575 return b
561576 }
@@ -43,7 +43,7 @@ func TestPayloadReaderGetBytes(t *testing.T) {
43 }43 }
44 }44 }
45 45
46-func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {46+func TestV4SnapshotRoundTripPreservesCanonicalTerminalData(t *testing.T) {
47 indexed := renderer.DefaultStyle()47 indexed := renderer.DefaultStyle()
48 indexed.Bold, indexed.Inverse = true, true48 indexed.Bold, indexed.Inverse = true, true
49 indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough49 indexed.Attrs = renderer.AttrDim | renderer.AttrUnderline | renderer.AttrBlink | renderer.AttrStrikethrough
@@ -54,18 +54,18 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
54 rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6}54 rgb.HasBackgroundRGB, rgb.BackgroundRGB = true, renderer.RGB{R: 4, G: 5, B: 6}
55 rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}55 rgb.UnderlineStyle, rgb.HasUnderlineColorRGB, rgb.UnderlineColorRGB = renderer.UnderlineDashed, true, renderer.RGB{R: 7, G: 8, B: 9}
56 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})56 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'I', Style: indexed}, {Rune: '好', Style: rgb}, {Continuation: true, Style: rgb}}})
57- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})57+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'V', Style: rgb}, {Rune: '界', Style: indexed}, {Continuation: true, Style: indexed}}})
58 sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}})58 sealed2, tail2 := historyBlobs(t, [][]renderer.Cell{{{Rune: 'S', Style: rgb}}, {{Rune: '2', Style: indexed}}})
59- visible2 := visibleBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}})59+ transcript2 := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'P', Style: indexed}, {Rune: '2', Style: rgb}}})
60- want := Session{Name: "v3 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{60+ want := Session{Name: "v4 exact", Tabs: []Tab{{Focus: "p", Tree: &layout.Tree{Focus: "p", Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf("p"), layout.NewLeaf("p2")}}}, Panes: []Pane{
61- {ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible},61+ {ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript},
62- {ID: "p2", SealedChunks: sealed2, Tail: tail2, Visible: visible2},62+ {ID: "p2", SealedChunks: sealed2, Tail: tail2, Transcript: transcript2},
63 }}}}63 }}}}
64 encoded, err := marshalTest(want)64 encoded, err := marshalTest(want)
65 if err != nil {65 if err != nil {
66 t.Fatal(err)66 t.Fatal(err)
67 }67 }
68- requireV3Envelope(t, encoded)68+ requireV4Envelope(t, encoded)
69 got, err := Unmarshal(encoded)69 got, err := Unmarshal(encoded)
70 if err != nil {70 if err != nil {
71 t.Fatal(err)71 t.Fatal(err)
@@ -86,19 +86,19 @@ func TestV3SnapshotRoundTripPreservesExactTerminalData(t *testing.T) {
86 if !bytes.Equal(gotPane.Tail, wantPane.Tail) {86 if !bytes.Equal(gotPane.Tail, wantPane.Tail) {
87 t.Fatalf("pane %q tail changed during round trip", wantPane.ID)87 t.Fatalf("pane %q tail changed during round trip", wantPane.ID)
88 }88 }
89- if !bytes.Equal(gotPane.Visible, wantPane.Visible) {89+ if !bytes.Equal(gotPane.Transcript, wantPane.Transcript) {
90- t.Fatalf("pane %q visible blob changed during round trip", wantPane.ID)90+ t.Fatalf("pane %q transcript blob changed during round trip", wantPane.ID)
91 }91 }
92 }92 }
93 if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {93 if _, err := vt.HistoryFromBlobs(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2}, got.Tabs[0].Panes[0].SealedChunks, got.Tabs[0].Panes[0].Tail); err != nil {
94 t.Fatalf("history decode: %v", err)94 t.Fatalf("history decode: %v", err)
95 }95 }
96- frame, err := vt.UnmarshalVisible(got.Tabs[0].Panes[0].Visible)96+ view, err := vt.UnmarshalHistory(got.Tabs[0].Panes[0].Transcript)
97 if err != nil {97 if err != nil {
98 t.Fatal(err)98 t.Fatal(err)
99 }99 }
100- if !frame.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !frame.Row(0)[2].Continuation {100+ if !view.Row(0)[0].Equal(renderer.Cell{Rune: 'V', Style: rgb}) || !view.Row(0)[2].Continuation {
101- t.Fatalf("visible terminal data lost: %#v", frame.Row(0))101+ t.Fatalf("transcript terminal data lost: %#v", view.Row(0))
102 }102 }
103 }103 }
104 104
@@ -130,26 +130,26 @@ func TestActiveTabReferenceMustBeExact(t *testing.T) {
130 name string130 name string
131 data []byte131 data []byte
132 }{132 }{
133- {name: "zero tabs active nonzero", data: v3ActiveManifest(1, 0)},133+ {name: "zero tabs active nonzero", data: v4ActiveManifest(1, 0)},
134- {name: "nonempty tabs active past end", data: v3ActiveManifest(1, 1)},134+ {name: "nonempty tabs active past end", data: v4ActiveManifest(1, 1)},
135 } {135 } {
136 t.Run("unmarshal "+tc.name, func(t *testing.T) {136 t.Run("unmarshal "+tc.name, func(t *testing.T) {
137- reject(t, v3Envelope(version, 0, tc.data), ErrInvalidData)137+ reject(t, v4Envelope(version, 0, tc.data), ErrInvalidData)
138 })138 })
139 }139 }
140 }140 }
141 141
142-func TestUnmarshalRejectsV3LegacyVersions(t *testing.T) {142+func TestUnmarshalRejectsV4LegacyVersions(t *testing.T) {
143- for _, version := range []uint16{1, 2} {143+ for _, version := range []uint16{1, 2, 3} {
144- if _, err := Unmarshal(v3Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {144+ if _, err := Unmarshal(v4Envelope(version, 0, nil)); !errors.Is(err, ErrBadVersion) {
145 t.Fatalf("version %d: %v", version, err)145 t.Fatalf("version %d: %v", version, err)
146 }146 }
147 }147 }
148 }148 }
149 149
150-func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {150+func TestUnmarshalRejectsMalformedV4EnvelopeBeforeAllocation(t *testing.T) {
151- body := v3Manifest(0, nil, nil, 0, nil, 0, nil)151+ body := v4Manifest(0, nil, nil, 0, nil, 0, nil)
152- valid := v3Envelope(version, 0, body)152+ valid := v4Envelope(version, 0, body)
153 badCRC := append([]byte(nil), valid...)153 badCRC := append([]byte(nil), valid...)
154 badCRC[15] ^= 1154 badCRC[15] ^= 1
155 for _, tc := range []struct {155 for _, tc := range []struct {
@@ -158,31 +158,31 @@ func TestUnmarshalRejectsMalformedV3EnvelopeBeforeAllocation(t *testing.T) {
158 want error158 want error
159 }{159 }{
160 {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic},160 {"short", valid[:15], ErrShortPayload}, {"magic", append([]byte("NOPE"), valid[4:]...), ErrBadMagic},
161- {"future", v3Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v3Envelope(version, 1, body), ErrInvalidData},161+ {"future", v4Envelope(version+1, 0, body), ErrBadVersion}, {"flags", v4Envelope(version, 1, body), ErrInvalidData},
162- {"crc", badCRC, ErrBadCRC}, {"body", v3EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData},162+ {"crc", badCRC, ErrBadCRC}, {"body", v4EnvelopeWithLength(version, 0, nil, math.MaxUint32), ErrInvalidData},
163 } {163 } {
164 t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) })164 t.Run(tc.name, func(t *testing.T) { reject(t, tc.data, tc.want) })
165 }165 }
166 }166 }
167 167
168-func TestUnmarshalRejectsHostileV3ManifestDeclarationsBeforeAllocation(t *testing.T) {168+func TestUnmarshalRejectsHostileV4ManifestDeclarationsBeforeAllocation(t *testing.T) {
169 for _, tc := range []struct {169 for _, tc := range []struct {
170 name string170 name string
171 body []byte171 body []byte
172 }{172 }{
173- {"count", v3Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},173+ {"count", v4Manifest(math.MaxUint32, nil, nil, 0, nil, 0, nil)},
174- {"sealed", v3Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},174+ {"sealed", v4Manifest(1, []uint32{math.MaxUint32}, nil, 0, nil, 0, nil)},
175- {"tail", v3Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},175+ {"tail", v4Manifest(0, nil, nil, math.MaxUint32, nil, 0, nil)},
176- {"visible", v3Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},176+ {"transcript", v4Manifest(0, nil, nil, 0, nil, math.MaxUint32, nil)},
177 } {177 } {
178- t.Run(tc.name, func(t *testing.T) { reject(t, v3Envelope(version, 0, tc.body), ErrInvalidData) })178+ t.Run(tc.name, func(t *testing.T) { reject(t, v4Envelope(version, 0, tc.body), ErrInvalidData) })
179 }179 }
180 }180 }
181 181
182-func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {182+func TestUnmarshalRejectsEveryV4PrefixAndTrailingGarbage(t *testing.T) {
183 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})183 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}})
184- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})184+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
185- encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})185+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "p", Tree: layout.NewTree("p"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}}})
186 if err != nil {186 if err != nil {
187 t.Fatal(err)187 t.Fatal(err)
188 }188 }
@@ -194,13 +194,13 @@ func TestUnmarshalRejectsEveryV3PrefixAndTrailingGarbage(t *testing.T) {
194 194
195 func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {195 func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
196 sealed, tail := historyBlobs(t, nil)196 sealed, tail := historyBlobs(t, nil)
197- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})197+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
198 for _, tc := range []struct {198 for _, tc := range []struct {
199 name string199 name string
200 panes []Pane200 panes []Pane
201 }{201 }{
202- {name: "empty", panes: []Pane{{ID: "", Tail: tail, Visible: visible}}},202+ {name: "empty", panes: []Pane{{ID: "", Tail: tail, Transcript: transcript}}},
203- {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}, {ID: "p", Tail: tail, Visible: visible}}},203+ {name: "duplicate", panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}, {ID: "p", Tail: tail, Transcript: transcript}}},
204 } {204 } {
205 t.Run(tc.name, func(t *testing.T) {205 t.Run(tc.name, func(t *testing.T) {
206 data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})206 data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Panes: tc.panes}}})
@@ -212,8 +212,8 @@ func TestUnmarshalRejectsEmptyAndDuplicatePaneIDsDuringPreflight(t *testing.T) {
212 212
213 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {213 func TestUnmarshalRejectsInvalidTreeReference(t *testing.T) {
214 sealed, tail := historyBlobs(t, nil)214 sealed, tail := historyBlobs(t, nil)
215- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})215+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
216- encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Visible: visible}}}}})216+ encoded, err := marshalTest(Session{Name: "p", Tabs: []Tab{{Focus: "missing", Tree: layout.NewTree("missing"), Panes: []Pane{{ID: "p", SealedChunks: sealed, Tail: tail, Transcript: transcript}}}}})
217 if err != nil {217 if err != nil {
218 t.Fatal(err)218 t.Fatal(err)
219 }219 }
@@ -231,12 +231,12 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
231 multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2)231 multiChunk = binary.BigEndian.AppendUint32(multiChunk, 2)
232 multiChunk = append(multiChunk, sealed[0][9:]...)232 multiChunk = append(multiChunk, sealed[0][9:]...)
233 multiChunk = append(multiChunk, sealed[0][9:]...)233 multiChunk = append(multiChunk, sealed[0][9:]...)
234- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})234+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
235- emptyVisible := visibleBlob(t, nil)235+ emptyTranscript := transcriptBlob(t, nil)
236 236
237 // Incremental captures retain one copied mutable tail chunk instead of237 // Incremental captures retain one copied mutable tail chunk instead of
238 // rotating it into the sealed history set. That one chunk is canonical and238 // rotating it into the sealed history set. That one chunk is canonical and
239- // must remain a valid v3 import payload.239+ // must remain a valid v4 import payload.
240 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})240 mutableHistory := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, ChunkRows: 2})
241 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {241 for _, row := range [][]renderer.Cell{{{Rune: 'a'}}, {{Rune: 'b'}}, {{Rune: 'c'}}} {
242 requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)}))242 requireNoError(t, mutableHistory.Append(row, vt.LineBound{End: len(row)}))
@@ -245,22 +245,32 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
245 requireNoError(t, err)245 requireNoError(t, err)
246 fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView())246 fullHistory, err := vt.MarshalHistory(mutableHistory.SealAndView())
247 requireNoError(t, err)247 requireNoError(t, err)
248- valid, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Visible: visible}}}}})248+ for _, tc := range []struct {
249- requireNoError(t, err)249+ name string
250- roundTrip, err := Unmarshal(valid)250+ transcript []byte
251- requireNoError(t, err)251+ }{
252- if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) {252+ {name: "single chunk", transcript: transcript},
253- t.Fatal("mutable tail did not round trip")253+ {name: "empty", transcript: emptyTranscript},
254+ {name: "multiple chunks", transcript: multiChunk},
255+ } {
256+ t.Run("valid "+tc.name, func(t *testing.T) {
257+ data, err := marshalTest(Session{Name: "s", Tabs: []Tab{{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: sealed[:1], Tail: mutableTail, Transcript: tc.transcript}}}}})
258+ requireNoError(t, err)
259+ roundTrip, err := Unmarshal(data)
260+ requireNoError(t, err)
261+ if got := roundTrip.Tabs[0].Panes[0].Tail; !bytes.Equal(got, mutableTail) {
262+ t.Fatal("mutable tail did not round trip")
263+ }
264+ })
254 }265 }
255 266
256 for _, tc := range []struct {267 for _, tc := range []struct {
257 name string268 name string
258 tab Tab269 tab Tab
259 }{270 }{
260- {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Visible: visible}}}},271+ {name: "sealed multi-chunk", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", SealedChunks: [][]byte{multiChunk}, Tail: emptyTail, Transcript: transcript}}}},
261- {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Visible: visible}}}},272+ {name: "tail has multiple chunks", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: multiChunk, Transcript: transcript}}}},
262- {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Visible: visible}}}},273+ {name: "noncanonical full history tail", tab: Tab{Cols: 1, Rows: 1, Panes: []Pane{{ID: "p", Tail: fullHistory, Transcript: transcript}}}},
263- {name: "empty visible geometry", tab: Tab{Panes: []Pane{{ID: "p", Tail: emptyTail, Visible: emptyVisible}}}},
264 } {274 } {
265 t.Run(tc.name, func(t *testing.T) {275 t.Run(tc.name, func(t *testing.T) {
266 data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})276 data, err := marshalTest(Session{Name: "s", Tabs: []Tab{tc.tab}})
@@ -270,13 +280,22 @@ func TestUnmarshalEnforcesCanonicalHistoryBlobRoles(t *testing.T) {
270 }280 }
271 }281 }
272 282
273-func TestUnmarshalRejectsMalformedTerminalBlobWithoutPanic(t *testing.T) {283+func TestUnmarshalRejectsMalformedCanonicalVTBlobsWithoutPanic(t *testing.T) {
274 tail := mustHistoryTail(t)284 tail := mustHistoryTail(t)
275- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})285+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
276- // The visible cell's underline-style byte is outside the renderer enum.286+ for _, tc := range []struct {
277- visible[13+29] = 0xff287+ name string
278- body := v3Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(visible)), visible)288+ blob []byte
279- reject(t, v3Envelope(version, 0, body), ErrInvalidData)289+ }{
290+ {name: "malformed", blob: append([]byte("NOPE"), transcript[4:]...)},
291+ {name: "truncated", blob: transcript[:len(transcript)-1]},
292+ {name: "trailing", blob: append(append([]byte(nil), transcript...), 0)},
293+ } {
294+ t.Run(tc.name, func(t *testing.T) {
295+ body := v4Manifest(0, nil, nil, uint32(len(tail)), tail, uint32(len(tc.blob)), tc.blob)
296+ reject(t, v4Envelope(version, 0, body), ErrInvalidData)
297+ })
298+ }
280 }299 }
281 300
282 func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {301 func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
@@ -301,7 +320,7 @@ func TestRoundTripNilTreeAndRootDoNotMaterializeLeaf(t *testing.T) {
301 320
302 func TestRoundTripSessionMetadataAndProcess(t *testing.T) {321 func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
303 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}})322 sealed, tail := historyBlobs(t, [][]renderer.Cell{{{Rune: 'h'}}, {{Rune: 'i'}}})
304- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})323+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
305 want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{324 want := Session{Name: "named", CreatedAt: 42, Active: 1, Tabs: []Tab{
306 {325 {
307 StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2",326 StableID: "t1", Cols: 100, Rows: 40, NextPaneID: 9, Focus: "2",
@@ -309,11 +328,11 @@ func TestRoundTripSessionMetadataAndProcess(t *testing.T) {
309 layout.NewLeaf("1"), layout.NewLeaf("2"),328 layout.NewLeaf("1"), layout.NewLeaf("2"),
310 }}},329 }}},
311 Panes: []Pane{330 Panes: []Pane{
312- {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Visible: visible, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}},331+ {ID: "1", StableID: "p1", Cwd: "/one", SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: &Process{Argv: []string{"pi", "--resume"}, Strategy: "pi", Opts: ProcessOpts{AgentSessionID: "agent-123"}}},
313- {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Visible: visible},332+ {ID: "2", StableID: "p2", Cwd: "/two", SealedChunks: sealed, Tail: tail, Transcript: transcript},
314 },333 },
315 },334 },
316- {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Visible: visible}}},335+ {StableID: "t2", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "a", Tree: layout.NewTree("a"), Panes: []Pane{{ID: "a", Cwd: "/tmp", SealedChunks: sealed, Tail: tail, Transcript: transcript}}},
317 }}336 }}
318 data, err := marshalTest(want)337 data, err := marshalTest(want)
319 requireNoError(t, err)338 requireNoError(t, err)
@@ -339,7 +358,7 @@ func TestMarshalRejectsProcessWithoutArgv(t *testing.T) {
339 358
340 func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {359 func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
341 tail := mustHistoryTail(t)360 tail := mustHistoryTail(t)
342- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})361+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
343 var w payloadWriter362 var w payloadWriter
344 _ = w.putString("s")363 _ = w.putString("s")
345 w.putUint64(0)364 w.putUint64(0)
@@ -358,13 +377,13 @@ func TestUnmarshalRejectsProcessWithoutArgv(t *testing.T) {
358 w.putUint32(0)377 w.putUint32(0)
359 w.putUint32(uint32(len(tail)))378 w.putUint32(uint32(len(tail)))
360 w.b = append(w.b, tail...)379 w.b = append(w.b, tail...)
361- w.putUint32(uint32(len(visible)))380+ w.putUint32(uint32(len(transcript)))
362- w.b = append(w.b, visible...)381+ w.b = append(w.b, transcript...)
363 w.putUint8(1)382 w.putUint8(1)
364 w.putUint16(0) // argv count383 w.putUint16(0) // argv count
365 _ = w.putString("generic")384 _ = w.putString("generic")
366 _ = w.putString("")385 _ = w.putString("")
367- if _, err := Unmarshal(v3Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) {386+ if _, err := Unmarshal(v4Envelope(version, 0, w.b)); !errors.Is(err, ErrInvalidData) {
368 t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)387 t.Fatalf("Unmarshal() error = %v, want %v", err, ErrInvalidData)
369 }388 }
370 }389 }
@@ -376,12 +395,12 @@ func TestUnmarshalGlobalBudgetRejectionDoesNotAllocatePerPane(t *testing.T) {
376 }395 }
377 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())396 sealed, tail, err := vt.MarshalSealedHistory(history.SealAndView())
378 requireNoError(t, err)397 requireNoError(t, err)
379- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})398+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
380 399
381 const paneCount = maxSnapshotRows/256 + 1400 const paneCount = maxSnapshotRows/256 + 1
382 tabs := make([]Tab, paneCount)401 tabs := make([]Tab, paneCount)
383 for i := range tabs {402 for i := range tabs {
384- tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Visible: visible}}}403+ tabs[i] = Tab{Panes: []Pane{{ID: layout.PaneID(strconv.Itoa(i)), SealedChunks: sealed, Tail: tail, Transcript: transcript}}}
385 }404 }
386 data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs})405 data, err := marshalTest(Session{Name: "over-budget", Tabs: tabs})
387 requireNoError(t, err)406 requireNoError(t, err)
@@ -407,15 +426,15 @@ func TestPreflightBudgetRejectsAggregateDecodedAllocation(t *testing.T) {
407 }426 }
408 427
409 func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) {428 func TestPreflightRejectsBroadTreeBeforeSecondPassAllocation(t *testing.T) {
410- body := v3WideTreeManifest(32_769)429+ body := v4WideTreeManifest(32_769)
411 if err := preflightSession(body); !errors.Is(err, ErrInvalidData) {430 if err := preflightSession(body); !errors.Is(err, ErrInvalidData) {
412 t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData)431 t.Fatalf("preflightSession() error = %v, want %v", err, ErrInvalidData)
413 }432 }
414 }433 }
415 434
416 func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) {435 func TestUnmarshalRejectsAggregateOpaqueBlobDeclarations(t *testing.T) {
417- body := v3Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)436+ body := v4Manifest(math.MaxUint32, nil, nil, math.MaxUint32, nil, math.MaxUint32, nil)
418- reject(t, v3Envelope(version, 0, body), ErrInvalidData)437+ reject(t, v4Envelope(version, 0, body), ErrInvalidData)
419 }438 }
420 439
421 func mustHistoryTail(t *testing.T) []byte {440 func mustHistoryTail(t *testing.T) []byte {
@@ -443,26 +462,22 @@ func historyBlobs(t *testing.T, rows [][]renderer.Cell) ([][]byte, []byte) {
443 }462 }
444 return sealed, tail463 return sealed, tail
445 }464 }
446-func visibleBlob(t *testing.T, rows [][]renderer.Cell) []byte {465+func transcriptBlob(t *testing.T, rows [][]renderer.Cell) []byte {
447 t.Helper()466 t.Helper()
448- width := 0467+ history := vt.NewHistory(vt.HistoryConfig{MaxRows: max(1, len(rows)), ChunkRows: 2})
449 for _, row := range rows {468 for _, row := range rows {
450- width = max(width, len(row))469+ requireNoError(t, history.Append(row, vt.LineBound{End: len(row)}))
451 }470 }
452- f := renderer.NewFrame(width, len(rows))471+ blob, err := vt.MarshalHistory(history.SealAndView())
453- for y, row := range rows {
454- copy(f.Row(y), row)
455- }
456- b, err := vt.MarshalVisible(f)
457 if err != nil {472 if err != nil {
458 t.Fatal(err)473 t.Fatal(err)
459 }474 }
460- return b475+ return blob
461 }476 }
462-func requireV3Envelope(t *testing.T, data []byte) {477+func requireV4Envelope(t *testing.T, data []byte) {
463 t.Helper()478 t.Helper()
464- if len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != version || binary.BigEndian.Uint16(data[6:8]) != 0 {479+ if version != 4 || len(data) < 16 || binary.BigEndian.Uint16(data[4:6]) != 4 || binary.BigEndian.Uint16(data[6:8]) != 0 {
465- t.Fatalf("not v3 envelope: % x", data)480+ t.Fatalf("not v4 envelope: % x", data)
466 }481 }
467 }482 }
468 func reject(t *testing.T, data []byte, want error) {483 func reject(t *testing.T, data []byte, want error) {
@@ -480,10 +495,10 @@ func reject(t *testing.T, data []byte, want error) {
480 t.Fatalf("error = %v, want %v", err, want)495 t.Fatalf("error = %v, want %v", err, want)
481 }496 }
482 }497 }
483-func v3Envelope(v, flags uint16, body []byte) []byte {498+func v4Envelope(v, flags uint16, body []byte) []byte {
484- return v3EnvelopeWithLength(v, flags, body, uint32(len(body)))499+ return v4EnvelopeWithLength(v, flags, body, uint32(len(body)))
485 }500 }
486-func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {501+func v4EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
487 out := make([]byte, 16+len(body))502 out := make([]byte, 16+len(body))
488 copy(out, magic)503 copy(out, magic)
489 binary.BigEndian.PutUint16(out[4:6], v)504 binary.BigEndian.PutUint16(out[4:6], v)
@@ -494,7 +509,7 @@ func v3EnvelopeWithLength(v, flags uint16, body []byte, length uint32) []byte {
494 return out509 return out
495 }510 }
496 511
497-func v3ActiveManifest(active, tabs uint16) []byte {512+func v4ActiveManifest(active, tabs uint16) []byte {
498 var b []byte513 var b []byte
499 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }514 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
500 putS("s")515 putS("s")
@@ -511,10 +526,10 @@ func v3ActiveManifest(active, tabs uint16) []byte {
511 return b526 return b
512 }527 }
513 528
514-// v3Manifest creates just enough session/tab/pane framing for hostile declarations.529+// v4Manifest creates just enough session/tab/pane framing for hostile declarations.
515-// v3WideTreeManifest declares a broad but compact tree without allocating a530+// v4WideTreeManifest declares a broad but compact tree without allocating a
516 // matching layout.Node graph. It exercises preflight's manifest budget.531 // matching layout.Node graph. It exercises preflight's manifest budget.
517-func v3WideTreeManifest(children uint16) []byte {532+func v4WideTreeManifest(children uint16) []byte {
518 var b []byte533 var b []byte
519 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }534 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
520 putS("s")535 putS("s")
@@ -533,7 +548,7 @@ func v3WideTreeManifest(children uint16) []byte {
533 return b548 return b
534 }549 }
535 550
536-func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, visibleLen uint32, visibleData []byte) []byte {551+func v4Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen uint32, tailData []byte, transcriptLen uint32, transcriptData []byte) []byte {
537 var b []byte552 var b []byte
538 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }553 putS := func(s string) { b = binary.BigEndian.AppendUint16(b, uint16(len(s))); b = append(b, s...) }
539 putS("s")554 putS("s")
@@ -555,7 +570,7 @@ func v3Manifest(sealed uint32, sealedLens []uint32, sealedData []byte, tailLen u
555 b = append(b, sealedData...)570 b = append(b, sealedData...)
556 b = binary.BigEndian.AppendUint32(b, tailLen)571 b = binary.BigEndian.AppendUint32(b, tailLen)
557 b = append(b, tailData...)572 b = append(b, tailData...)
558- b = binary.BigEndian.AppendUint32(b, visibleLen)573+ b = binary.BigEndian.AppendUint32(b, transcriptLen)
559- b = append(b, visibleData...)574+ b = append(b, transcriptData...)
560 return b575 return b
561 }576 }
M internal/usecase/snapshot/manifest.go +13 -13
@@ -16,7 +16,7 @@ const (
1616 manifestMagic = "VEVM"
1717 headMagic = "VEVH"
1818 objectMagic = "VEVO"
19- ManifestVersion = uint16(2)
19+ ManifestVersion = uint16(3)
2020 manifestHeaderSize = 16
2121 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
2222 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
@@ -48,7 +48,7 @@ type ObjectKind uint8
4848 const (
4949 HistoryChunk ObjectKind = iota + 1
5050 HistoryTail
51- Visible
51+ RecoveryTranscript
5252 )
5353
5454 type ObjectRef struct {
@@ -117,13 +117,13 @@ type ManifestTab struct {
117117 }
118118
119119 type ManifestPane struct {
120- ID layout.PaneID
121- StableID string
122- Cwd string
123- Sealed []ObjectRef
124- Tail ObjectRef
125- Visible ObjectRef
126- Process *Process
120+ ID layout.PaneID
121+ StableID string
122+ Cwd string
123+ Sealed []ObjectRef
124+ Tail ObjectRef
125+ Transcript ObjectRef
126+ Process *Process
127127 }
128128
129129 func MarshalManifest(m Manifest) ([]byte, error) {
@@ -479,7 +479,7 @@ func writeManifestPane(w *payloadWriter, pane ManifestPane) error {
479479 writeObjectRef(w, ref)
480480 }
481481 writeObjectRef(w, pane.Tail)
482- writeObjectRef(w, pane.Visible)
482+ writeObjectRef(w, pane.Transcript)
483483 return writeProcess(w, pane.Process)
484484 }
485485 func writeObjectRef(w *payloadWriter, ref ObjectRef) {
@@ -551,7 +551,7 @@ func readManifestPane(r *payloadReader) (ManifestPane, error) {
551551 if pane.Tail, err = readObjectRef(r); err != nil {
552552 return ManifestPane{}, err
553553 }
554- if pane.Visible, err = readObjectRef(r); err != nil {
554+ if pane.Transcript, err = readObjectRef(r); err != nil {
555555 return ManifestPane{}, err
556556 }
557557 pane.Process, err = readProcess(r)
@@ -647,7 +647,7 @@ func validatePaneRefs(p ManifestPane) error {
647647 if err := validateObjectRef(p.Tail, HistoryTail); err != nil {
648648 return err
649649 }
650- return validateObjectRef(p.Visible, Visible)
650+ return validateObjectRef(p.Transcript, RecoveryTranscript)
651651 }
652652 func validateObjectRef(ref ObjectRef, want ObjectKind) error {
653653 return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want)
@@ -664,7 +664,7 @@ func validObjectEnvelopeSize(size uint32) bool {
664664 }
665665
666666 func validObjectKind(kind ObjectKind) bool {
667- return kind == HistoryChunk || kind == HistoryTail || kind == Visible
667+ return kind == HistoryChunk || kind == HistoryTail || kind == RecoveryTranscript
668668 }
669669 func isZeroDigestBytes(d []byte) bool {
670670 for _, b := range d {
@@ -16,7 +16,7 @@ const (
16 manifestMagic = "VEVM"16 manifestMagic = "VEVM"
17 headMagic = "VEVH"17 headMagic = "VEVH"
18 objectMagic = "VEVO"18 objectMagic = "VEVO"
19- ManifestVersion = uint16(2)19+ ManifestVersion = uint16(3)
20 manifestHeaderSize = 1620 manifestHeaderSize = 16
21 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length21 objectEnvelopeBodyPrefixSize = 1 + 4 // object kind and payload length
22 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 122 minObjectEnvelopeSize = manifestHeaderSize + objectEnvelopeBodyPrefixSize + 1
@@ -48,7 +48,7 @@ type ObjectKind uint8
48 const (48 const (
49 HistoryChunk ObjectKind = iota + 149 HistoryChunk ObjectKind = iota + 1
50 HistoryTail50 HistoryTail
51- Visible51+ RecoveryTranscript
52 )52 )
53 53
54 type ObjectRef struct {54 type ObjectRef struct {
@@ -117,13 +117,13 @@ type ManifestTab struct {
117 }117 }
118 118
119 type ManifestPane struct {119 type ManifestPane struct {
120- ID layout.PaneID120+ ID layout.PaneID
121- StableID string121+ StableID string
122- Cwd string122+ Cwd string
123- Sealed []ObjectRef123+ Sealed []ObjectRef
124- Tail ObjectRef124+ Tail ObjectRef
125- Visible ObjectRef125+ Transcript ObjectRef
126- Process *Process126+ Process *Process
127 }127 }
128 128
129 func MarshalManifest(m Manifest) ([]byte, error) {129 func MarshalManifest(m Manifest) ([]byte, error) {
@@ -479,7 +479,7 @@ func writeManifestPane(w *payloadWriter, pane ManifestPane) error {
479 writeObjectRef(w, ref)479 writeObjectRef(w, ref)
480 }480 }
481 writeObjectRef(w, pane.Tail)481 writeObjectRef(w, pane.Tail)
482- writeObjectRef(w, pane.Visible)482+ writeObjectRef(w, pane.Transcript)
483 return writeProcess(w, pane.Process)483 return writeProcess(w, pane.Process)
484 }484 }
485 func writeObjectRef(w *payloadWriter, ref ObjectRef) {485 func writeObjectRef(w *payloadWriter, ref ObjectRef) {
@@ -551,7 +551,7 @@ func readManifestPane(r *payloadReader) (ManifestPane, error) {
551 if pane.Tail, err = readObjectRef(r); err != nil {551 if pane.Tail, err = readObjectRef(r); err != nil {
552 return ManifestPane{}, err552 return ManifestPane{}, err
553 }553 }
554- if pane.Visible, err = readObjectRef(r); err != nil {554+ if pane.Transcript, err = readObjectRef(r); err != nil {
555 return ManifestPane{}, err555 return ManifestPane{}, err
556 }556 }
557 pane.Process, err = readProcess(r)557 pane.Process, err = readProcess(r)
@@ -647,7 +647,7 @@ func validatePaneRefs(p ManifestPane) error {
647 if err := validateObjectRef(p.Tail, HistoryTail); err != nil {647 if err := validateObjectRef(p.Tail, HistoryTail); err != nil {
648 return err648 return err
649 }649 }
650- return validateObjectRef(p.Visible, Visible)650+ return validateObjectRef(p.Transcript, RecoveryTranscript)
651 }651 }
652 func validateObjectRef(ref ObjectRef, want ObjectKind) error {652 func validateObjectRef(ref ObjectRef, want ObjectKind) error {
653 return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want)653 return validateObjectRefFields(ref.Kind, ref.Digest[:], ref.Size, want)
@@ -664,7 +664,7 @@ func validObjectEnvelopeSize(size uint32) bool {
664 }664 }
665 665
666 func validObjectKind(kind ObjectKind) bool {666 func validObjectKind(kind ObjectKind) bool {
667- return kind == HistoryChunk || kind == HistoryTail || kind == Visible667+ return kind == HistoryChunk || kind == HistoryTail || kind == RecoveryTranscript
668 }668 }
669 func isZeroDigestBytes(d []byte) bool {669 func isZeroDigestBytes(d []byte) bool {
670 for _, b := range d {670 for _, b := range d {
M internal/usecase/snapshot/manifest_preflight.go +1 -1
@@ -269,7 +269,7 @@ func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (l
269269 if err := preflightObjectRef(r, HistoryTail); err != nil {
270270 return "", err
271271 }
272- if err := preflightObjectRef(r, Visible); err != nil {
272+ if err := preflightObjectRef(r, RecoveryTranscript); err != nil {
273273 return "", err
274274 }
275275 if err := preflightManifestProcess(r, budget); err != nil {
@@ -269,7 +269,7 @@ func preflightManifestPane(r *payloadReader, budget *manifestPreflightBudget) (l
269 if err := preflightObjectRef(r, HistoryTail); err != nil {269 if err := preflightObjectRef(r, HistoryTail); err != nil {
270 return "", err270 return "", err
271 }271 }
272- if err := preflightObjectRef(r, Visible); err != nil {272+ if err := preflightObjectRef(r, RecoveryTranscript); err != nil {
273 return "", err273 return "", err
274 }274 }
275 if err := preflightManifestProcess(r, budget); err != nil {275 if err := preflightManifestProcess(r, budget); err != nil {
M internal/usecase/snapshot/manifest_preflight_test.go +5 -5
@@ -140,9 +140,9 @@ func TestManifestObjectRefKinds(t *testing.T) {
140140 }{
141141 {"chunk", HistoryChunk, HistoryChunk, true},
142142 {"tail", HistoryTail, HistoryTail, true},
143- {"visible", Visible, Visible, true},
144- {"invalid", ObjectKind(99), Visible, false},
145- {"wrong role", HistoryTail, Visible, false},
143+ {"transcript", RecoveryTranscript, RecoveryTranscript, true},
144+ {"invalid", ObjectKind(99), RecoveryTranscript, false},
145+ {"wrong role", HistoryTail, RecoveryTranscript, false},
146146 } {
147147 t.Run(tc.name, func(t *testing.T) {
148148 var w payloadWriter
@@ -230,8 +230,8 @@ func testManifest(tree *layout.Tree, process *Process) Manifest {
230230 return Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{
231231 StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree,
232232 Panes: []ManifestPane{
233- {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process},
234- {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}},
233+ {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process},
234+ {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}},
235235 },
236236 }}}
237237 }
@@ -140,9 +140,9 @@ func TestManifestObjectRefKinds(t *testing.T) {
140 }{140 }{
141 {"chunk", HistoryChunk, HistoryChunk, true},141 {"chunk", HistoryChunk, HistoryChunk, true},
142 {"tail", HistoryTail, HistoryTail, true},142 {"tail", HistoryTail, HistoryTail, true},
143- {"visible", Visible, Visible, true},143+ {"transcript", RecoveryTranscript, RecoveryTranscript, true},
144- {"invalid", ObjectKind(99), Visible, false},144+ {"invalid", ObjectKind(99), RecoveryTranscript, false},
145- {"wrong role", HistoryTail, Visible, false},145+ {"wrong role", HistoryTail, RecoveryTranscript, false},
146 } {146 } {
147 t.Run(tc.name, func(t *testing.T) {147 t.Run(tc.name, func(t *testing.T) {
148 var w payloadWriter148 var w payloadWriter
@@ -230,8 +230,8 @@ func testManifest(tree *layout.Tree, process *Process) Manifest {
230 return Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{230 return Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{
231 StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree,231 StableID: "tab", Cols: 80, Rows: 24, Focus: "one", Tree: tree,
232 Panes: []ManifestPane{232 Panes: []ManifestPane{
233- {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process},233+ {ID: "one", StableID: "one", Cwd: "/one", Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: SnapshotDigest{1}, Size: minObjectEnvelopeSize}}, Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize}, Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{3}, Size: minObjectEnvelopeSize}, Process: process},
234- {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}},234+ {ID: "two", StableID: "two", Cwd: "/two", Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{4}, Size: minObjectEnvelopeSize}, Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{5}, Size: minObjectEnvelopeSize}},
235 },235 },
236 }}}236 }}}
237 }237 }
M internal/usecase/snapshot/manifest_test.go +15 -15
@@ -65,16 +65,16 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) {
6565 if err != nil {
6666 t.Fatal(err)
6767 }
68- visible, err := MarshalObject(Visible, []byte("visible"))
68+ transcript, err := MarshalObject(RecoveryTranscript, []byte("transcript"))
6969 if err != nil {
7070 t.Fatal(err)
7171 }
7272 manifest := Manifest{Generation: 7, IncarnationID: domain.IncarnationID{1}, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{
7373 StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"),
7474 Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp",
75- Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}},
76- Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))},
77- Visible: ObjectRef{Kind: Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))},
75+ Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}},
76+ Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))},
77+ Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))},
7878 }},
7979 }}}
8080 encoded, err := MarshalManifest(manifest)
@@ -104,7 +104,7 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) {
104104 }
105105
106106 func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
107- object, err := MarshalObject(Visible, []byte("canonical-vt"))
107+ object, err := MarshalObject(RecoveryTranscript, []byte("canonical-vt"))
108108 if err != nil {
109109 t.Fatal(err)
110110 }
@@ -112,11 +112,11 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
112112 t.Fatalf("digest does not address VEVO bytes")
113113 }
114114 kind, payload, err := UnmarshalObject(object.Data)
115- if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) {
115+ if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {
116116 t.Fatalf("round trip = %v %q %v", kind, payload, err)
117117 }
118118 kind, payload, err = PreflightObject(object.Data)
119- if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) {
119+ if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {
120120 t.Fatalf("preflight = %v %q %v", kind, payload, err)
121121 }
122122 for n := range len(object.Data) {
@@ -131,7 +131,7 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
131131
132132 func TestManifestDimensionBoundaries(t *testing.T) {
133133 ref := ObjectRef{Kind: HistoryTail, Digest: ports.SnapshotDigest{1}, Size: minObjectEnvelopeSize}
134- visible := ObjectRef{Kind: Visible, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize}
134+ transcript := ObjectRef{Kind: RecoveryTranscript, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize}
135135 for _, tc := range []struct {
136136 name string
137137 cols, rows uint16
@@ -142,7 +142,7 @@ func TestManifestDimensionBoundaries(t *testing.T) {
142142 {"hostile product", math.MaxUint16, math.MaxUint16, true},
143143 } {
144144 t.Run(tc.name, func(t *testing.T) {
145- m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Visible: visible}}}}}
145+ m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Transcript: transcript}}}}}
146146 _, err := MarshalManifest(m)
147147 if (err != nil) != tc.wantErr {
148148 t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr)
@@ -153,9 +153,9 @@ func TestManifestDimensionBoundaries(t *testing.T) {
153153
154154 func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) {
155155 manifest := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{
156- ID: "pane",
157- Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1},
158- Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
156+ ID: "pane",
157+ Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1},
158+ Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
159159 }}}}}
160160 if _, err := MarshalManifest(manifest); !errors.Is(err, ErrInvalidData) {
161161 t.Fatalf("MarshalManifest() error = %v, want invalid data", err)
@@ -220,9 +220,9 @@ func manifestWithTailSize(size uint32) Manifest {
220220 Cols: 1,
221221 Rows: 1,
222222 Panes: []ManifestPane{{
223- ID: "pane",
224- Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size},
225- Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
223+ ID: "pane",
224+ Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size},
225+ Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
226226 }},
227227 }}}
228228 }
@@ -65,16 +65,16 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) {
65 if err != nil {65 if err != nil {
66 t.Fatal(err)66 t.Fatal(err)
67 }67 }
68- visible, err := MarshalObject(Visible, []byte("visible"))68+ transcript, err := MarshalObject(RecoveryTranscript, []byte("transcript"))
69 if err != nil {69 if err != nil {
70 t.Fatal(err)70 t.Fatal(err)
71 }71 }
72 manifest := Manifest{Generation: 7, IncarnationID: domain.IncarnationID{1}, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{72 manifest := Manifest{Generation: 7, IncarnationID: domain.IncarnationID{1}, Name: "named", CreatedAt: 9, Active: 0, Tabs: []ManifestTab{{
73 StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"),73 StableID: "tab", Cols: 80, Rows: 24, NextPaneID: 2, Focus: "pane", Tree: layout.NewTree("pane"),
74 Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp",74 Panes: []ManifestPane{{ID: "pane", StableID: "pane-stable", Cwd: "/tmp",
75- Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}},75+ Sealed: []ObjectRef{{Kind: HistoryChunk, Digest: chunk.Digest, Size: uint32(len(chunk.Data))}},
76- Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))},76+ Tail: ObjectRef{Kind: HistoryTail, Digest: tail.Digest, Size: uint32(len(tail.Data))},
77- Visible: ObjectRef{Kind: Visible, Digest: visible.Digest, Size: uint32(len(visible.Data))},77+ Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: transcript.Digest, Size: uint32(len(transcript.Data))},
78 }},78 }},
79 }}}79 }}}
80 encoded, err := MarshalManifest(manifest)80 encoded, err := MarshalManifest(manifest)
@@ -104,7 +104,7 @@ func TestManifestCodecUsesGenerationAndOrderedTypedReferences(t *testing.T) {
104 }104 }
105 105
106 func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {106 func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
107- object, err := MarshalObject(Visible, []byte("canonical-vt"))107+ object, err := MarshalObject(RecoveryTranscript, []byte("canonical-vt"))
108 if err != nil {108 if err != nil {
109 t.Fatal(err)109 t.Fatal(err)
110 }110 }
@@ -112,11 +112,11 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
112 t.Fatalf("digest does not address VEVO bytes")112 t.Fatalf("digest does not address VEVO bytes")
113 }113 }
114 kind, payload, err := UnmarshalObject(object.Data)114 kind, payload, err := UnmarshalObject(object.Data)
115- if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) {115+ if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {
116 t.Fatalf("round trip = %v %q %v", kind, payload, err)116 t.Fatalf("round trip = %v %q %v", kind, payload, err)
117 }117 }
118 kind, payload, err = PreflightObject(object.Data)118 kind, payload, err = PreflightObject(object.Data)
119- if err != nil || kind != Visible || !bytes.Equal(payload, []byte("canonical-vt")) {119+ if err != nil || kind != RecoveryTranscript || !bytes.Equal(payload, []byte("canonical-vt")) {
120 t.Fatalf("preflight = %v %q %v", kind, payload, err)120 t.Fatalf("preflight = %v %q %v", kind, payload, err)
121 }121 }
122 for n := range len(object.Data) {122 for n := range len(object.Data) {
@@ -131,7 +131,7 @@ func TestObjectCodecReturnsContentAddressedObjectAndPayload(t *testing.T) {
131 131
132 func TestManifestDimensionBoundaries(t *testing.T) {132 func TestManifestDimensionBoundaries(t *testing.T) {
133 ref := ObjectRef{Kind: HistoryTail, Digest: ports.SnapshotDigest{1}, Size: minObjectEnvelopeSize}133 ref := ObjectRef{Kind: HistoryTail, Digest: ports.SnapshotDigest{1}, Size: minObjectEnvelopeSize}
134- visible := ObjectRef{Kind: Visible, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize}134+ transcript := ObjectRef{Kind: RecoveryTranscript, Digest: ports.SnapshotDigest{2}, Size: minObjectEnvelopeSize}
135 for _, tc := range []struct {135 for _, tc := range []struct {
136 name string136 name string
137 cols, rows uint16137 cols, rows uint16
@@ -142,7 +142,7 @@ func TestManifestDimensionBoundaries(t *testing.T) {
142 {"hostile product", math.MaxUint16, math.MaxUint16, true},142 {"hostile product", math.MaxUint16, math.MaxUint16, true},
143 } {143 } {
144 t.Run(tc.name, func(t *testing.T) {144 t.Run(tc.name, func(t *testing.T) {
145- m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Visible: visible}}}}}145+ m := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "safe", Tabs: []ManifestTab{{Cols: tc.cols, Rows: tc.rows, Panes: []ManifestPane{{ID: "p", Tail: ref, Transcript: transcript}}}}}
146 _, err := MarshalManifest(m)146 _, err := MarshalManifest(m)
147 if (err != nil) != tc.wantErr {147 if (err != nil) != tc.wantErr {
148 t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr)148 t.Fatalf("MarshalManifest() error = %v, want error=%v", err, tc.wantErr)
@@ -153,9 +153,9 @@ func TestManifestDimensionBoundaries(t *testing.T) {
153 153
154 func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) {154 func TestManifestRejectsImpossibleObjectEnvelopeSize(t *testing.T) {
155 manifest := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{155 manifest := Manifest{Generation: 1, IncarnationID: domain.IncarnationID{1}, Name: "named", Tabs: []ManifestTab{{Cols: 1, Rows: 1, Panes: []ManifestPane{{
156- ID: "pane",156+ ID: "pane",
157- Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1},157+ Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: 1},
158- Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},158+ Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
159 }}}}}159 }}}}}
160 if _, err := MarshalManifest(manifest); !errors.Is(err, ErrInvalidData) {160 if _, err := MarshalManifest(manifest); !errors.Is(err, ErrInvalidData) {
161 t.Fatalf("MarshalManifest() error = %v, want invalid data", err)161 t.Fatalf("MarshalManifest() error = %v, want invalid data", err)
@@ -220,9 +220,9 @@ func manifestWithTailSize(size uint32) Manifest {
220 Cols: 1,220 Cols: 1,
221 Rows: 1,221 Rows: 1,
222 Panes: []ManifestPane{{222 Panes: []ManifestPane{{
223- ID: "pane",223+ ID: "pane",
224- Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size},224+ Tail: ObjectRef{Kind: HistoryTail, Digest: SnapshotDigest{1}, Size: size},
225- Visible: ObjectRef{Kind: Visible, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},225+ Transcript: ObjectRef{Kind: RecoveryTranscript, Digest: SnapshotDigest{2}, Size: minObjectEnvelopeSize},
226 }},226 }},
227 }}}227 }}}
228 }228 }
M internal/usecase/snapshot/preflight.go +5 -14
@@ -202,7 +202,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
202202 return fmt.Errorf("%w: sealed chunks", ErrInvalidData)
203203 }
204204 for range n {
205- stats, err := preflightBlob(r, totals, blobs, true, budget)
205+ stats, err := preflightBlob(r, totals, blobs, budget)
206206 if err != nil {
207207 return err
208208 }
@@ -210,7 +210,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
210210 return fmt.Errorf("%w: sealed blob role", ErrInvalidData)
211211 }
212212 }
213- tail, err := preflightBlob(r, totals, blobs, true, budget)
213+ tail, err := preflightBlob(r, totals, blobs, budget)
214214 if err != nil {
215215 return err
216216 }
@@ -220,17 +220,13 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
220220 if tail.Chunks > 1 {
221221 return fmt.Errorf("%w: tail blob role", ErrInvalidData)
222222 }
223- visible, err := preflightBlob(r, totals, blobs, false, budget)
224- if err != nil {
223+ if _, err := preflightBlob(r, totals, blobs, budget); err != nil {
225224 return err
226225 }
227- if visible.Rows == 0 || visible.Cells == 0 {
228- return fmt.Errorf("%w: visible geometry", ErrInvalidData)
229- }
230226 return preflightProcess(r)
231227 }
232228
233-func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool, budget *preflightBudget) (vt.DecodeStats, error) {
229+func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) (vt.DecodeStats, error) {
234230 n, err := r.getUint32()
235231 if err != nil {
236232 return vt.DecodeStats{}, err
@@ -243,12 +239,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist
243239 }
244240 blob := r.b[:n]
245241 r.b = r.b[n:]
246- var stats vt.DecodeStats
247- if history {
248- stats, err = vt.PreflightHistoryBlob(blob)
249- } else {
250- stats, err = vt.PreflightVisibleBlob(blob)
251- }
242+ stats, err := vt.PreflightHistoryBlob(blob)
252243 if err != nil {
253244 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData)
254245 }
@@ -202,7 +202,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
202 return fmt.Errorf("%w: sealed chunks", ErrInvalidData)202 return fmt.Errorf("%w: sealed chunks", ErrInvalidData)
203 }203 }
204 for range n {204 for range n {
205- stats, err := preflightBlob(r, totals, blobs, true, budget)205+ stats, err := preflightBlob(r, totals, blobs, budget)
206 if err != nil {206 if err != nil {
207 return err207 return err
208 }208 }
@@ -210,7 +210,7 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
210 return fmt.Errorf("%w: sealed blob role", ErrInvalidData)210 return fmt.Errorf("%w: sealed blob role", ErrInvalidData)
211 }211 }
212 }212 }
213- tail, err := preflightBlob(r, totals, blobs, true, budget)213+ tail, err := preflightBlob(r, totals, blobs, budget)
214 if err != nil {214 if err != nil {
215 return err215 return err
216 }216 }
@@ -220,17 +220,13 @@ func preflightPaneStructure(r *payloadReader, totals *vt.DecodeStats, blobs *uin
220 if tail.Chunks > 1 {220 if tail.Chunks > 1 {
221 return fmt.Errorf("%w: tail blob role", ErrInvalidData)221 return fmt.Errorf("%w: tail blob role", ErrInvalidData)
222 }222 }
223- visible, err := preflightBlob(r, totals, blobs, false, budget)223+ if _, err := preflightBlob(r, totals, blobs, budget); err != nil {
224- if err != nil {
225 return err224 return err
226 }225 }
227- if visible.Rows == 0 || visible.Cells == 0 {
228- return fmt.Errorf("%w: visible geometry", ErrInvalidData)
229- }
230 return preflightProcess(r)226 return preflightProcess(r)
231 }227 }
232 228
233-func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, history bool, budget *preflightBudget) (vt.DecodeStats, error) {229+func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, budget *preflightBudget) (vt.DecodeStats, error) {
234 n, err := r.getUint32()230 n, err := r.getUint32()
235 if err != nil {231 if err != nil {
236 return vt.DecodeStats{}, err232 return vt.DecodeStats{}, err
@@ -243,12 +239,7 @@ func preflightBlob(r *payloadReader, totals *vt.DecodeStats, blobs *uint64, hist
243 }239 }
244 blob := r.b[:n]240 blob := r.b[:n]
245 r.b = r.b[n:]241 r.b = r.b[n:]
246- var stats vt.DecodeStats242+ stats, err := vt.PreflightHistoryBlob(blob)
247- if history {
248- stats, err = vt.PreflightHistoryBlob(blob)
249- } else {
250- stats, err = vt.PreflightVisibleBlob(blob)
251- }
252 if err != nil {243 if err != nil {
253 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData)244 return vt.DecodeStats{}, fmt.Errorf("%w: VT blob", ErrInvalidData)
254 }245 }
M internal/usecase/snapshot/preflight_acceptance_test.go +3 -3
@@ -10,7 +10,7 @@ import (
1010
1111 func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {
1212 sealed, tail := historyBlobs(t, nil)
13- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
13+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
1414 for _, tt := range []struct {
1515 name string
1616 tab Tab
@@ -20,7 +20,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T
2020 tab: Tab{
2121 Focus: "pane",
2222 Tree: layout.NewTree("missing"),
23- Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},
23+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Transcript: transcript}},
2424 },
2525 },
2626 {
@@ -28,7 +28,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T
2828 tab: Tab{
2929 Focus: "missing",
3030 Tree: layout.NewTree("pane"),
31- Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},
31+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Transcript: transcript}},
3232 },
3333 },
3434 } {
@@ -10,7 +10,7 @@ import (
10 10
11 func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {11 func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T) {
12 sealed, tail := historyBlobs(t, nil)12 sealed, tail := historyBlobs(t, nil)
13- visible := visibleBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})13+ transcript := transcriptBlob(t, [][]renderer.Cell{{{Rune: 'v'}}})
14 for _, tt := range []struct {14 for _, tt := range []struct {
15 name string15 name string
16 tab Tab16 tab Tab
@@ -20,7 +20,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T
20 tab: Tab{20 tab: Tab{
21 Focus: "pane",21 Focus: "pane",
22 Tree: layout.NewTree("missing"),22 Tree: layout.NewTree("missing"),
23- Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},23+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Transcript: transcript}},
24 },24 },
25 },25 },
26 {26 {
@@ -28,7 +28,7 @@ func TestPreflightRejectsDanglingTreeAndFocusReferencesBeforeDecode(t *testing.T
28 tab: Tab{28 tab: Tab{
29 Focus: "missing",29 Focus: "missing",
30 Tree: layout.NewTree("pane"),30 Tree: layout.NewTree("pane"),
31- Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Visible: visible}},31+ Panes: []Pane{{ID: "pane", SealedChunks: sealed, Tail: tail, Transcript: transcript}},
32 },32 },
33 },33 },
34 } {34 } {
M internal/usecase/snapshot/snapshot.go +3 -3
@@ -27,9 +27,9 @@ type Pane struct {
2727 ID layout.PaneID
2828 StableID string
2929 Cwd string
30- SealedChunks [][]byte // oldest-first, each a self-contained VT blob
31- Tail []byte // mandatory nonempty canonical VT blob
32- Visible []byte // mandatory nonempty canonical VT blob
30+ SealedChunks [][]byte // oldest-first, each a self-contained canonical history blob
31+ Tail []byte // mandatory nonempty canonical history blob
32+ Transcript []byte // mandatory nonempty canonical history blob
3333 Process *Process
3434 }
3535
@@ -27,9 +27,9 @@ type Pane struct {
27 ID layout.PaneID27 ID layout.PaneID
28 StableID string28 StableID string
29 Cwd string29 Cwd string
30- SealedChunks [][]byte // oldest-first, each a self-contained VT blob30+ SealedChunks [][]byte // oldest-first, each a self-contained canonical history blob
31- Tail []byte // mandatory nonempty canonical VT blob31+ Tail []byte // mandatory nonempty canonical history blob
32- Visible []byte // mandatory nonempty canonical VT blob32+ Transcript []byte // mandatory nonempty canonical history blob
33 Process *Process33 Process *Process
34 }34 }
35 35
M internal/usecase/snapshot/weights_test.go +4 -4
@@ -86,17 +86,17 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) {
8686 const (
8787 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"
8888 objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"
89- manifestGolden = "5645564d0002000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
89+ manifestGolden = "5645564d0003000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
9090 )
91- if ManifestVersion != 2 {
92- t.Fatalf("ManifestVersion = %d, want 2", ManifestVersion)
91+ if ManifestVersion != 3 {
92+ t.Fatalf("ManifestVersion = %d, want 3", ManifestVersion)
9393 }
9494
9595 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})
9696 if err != nil {
9797 t.Fatal(err)
9898 }
99- object, err := MarshalObject(Visible, []byte("canonical-vt"))
99+ object, err := MarshalObject(RecoveryTranscript, []byte("canonical-vt"))
100100 if err != nil {
101101 t.Fatal(err)
102102 }
@@ -86,17 +86,17 @@ func TestSnapshotEnvelopeCompatibility(t *testing.T) {
86 const (86 const (
87 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"87 headGolden = "56455648000100000000002898ebcac200000000000000070100000000000000000000000000000000000000000000000000000000000000"
88 objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"88 objectGolden = "5645564f00010000000000111cb8f09f030000000c63616e6f6e6963616c2d7674"
89- manifestGolden = "5645564d0002000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"89+ manifestGolden = "5645564d0003000000000133d118cc030000000000000001010000000000000000000000000000000000056e616d6564000000000000000000000001000374616200500018000000000000000000036f6e65010000020000036f6e6500000374776f000200036f6e6500036f6e6500042f6f6e65000101010000000000000000000000000000000000000000000000000000000000000000000016020200000000000000000000000000000000000000000000000000000000000000000000160303000000000000000000000000000000000000000000000000000000000000000000001600000374776f000374776f00042f74776f0000020400000000000000000000000000000000000000000000000000000000000000000000160305000000000000000000000000000000000000000000000000000000000000000000001600"
90 )90 )
91- if ManifestVersion != 2 {91+ if ManifestVersion != 3 {
92- t.Fatalf("ManifestVersion = %d, want 2", ManifestVersion)92+ t.Fatalf("ManifestVersion = %d, want 3", ManifestVersion)
93 }93 }
94 94
95 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})95 head, err := MarshalHead(Head{Generation: 7, ManifestDigest: SnapshotDigest{1}})
96 if err != nil {96 if err != nil {
97 t.Fatal(err)97 t.Fatal(err)
98 }98 }
99- object, err := MarshalObject(Visible, []byte("canonical-vt"))99+ object, err := MarshalObject(RecoveryTranscript, []byte("canonical-vt"))
100 if err != nil {100 if err != nil {
101 t.Fatal(err)101 t.Fatal(err)
102 }102 }
M pkg/vt/snapshot.go +0 -13
@@ -143,19 +143,6 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
143143 return screen, nil
144144 }
145145
146-// NewScreenWithRestoredHistory constructs a screen that owns history restored
147-// directly from canonical blobs. Call RestorePrimaryVisible to install a
148-// persisted primary frame before making the screen live.
149-func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, sealed [][]byte, tail []byte) (*Screen, error) {
150- history, err := HistoryFromBlobs(config, sealed, tail)
151- if err != nil {
152- return nil, err
153- }
154- screen := NewScreenWithHistory(width, height, config)
155- screen.history = history
156- return screen, nil
157-}
158-
159146 // MarshalVisible encodes the exact visible primary frame, including every
160147 // blank, row width, and terminal cell attribute.
161148 func MarshalVisible(frame renderer.Frame) ([]byte, error) {
@@ -143,19 +143,6 @@ func NewScreenWithRecoveryTranscript(width, height int, config HistoryConfig, se
143 return screen, nil143 return screen, nil
144 }144 }
145 145
146-// NewScreenWithRestoredHistory constructs a screen that owns history restored
147-// directly from canonical blobs. Call RestorePrimaryVisible to install a
148-// persisted primary frame before making the screen live.
149-func NewScreenWithRestoredHistory(width, height int, config HistoryConfig, sealed [][]byte, tail []byte) (*Screen, error) {
150- history, err := HistoryFromBlobs(config, sealed, tail)
151- if err != nil {
152- return nil, err
153- }
154- screen := NewScreenWithHistory(width, height, config)
155- screen.history = history
156- return screen, nil
157-}
158-
159 // MarshalVisible encodes the exact visible primary frame, including every146 // MarshalVisible encodes the exact visible primary frame, including every
160 // blank, row width, and terminal cell attribute.147 // blank, row width, and terminal cell attribute.
161 func MarshalVisible(frame renderer.Frame) ([]byte, error) {148 func MarshalVisible(frame renderer.Frame) ([]byte, error) {

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