1package main
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11
12 vt "github.com/bnema/vev-vt"
13 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"
15)
16
17const (
18 defaultProbeCols = 80
19 defaultProbeRows = 24
20
21 maxProbeEvents = 128
22 maxProbeCheckpoints = 16
23 maxArtifactBytes = 64 << 10
24
25 probeEventOutput = "output"
26 probeEventWelcome = "welcome"
27 probeEventOutputReset = "output_reset_request"
28 probeEventUnexpectedHandoff = "unexpected_attach_target"
29)
30
31// visualOutputState mirrors the client's output dependency chain. A probe may
32// inspect output only after the frame's epoch, base, and view revision continue
33// the last accepted state.
34type visualOutputState struct {
35 epoch uint64
36 state uint64
37 viewRevision uint64
38 initialized bool
39}
40
41func (s visualOutputState) next(output ports.Output) (visualOutputState, bool) {
42 if output.Epoch == 0 {
43 return visualOutputState{}, false
44 }
45 if !s.initialized {
46 if output.New == 0 {
47 if output.Base != 0 || output.Full {
48 return visualOutputState{}, false
49 }
50 return s, true
51 }
52 if output.Base != 0 || !output.Full {
53 return visualOutputState{}, false
54 }
55 return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
56 }
57 if output.Epoch < s.epoch {
58 return visualOutputState{}, false
59 }
60 if output.Epoch == s.epoch {
61 if output.ViewRevision != s.viewRevision {
62 return visualOutputState{}, false
63 }
64 if output.New == 0 {
65 if output.Base != 0 || output.Full {
66 return visualOutputState{}, false
67 }
68 return s, true
69 }
70 if output.Full || output.Base != s.state || output.New != output.Base+1 {
71 return visualOutputState{}, false
72 }
73 return visualOutputState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
74 }
75 if !output.Full || output.Base != 0 || output.New == 0 {
76 return visualOutputState{}, false
77 }
78 return visualOutputState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
79}
80
81type probeEvent struct {
82 Sequence uint64
83 Transport string
84 Kind string
85 Accepted bool
86 StateBearing bool
87 Acked bool
88 Epoch uint64
89 Base uint64
90 New uint64
91 ViewRevision uint64
92 Full bool
93 DataBytes int
94}
95
96type visualCheckpoint struct {
97 event *probeEvent
98 state visualOutputState
99 snapshot vt.ScreenSnapshot
100}
101
102type visualOutputResult struct {
103 Accepted bool
104 StateBearing bool
105 Ack ports.Ack
106 event *probeEvent
107}
108
109// visualProbe is deliberately transport-local. A new physical transport gets
110// one screen and one output state; waiting for a particular marker never
111// creates a replacement screen and therefore cannot lose prior terminal state.
112type visualProbe struct {
113 label string
114
115 screen *vt.Screen
116 state visualOutputState
117
118 events []*probeEvent
119 checkpoints []visualCheckpoint
120 nextEvent uint64
121
122 outputFrames int
123 outputBytes int
124 resetPending bool
125
126 unexpectedHandoffs int
127}
128
129type harnessArtifactContextKey struct{}
130
131func withHarnessArtifact(ctx context.Context, artifact *harnessArtifact) context.Context {
132 return context.WithValue(ctx, harnessArtifactContextKey{}, artifact)
133}
134
135func harnessArtifactFromContext(ctx context.Context) *harnessArtifact {
136 if ctx == nil {
137 return nil
138 }
139 artifact, _ := ctx.Value(harnessArtifactContextKey{}).(*harnessArtifact)
140 return artifact
141}
142
143func newVisualProbe(size domain.Size) *visualProbe {
144 width, height := size.Cols, size.Rows
145 if width <= 0 {
146 width = defaultProbeCols
147 }
148 if height <= 0 {
149 height = defaultProbeRows
150 }
151 return &visualProbe{screen: vt.NewScreen(width, height)}
152}
153
154func (p *visualProbe) configure(label string) {
155 if p == nil {
156 return
157 }
158 p.label = label
159}
160
161func (p *visualProbe) record(event probeEvent) *probeEvent {
162 if p == nil {
163 return nil
164 }
165 p.nextEvent++
166 event.Sequence = p.nextEvent
167 event.Transport = p.label
168 owned := &event
169 if len(p.events) == maxProbeEvents {
170 p.events = p.events[1:]
171 }
172 p.events = append(p.events, owned)
173 return owned
174}
175
176func (p *visualProbe) recordControl(kind string) {
177 if p == nil {
178 return
179 }
180 p.record(probeEvent{Kind: kind, Accepted: true})
181}
182
183func (p *visualProbe) apply(output ports.Output) visualOutputResult {
184 if p == nil {
185 return visualOutputResult{}
186 }
187 p.outputFrames++
188 p.outputBytes += len(output.Data)
189 accepted := ports.ValidateOutput(output) == nil
190 next := visualOutputState{}
191 if accepted {
192 next, accepted = p.state.next(output)
193 }
194 event := p.record(probeEvent{
195 Kind: probeEventOutput,
196 Accepted: accepted,
197 StateBearing: output.New != 0,
198 Epoch: output.Epoch,
199 Base: output.Base,
200 New: output.New,
201 ViewRevision: output.ViewRevision,
202 Full: output.Full,
203 DataBytes: len(output.Data),
204 })
205 if !accepted {
206 return visualOutputResult{event: event}
207 }
208
209 // The client writes side effects as well as state-bearing frames, but only
210 // state-bearing frames advance the dependency chain and receive an ACK.
211 if p.screen.Frame.Width != output.Size.Cols || p.screen.Frame.Height != output.Size.Rows {
212 p.screen.Resize(output.Size.Cols, output.Size.Rows)
213 }
214 p.screen.Write(output.Data)
215 p.state = next
216 checkpoint := visualCheckpoint{event: event, state: p.state, snapshot: p.screen.Snapshot()}
217 if len(p.checkpoints) == maxProbeCheckpoints {
218 p.checkpoints = p.checkpoints[1:]
219 }
220 p.checkpoints = append(p.checkpoints, checkpoint)
221 result := visualOutputResult{Accepted: true, StateBearing: output.New != 0, event: event}
222 if result.StateBearing {
223 result.Ack = ports.Ack{Epoch: output.Epoch, State: output.New}
224 }
225 return result
226}
227
228func (p *visualProbe) text() string {
229 if p == nil || p.screen == nil {
230 return ""
231 }
232 return visualScreenText(p.screen)
233}
234
235func (p *visualProbe) contains(want string) bool {
236 return strings.Contains(p.text(), want)
237}
238
239func (p *visualProbe) recordIncoming(frame ports.Frame) {
240 if p == nil || frame.Type != ports.MsgAttachTarget {
241 return
242 }
243 p.unexpectedHandoffs++
244 p.record(probeEvent{Kind: probeEventUnexpectedHandoff})
245}
246
247func (p *visualProbe) markAcked(result visualOutputResult) {
248 if result.event != nil && result.StateBearing && result.Accepted {
249 result.event.Acked = true
250 }
251}
252
253func visualScreenLines(screen *vt.Screen) []string {
254 if screen == nil {
255 return nil
256 }
257 lines := make([]string, screen.Frame.Height)
258 for y := range screen.Frame.Height {
259 var line strings.Builder
260 for x := range screen.Frame.Width {
261 r := screen.Frame.At(x, y).Rune
262 if r == 0 {
263 r = ' '
264 }
265 line.WriteRune(r)
266 }
267 lines[y] = line.String()
268 }
269 return lines
270}
271
272func visualScreenText(screen *vt.Screen) string {
273 lines := visualScreenLines(screen)
274 if len(lines) == 0 {
275 return ""
276 }
277 return strings.Join(lines, "\n") + "\n"
278}
279
280func capturePickerRows(probe *visualProbe) []string {
281 if probe == nil {
282 return nil
283 }
284 var rows []string
285 for _, line := range visualScreenLines(probe.screen) {
286 if line = strings.TrimSpace(line); line != "" {
287 rows = append(rows, line)
288 }
289 }
290 return rows
291}
292
293func assertUnifiedPickerRows(rows []string, localSession, remoteSession string) error {
294 if len(rows) == 0 {
295 return errors.New("picker screen had no visible rows")
296 }
297 contains := func(want string) bool {
298 for _, row := range rows {
299 if strings.Contains(row, want) {
300 return true
301 }
302 }
303 return false
304 }
305 if !contains(localSession) {
306 return fmt.Errorf("unified picker rows omitted local session %q", localSession)
307 }
308 remoteRow := remoteSession + "@remote"
309 if !contains(remoteRow) {
310 return fmt.Errorf("unified picker rows omitted remote session %q", remoteRow)
311 }
312 return nil
313}
314
315func assertNoRemoteChrome(probe *visualProbe, remoteSession string) error {
316 if probe == nil {
317 return errors.New("remote view probe was not created")
318 }
319 lines := visualScreenLines(probe.screen)
320 if len(lines) < 3 {
321 return errors.New("remote view did not expose a content region")
322 }
323 // The first and last rows belong to the local attachment's bars. Remote
324 // daemon chrome must not be replayed into the content rows.
325 for _, line := range lines[1 : len(lines)-1] {
326 if strings.Contains(line, remoteSession+"@remote") || strings.Contains(line, " Sessions · ") {
327 return fmt.Errorf("remote chrome leaked into content row %q", strings.TrimSpace(line))
328 }
329 }
330 return nil
331}
332
333type harnessArtifact struct {
334 dir string
335 probes []*visualProbe
336}
337
338func newHarnessArtifact(dir string) *harnessArtifact {
339 if dir == "" {
340 return nil
341 }
342 return &harnessArtifact{dir: dir}
343}
344
345func (a *harnessArtifact) registerProbe(probe *visualProbe) {
346 if a == nil || probe == nil {
347 return
348 }
349 a.probes = append(a.probes, probe)
350}
351
352type artifactEvent struct {
353 Sequence uint64 `json:"sequence"`
354 Transport string `json:"transport"`
355 Kind string `json:"kind"`
356 Accepted bool `json:"accepted"`
357 StateBearing bool `json:"state_bearing"`
358 Acked bool `json:"acked"`
359 Epoch uint64 `json:"epoch,omitempty"`
360 Base uint64 `json:"base,omitempty"`
361 New uint64 `json:"new,omitempty"`
362 ViewRevision uint64 `json:"view_revision,omitempty"`
363 Full bool `json:"full,omitempty"`
364 DataBytes int `json:"data_bytes,omitempty"`
365}
366
367type artifactCheckpoint struct {
368 Sequence uint64 `json:"sequence"`
369 Epoch uint64 `json:"epoch,omitempty"`
370 State uint64 `json:"state,omitempty"`
371 ViewRevision uint64 `json:"view_revision,omitempty"`
372 Columns int `json:"columns"`
373 Rows int `json:"rows"`
374 CursorRow int `json:"cursor_row"`
375 CursorColumn int `json:"cursor_column"`
376 AlternateScreen bool `json:"alternate_screen"`
377 BracketedPaste bool `json:"bracketed_paste"`
378 SynchronizedUpdate bool `json:"synchronized_update"`
379}
380
381type artifactProbe struct {
382 Transport string `json:"transport"`
383 Events []artifactEvent `json:"events"`
384 Checkpoints []artifactCheckpoint `json:"checkpoints"`
385}
386
387type artifactDocument struct {
388 Version int `json:"version"`
389 Passed bool `json:"passed"`
390 Probes []artifactProbe `json:"probes"`
391}
392
393func (a *harnessArtifact) document(passed bool) artifactDocument {
394 document := artifactDocument{Version: 1, Passed: passed}
395 for _, probe := range a.probes {
396 if probe == nil {
397 continue
398 }
399 item := artifactProbe{Transport: probe.label}
400 for _, event := range probe.events {
401 if event == nil {
402 continue
403 }
404 item.Events = append(item.Events, artifactEventFrom(event))
405 }
406 for _, checkpoint := range probe.checkpoints {
407 snapshot := checkpoint.snapshot
408 cursor := snapshot.Cursor()
409 modes := snapshot.Modes()
410 item.Checkpoints = append(item.Checkpoints, artifactCheckpoint{
411 Sequence: checkpoint.event.Sequence,
412 Epoch: checkpoint.state.epoch,
413 State: checkpoint.state.state,
414 ViewRevision: checkpoint.state.viewRevision,
415 Columns: snapshot.Columns(),
416 Rows: snapshot.Rows(),
417 CursorRow: cursor.Row,
418 CursorColumn: cursor.Col,
419 AlternateScreen: modes.AlternateScreen,
420 BracketedPaste: modes.BracketedPaste,
421 SynchronizedUpdate: modes.SynchronizedUpdate,
422 })
423 }
424 document.Probes = append(document.Probes, item)
425 }
426 return document
427}
428
429func artifactEventFrom(event *probeEvent) artifactEvent {
430 return artifactEvent{
431 Sequence: event.Sequence,
432 Transport: event.Transport,
433 Kind: event.Kind,
434 Accepted: event.Accepted,
435 StateBearing: event.StateBearing,
436 Acked: event.Acked,
437 Epoch: event.Epoch,
438 Base: event.Base,
439 New: event.New,
440 ViewRevision: event.ViewRevision,
441 Full: event.Full,
442 DataBytes: event.DataBytes,
443 }
444}
445
446func (a *harnessArtifact) write(passed bool) error {
447 if a == nil || a.dir == "" {
448 return nil
449 }
450 data, err := json.MarshalIndent(a.document(passed), "", " ")
451 if err != nil {
452 return fmt.Errorf("marshal harness artifact: %w", err)
453 }
454 if len(data) > maxArtifactBytes {
455 return fmt.Errorf("harness artifact exceeds %d bytes", maxArtifactBytes)
456 }
457 if err := os.MkdirAll(a.dir, 0o700); err != nil {
458 return fmt.Errorf("create harness artifact directory: %w", err)
459 }
460 tmp, err := os.CreateTemp(a.dir, ".remote-picker-harness-*.tmp")
461 if err != nil {
462 return fmt.Errorf("create harness artifact: %w", err)
463 }
464 tmpName := tmp.Name()
465 defer func() { _ = os.Remove(tmpName) }()
466 if err := tmp.Chmod(0o600); err != nil {
467 _ = tmp.Close()
468 return fmt.Errorf("protect harness artifact: %w", err)
469 }
470 if _, err := tmp.Write(data); err != nil {
471 _ = tmp.Close()
472 return fmt.Errorf("write harness artifact: %w", err)
473 }
474 if err := tmp.Close(); err != nil {
475 return fmt.Errorf("close harness artifact: %w", err)
476 }
477 if err := os.Rename(tmpName, filepath.Join(a.dir, "remote-picker-harness.json")); err != nil {
478 return fmt.Errorf("publish harness artifact: %w", err)
479 }
480 return nil
481}