Files
correx/apps/tui-go/internal/ws/client.go
T
kami 622b331de3 feat(workspace): Axis 2 Phase B — client→server workspace handshake
Wires the workspace handshake end to end so a session's workspace is bound
from the client's cwd at connect time and is event-sourced for replay.

- Go TUI sends Hello{workingDir=os.Getwd()} as the first WS frame on connect
  (protocol.go encoder + client.go connect path; golden test pins the wire
  format against the Kotlin discriminator).
- Server adds ClientMessage.Hello, stashes the per-connection workingDir, and
  on session start resolves it through WorkspaceResolver's trust pipeline,
  emits SessionWorkspaceBoundEvent (invariant #9), and threads the resolved
  workspace into OrchestrationConfig for the live run. A Hello after the first
  StartSession is ignored (warn); a rejected path binds the resolver fallback.
- Replay derives the workspace from the recorded event: SessionState gains
  boundWorkspace, DefaultSessionReducer fills it from SessionWorkspaceBoundEvent,
  and ReplayOrchestrator uses it (Path.of only, no filesystem re-query —
  invariant #8) with graceful fallback to config for pre-Phase-B logs.

Absent Hello / null resolver degrades to the prior config-workspace behavior.
2026-06-03 13:18:17 +04:00

168 lines
3.4 KiB
Go

// Package ws is the WebSocket transport to apps/server. It owns a reconnecting
// connection and exposes decoded server messages plus connection-status changes
// over channels the Bubble Tea loop drains via commands.
package ws
import (
"context"
"net/url"
"os"
"time"
"github.com/correx/tui-go/internal/protocol"
"github.com/gorilla/websocket"
)
const (
initialBackoff = 500 * time.Millisecond
maxBackoff = 8 * time.Second
)
// Status reports connection lifecycle transitions.
type Status struct {
Connected bool
Reconnecting bool
Attempt int
}
// Client is a reconnecting WebSocket client. Construct with New, then Run in a
// goroutine. Incoming and Conn are drained by the UI.
type Client struct {
url string
send chan []byte
in chan protocol.ServerMessage
conn chan Status
}
// New builds a client targeting ws://host:port/stream.
func New(host string, port int) *Client {
u := url.URL{Scheme: "ws", Host: hostPort(host, port), Path: "/stream"}
return &Client{
url: u.String(),
send: make(chan []byte, 64),
in: make(chan protocol.ServerMessage, 256),
conn: make(chan Status, 16),
}
}
func hostPort(host string, port int) string {
h := host
if h == "" {
h = "localhost"
}
return h + ":" + itoa(port)
}
func itoa(n int) string {
if n == 0 {
return "0"
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
return string(b[i:])
}
// Incoming yields decoded server messages.
func (c *Client) Incoming() <-chan protocol.ServerMessage { return c.in }
// Conn yields connection-status changes.
func (c *Client) Conn() <-chan Status { return c.conn }
// Send queues a raw client frame. Non-blocking; drops if the buffer is full
// (the server will be resynced on reconnect via snapshot).
func (c *Client) Send(frame []byte) {
select {
case c.send <- frame:
default:
}
}
// Run drives the reconnect loop until ctx is cancelled.
func (c *Client) Run(ctx context.Context) {
backoff := initialBackoff
attempt := 0
for {
if ctx.Err() != nil {
return
}
conn, _, err := websocket.DefaultDialer.DialContext(ctx, c.url, nil)
if err != nil {
attempt++
c.emit(Status{Reconnecting: true, Attempt: attempt})
if !sleep(ctx, backoff) {
return
}
backoff = min(backoff*2, maxBackoff)
continue
}
backoff = initialBackoff
attempt = 0
c.emit(Status{Connected: true})
if cwd, err := os.Getwd(); err == nil {
_ = conn.WriteMessage(websocket.TextMessage, protocol.Hello(cwd))
}
c.pump(ctx, conn)
c.emit(Status{Reconnecting: true})
}
}
// pump runs the read/write loops for a single live connection and returns when
// it drops.
func (c *Client) pump(ctx context.Context, conn *websocket.Conn) {
connCtx, cancel := context.WithCancel(ctx)
defer cancel()
go func() {
for {
select {
case <-connCtx.Done():
return
case frame := <-c.send:
if err := conn.WriteMessage(websocket.TextMessage, frame); err != nil {
cancel()
return
}
}
}
}()
for {
_, raw, err := conn.ReadMessage()
if err != nil {
cancel()
_ = conn.Close()
return
}
if msg, derr := protocol.Decode(raw); derr == nil {
select {
case c.in <- msg:
case <-connCtx.Done():
return
}
}
}
}
func (c *Client) emit(s Status) {
select {
case c.conn <- s:
default:
}
}
func sleep(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}