Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 0 additions & 18 deletions internal/system/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -971,24 +971,6 @@ func (d *system) createTerminalSession(ctx context.Context, projectID string) (r
return relay.TerminalSessionInfo{}, fmt.Errorf("could not allocate a unique terminal session id")
}

func (d *system) restartTerminalSession(ctx context.Context, recordID, projectID, sessionID string, previousGeneration int) (relay.TerminalSessionInfo, error) {
if err := d.terminalLifecycleReady(); err != nil {
return relay.TerminalSessionInfo{}, err
}
data, err := d.relayDo(ctx, http.MethodPost, "/system/terminal-sessions/"+url.PathEscape(recordID)+"/restart", map[string]int{"generation": previousGeneration})
if err != nil {
return relay.TerminalSessionInfo{}, err
}
var info relay.TerminalSessionInfo
if err := json.Unmarshal(data, &info); err != nil {
return relay.TerminalSessionInfo{}, err
}
if info.ID == "" || info.ID != recordID || info.ProjectID != projectID || info.SessionID != sessionID || info.State != relay.TerminalStateRunning || info.Generation <= previousGeneration {
return relay.TerminalSessionInfo{}, fmt.Errorf("restart returned invalid terminal session")
}
return info, nil
}

func (d *system) deleteTerminalSession(ctx context.Context, recordID string, generation int) error {
if _, err := d.relayDo(ctx, http.MethodDelete, "/system/terminal-sessions/"+url.PathEscape(recordID), map[string]int{"generation": generation}); err != nil {
return err
Expand Down
80 changes: 0 additions & 80 deletions internal/system/terminal_http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,12 +121,6 @@ func TestTerminalMutationGenerationWireShape(t *testing.T) {
return nil, err
}
}
if req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/restart") {
if len(body) != 1 || body["generation"] != float64(3) {
t.Fatalf("restart body=%#v", body)
}
return testHTTPResponse(http.StatusOK, `{"id":"record","project_id":"project","session_id":"tab","state":"running","generation":4}`), nil
}
if req.Method == http.MethodDelete && req.URL.Path == "/system/terminal-sessions/record" {
if len(body) != 1 || body["generation"] != float64(4) {
t.Fatalf("delete body=%#v", body)
Expand All @@ -139,9 +133,6 @@ func TestTerminalMutationGenerationWireShape(t *testing.T) {
return testHTTPResponse(http.StatusNotFound, ""), nil
})}
d := &system{cfg: systemConfig{RelayURL: "ws://relay.test"}, resetDone: true, terminals: make(map[string]*terminalSession)}
if _, err := d.restartTerminalSession(context.Background(), "record", "project", "tab", 3); err != nil {
t.Fatal(err)
}
if err := d.deleteTerminalSession(context.Background(), "record", 4); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -214,77 +205,6 @@ func TestTerminalSessionCreateRetriesOnlyExplicitConflict(t *testing.T) {
}
}

func TestRestartTerminalSessionRequiresFlatRunningGeneration(t *testing.T) {
oldClient := httpClient
t.Cleanup(func() { httpClient = oldClient })
for name, body := range map[string]string{
"wrapper": `{"session":{"id":"record","project_id":"project","session_id":"tab","state":"running","generation":2}}`,
"missing id": `{"project_id":"project","session_id":"tab","state":"running","generation":2}`,
"missing session": `{"id":"record","project_id":"project","state":"running","generation":2}`,
"not running": `{"id":"record","project_id":"project","session_id":"tab","state":"closing","generation":2}`,
"bad generation": `{"id":"record","project_id":"project","session_id":"tab","state":"running","generation":0}`,
"wrong record": `{"id":"other","project_id":"project","session_id":"tab","state":"running","generation":2}`,
"wrong project": `{"id":"record","project_id":"other","session_id":"tab","state":"running","generation":2}`,
"wrong session": `{"id":"record","project_id":"project","session_id":"other","state":"running","generation":2}`,
"same generation": `{"id":"record","project_id":"project","session_id":"tab","state":"running","generation":1}`,
} {
t.Run(name, func(t *testing.T) {
httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return testHTTPResponse(http.StatusOK, body), nil
})}
d := &system{cfg: systemConfig{RelayURL: "ws://relay.test"}, resetDone: true}
if _, err := d.restartTerminalSession(context.Background(), "record", "project", "tab", 1); err == nil {
t.Fatal("malformed restart response was accepted")
}
})
}
}

func TestRestartTerminalSessionRequiresCompletedReset(t *testing.T) {
for name, state := range map[string]struct {
done bool
err error
}{
"reset pending": {done: false},
"reset failed": {err: errors.New("reset unavailable")},
} {
t.Run(name, func(t *testing.T) {
calls := 0
oldClient := httpClient
t.Cleanup(func() { httpClient = oldClient })
httpClient = &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
calls++
return testHTTPResponse(http.StatusOK, `{"id":"record","project_id":"project","session_id":"tab","state":"running","generation":2}`), nil
})}
d := &system{cfg: systemConfig{RelayURL: "ws://relay.test"}, resetDone: state.done, resetErr: state.err}
if _, err := d.restartTerminalSession(context.Background(), "record", "project", "tab", 1); err == nil {
t.Fatal("restart bypassed terminal lifecycle reset")
}
if calls != 0 {
t.Fatalf("restart issued %d POSTs before reset completion", calls)
}
})
}

calls := 0
oldClient := httpClient
t.Cleanup(func() { httpClient = oldClient })
httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
calls++
if req.Method != http.MethodPost || req.URL.Path != "/system/terminal-sessions/record/restart" {
t.Fatalf("unexpected restart request: %s %s", req.Method, req.URL.Path)
}
return testHTTPResponse(http.StatusOK, `{"id":"record","project_id":"project","session_id":"tab","state":"running","generation":2}`), nil
})}
d := &system{cfg: systemConfig{RelayURL: "ws://relay.test"}, resetDone: true}
if _, err := d.restartTerminalSession(context.Background(), "record", "project", "tab", 1); err != nil {
t.Fatalf("completed reset blocked restart: %v", err)
}
if calls != 1 {
t.Fatalf("completed reset issued %d POSTs, want one", calls)
}
}

func TestCreateTerminalSessionRequiresRequestedIdentity(t *testing.T) {
oldClient, oldRandom := httpClient, terminalSessionRandom
t.Cleanup(func() { httpClient, terminalSessionRandom = oldClient, oldRandom })
Expand Down
18 changes: 4 additions & 14 deletions internal/system/tui.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ type terminalsMsg struct {
type terminalCreatedMsg struct {
projectRoot string
info relay.TerminalSessionInfo
restarted bool
err error
}

Expand Down Expand Up @@ -399,11 +398,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.terminalsCmd()
}
m.err = ""
if msg.restarted {
m.notice = "terminal restarted"
} else {
m.notice = "terminal created"
}
m.notice = "terminal created"
m, attach := m.startTerminal(msg.projectRoot, msg.info, msg.info.SessionID)
return m, tea.Batch(
tea.DisableMouse,
Expand Down Expand Up @@ -551,13 +546,8 @@ func (m model) updateNormal(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
p := m.projects[r.projectIdx]
tab := m.terminals[r.terminalIdx]
if tab.info.State == relay.TerminalStateExited {
recordID := tab.info.ID
return m, func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
info, err := m.d.restartTerminalSession(ctx, recordID, p.ID, tab.info.SessionID, tab.info.Generation)
return terminalCreatedMsg{projectRoot: p.RootDir, info: info, restarted: true, err: err}
}
m.notice = "terminal exited; removal is automatic"
return m, nil
}
if tab.info.State != "" && tab.info.State != relay.TerminalStateRunning {
m.err = "terminal unavailable"
Expand Down Expand Up @@ -944,7 +934,7 @@ func (m model) View() string {
tab := m.terminals[r.terminalIdx]
switch tab.info.State {
case relay.TerminalStateExited:
hints += " · enter restart"
hints += " · exited; removing automatically"
case relay.TerminalStateClosing:
hints += " · d delete"
case "", relay.TerminalStateRunning:
Expand Down
40 changes: 13 additions & 27 deletions internal/system/tui_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package system
import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
Expand Down Expand Up @@ -205,34 +206,24 @@ func TestTerminalCreationPersistsBeforeAttach(t *testing.T) {
}
}

func TestExitedTerminalEnterRestartsBeforeAttach(t *testing.T) {
func TestExitedTerminalEnterHasNoRestartAction(t *testing.T) {
m := terminalDashboard(t)
next, _ := m.Update(terminalsMsg{terminals: []relay.TerminalSessionInfo{{ID: "record", ProjectID: "project-a", SessionID: "a-tab", State: relay.TerminalStateExited, Generation: 3}}})
m = next.(model)
m.cursor = rowIndex(m.rows, rowTerminal, "project-a", "a-tab")
oldClient, oldAttach := httpClient, attachTerminalScreen
t.Cleanup(func() { httpClient, attachTerminalScreen = oldClient, oldAttach })
oldClient := httpClient
t.Cleanup(func() { httpClient = oldClient })
requests := 0
httpClient = &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
if req.Method != http.MethodPost || req.URL.Path != "/system/terminal-sessions/record/restart" {
t.Fatalf("restart request=%s %s", req.Method, req.URL.Path)
}
return testHTTPResponse(http.StatusOK, `{"id":"record","project_id":"project-a","session_id":"a-tab","state":"running","generation":4}`), nil
requests++
return nil, fmt.Errorf("unexpected request: %s %s", req.Method, req.URL.Path)
})}
_, restartCmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
created, ok := restartCmd().(terminalCreatedMsg)
if !ok || created.info.ID != "record" || created.info.Generation != 4 {
t.Fatalf("restart result=%#v", created)
}
client := newFakeTerminalAttachment()
attachTerminalScreen = func(_ *system, _ string, info relay.TerminalSessionInfo, _, _ int) (terminalAttachment, error) {
if info.ID != "record" || info.Generation != 4 {
t.Fatalf("attach info=%+v", info)
}
return client, nil
updated, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
if cmd != nil || requests != 0 || updated.(model).mode == modeTerminal {
t.Fatalf("exited enter command=%v requests=%d mode=%v", cmd, requests, updated.(model).mode)
}
updated, _ := m.Update(created)
if updated.(model).mode != modeTerminal {
t.Fatal("restarted terminal did not enter terminal mode")
if got := updated.(model).notice; got != "terminal exited; removal is automatic" {
t.Fatalf("exited notice=%q", got)
}
}

Expand All @@ -243,7 +234,7 @@ func TestTerminalStateLabelsAndNoticesMatchActions(t *testing.T) {
want string
avoid string
}{
{relay.TerminalStateExited, "enter restart", "open in TUI"},
{relay.TerminalStateExited, "exited; removing automatically", "restart"},
{relay.TerminalStateClosing, "d delete", "enter open in TUI"},
{"future-state", "terminal unavailable", "enter open in TUI"},
} {
Expand All @@ -257,11 +248,6 @@ func TestTerminalStateLabelsAndNoticesMatchActions(t *testing.T) {
t.Fatalf("state=%s view lacks truthful footer: %q", tc.state, view)
}
}
m.notice = ""
next, _ := m.Update(terminalCreatedMsg{info: relay.TerminalSessionInfo{ID: "record", SessionID: "a-tab", State: relay.TerminalStateRunning, Generation: 4}, restarted: true, err: nil})
if got := next.(model).notice; got != "terminal restarted" {
t.Fatalf("restart notice=%q", got)
}
}

func TestTerminalDeleteNoticeReportsCompletion(t *testing.T) {
Expand Down