feat(exec): add command handoff planning #7

Merged
KofTwentyTwo merged 1 commits from feature/GH-3-exec-handoff into develop 2026-05-26 20:27:24 +00:00
2 changed files with 393 additions and 0 deletions

No files matched your search

+155
View File
@@ -0,0 +1,155 @@
// Package exec plans and performs the final process handoff.
package exec
import (
"errors"
"fmt"
"io"
"os"
"strings"
"syscall"
"time"
"github.com/KofTwentyTwo/limen/internal/config"
"github.com/KofTwentyTwo/limen/internal/probe"
"github.com/KofTwentyTwo/limen/internal/ssh"
"github.com/KofTwentyTwo/limen/internal/state"
)
const maxSessionNameLength = 64
// ErrEmptyArgv reports an invalid handoff plan.
var ErrEmptyArgv = errors.New("empty argv")
// Plan is the final command vector and the host it targets.
type Plan struct {
HostName string
Argv []string
}
// SyscallExec is the injectable boundary around syscall.Exec.
type SyscallExec func(path string, argv []string, env []string) error
// Options supplies dependencies for Handoff.
type Options struct {
StatePath string
Now func() time.Time
Environ func() []string
Stderr io.Writer
LoadState func(string) state.State
SaveState func(string, state.State) error
Exec SyscallExec
}
// LocalAttach plans `tmux attach -t <session>` for localhost.
func LocalAttach(session string) Plan {
return Plan{
HostName: probe.LocalhostName,
Argv: ssh.LocalTmuxArgv("attach", "-t", session),
}
}
// LocalNew plans `tmux new`, optionally with `-s <name>`.
func LocalNew(name string) (Plan, error) {
args, err := newSessionArgs(name)
if err != nil {
return Plan{}, err
}
return Plan{
HostName: probe.LocalhostName,
Argv: ssh.LocalTmuxArgv(args...),
}, nil
}
// RemoteAttach plans `ssh -t <host> tmux attach -t <session>`.
func RemoteAttach(host config.Host, session string) Plan {
return Plan{
HostName: host.Name,
Argv: ssh.SessionArgv(host, "attach", "-t", session),
}
}
// RemoteNew plans `ssh -t <host> tmux new`, optionally with `-s <name>`.
func RemoteNew(host config.Host, name string) (Plan, error) {
args, err := newSessionArgs(name)
if err != nil {
return Plan{}, err
}
return Plan{
HostName: host.Name,
Argv: ssh.SessionArgv(host, args...),
}, nil
}
// NormalizeSessionName trims and validates a name for `tmux new -s`.
// Empty input is valid and means tmux should create an unnamed session.
func NormalizeSessionName(name string) (string, bool, error) {
normalized := strings.TrimSpace(name)
if normalized == "" {
return "", false, nil
}
if strings.ContainsAny(normalized, ":.") {
return "", false, errors.New("name cannot contain : or .")
}
if len([]rune(normalized)) > maxSessionNameLength {
return "", false, fmt.Errorf("name cannot exceed %d characters", maxSessionNameLength)
}
return normalized, true, nil
}
// Handoff records the destination host, then replaces the current process.
// State is advisory: a write failure must not strand the user before exec.
func Handoff(plan Plan, opts Options) int {
deps := opts.withDefaults()
if len(plan.Argv) == 0 {
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", ErrEmptyArgv)
return 127
}
st := deps.LoadState(deps.StatePath)
if st.LastAttached == nil {
st.LastAttached = map[string]time.Time{}
}
st.LastAttached[plan.HostName] = deps.Now().UTC()
_ = deps.SaveState(deps.StatePath, st)
if err := deps.Exec(plan.Argv[0], plan.Argv, deps.Environ()); err != nil {
fmt.Fprintf(deps.Stderr, "limen: exec failed: %v\n", err)
return 127
}
return 0
}
func newSessionArgs(name string) ([]string, error) {
normalized, named, err := NormalizeSessionName(name)
if err != nil {
return nil, err
}
if !named {
return []string{"new"}, nil
}
return []string{"new", "-s", normalized}, nil
}
func (opts Options) withDefaults() Options {
if opts.Now == nil {
opts.Now = time.Now
}
if opts.Environ == nil {
opts.Environ = os.Environ
}
if opts.Stderr == nil {
opts.Stderr = io.Discard
}
if opts.LoadState == nil {
opts.LoadState = state.Load
}
if opts.SaveState == nil {
opts.SaveState = state.Save
}
if opts.Exec == nil {
opts.Exec = syscall.Exec
}
return opts
}
+238
View File
@@ -0,0 +1,238 @@
package exec
import (
"bytes"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/KofTwentyTwo/limen/internal/config"
"github.com/KofTwentyTwo/limen/internal/probe"
"github.com/KofTwentyTwo/limen/internal/state"
)
func TestLocalAttachPlansTmuxAttach(t *testing.T) {
got := LocalAttach("api")
want := Plan{
HostName: probe.LocalhostName,
Argv: []string{"tmux", "attach", "-t", "api"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("LocalAttach() = %#v, want %#v", got, want)
}
}
func TestLocalNewPlansUnnamedAndNamedSessions(t *testing.T) {
tests := []struct {
name string
input string
want Plan
}{
{
name: "unnamed",
input: " ",
want: Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new"}},
},
{
name: "named",
input: " build ",
want: Plan{HostName: probe.LocalhostName, Argv: []string{"tmux", "new", "-s", "build"}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := LocalNew(tt.input)
if err != nil {
t.Fatalf("LocalNew returned error: %v", err)
}
if !reflect.DeepEqual(got, tt.want) {
t.Fatalf("LocalNew() = %#v, want %#v", got, tt.want)
}
})
}
}
func TestRemoteAttachPlansSSHAttach(t *testing.T) {
host := config.Host{Name: "prod", Hostname: "prod.example.com", User: "deploy", Port: 2222}
got := RemoteAttach(host, "api")
want := Plan{
HostName: "prod",
Argv: []string{
"ssh", "-t", "-p", "2222", "deploy@prod.example.com",
"tmux", "attach", "-t", "api",
},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RemoteAttach() = %#v, want %#v", got, want)
}
}
func TestRemoteNewPlansSSHNew(t *testing.T) {
host := config.Host{Name: "dev", Hostname: "dev.lan", User: "james"}
got, err := RemoteNew(host, " work ")
if err != nil {
t.Fatalf("RemoteNew returned error: %v", err)
}
want := Plan{
HostName: "dev",
Argv: []string{"ssh", "-t", "james@dev.lan", "tmux", "new", "-s", "work"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RemoteNew() = %#v, want %#v", got, want)
}
}
func TestNewSessionPlannersReturnValidationErrors(t *testing.T) {
if _, err := LocalNew("bad:name"); err == nil || err.Error() != "name cannot contain : or ." {
t.Fatalf("LocalNew error = %v, want invalid name", err)
}
host := config.Host{Name: "dev", Hostname: "dev.lan"}
if _, err := RemoteNew(host, "bad.name"); err == nil || err.Error() != "name cannot contain : or ." {
t.Fatalf("RemoteNew error = %v, want invalid name", err)
}
}
func TestNormalizeSessionName(t *testing.T) {
tests := []struct {
name string
input string
wantName string
wantNamed bool
wantErr string
}{
{name: "empty creates unnamed", input: " \t ", wantNamed: false},
{name: "trims whitespace", input: " Build ", wantName: "Build", wantNamed: true},
{name: "rejects colon", input: "bad:name", wantErr: "name cannot contain : or ."},
{name: "rejects dot", input: "bad.name", wantErr: "name cannot contain : or ."},
{name: "rejects over cap", input: repeatRune('a', 65), wantErr: "name cannot exceed 64 characters"},
{name: "accepts cap", input: repeatRune('a', 64), wantName: repeatRune('a', 64), wantNamed: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotName, gotNamed, err := NormalizeSessionName(tt.input)
if tt.wantErr != "" {
if err == nil || err.Error() != tt.wantErr {
t.Fatalf("NormalizeSessionName error = %v, want %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("NormalizeSessionName returned error: %v", err)
}
if gotName != tt.wantName || gotNamed != tt.wantNamed {
t.Fatalf("NormalizeSessionName() = (%q, %v), want (%q, %v)", gotName, gotNamed, tt.wantName, tt.wantNamed)
}
})
}
}
func TestHandoffUpdatesStateBeforeExec(t *testing.T) {
now := time.Date(2026, 5, 26, 13, 45, 0, 0, time.FixedZone("test", -5*60*60))
var calls []string
var saved state.State
var execPath string
var execArgv []string
var execEnv []string
var stderr bytes.Buffer
code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh", "-t", "prod", "tmux", "new"}}, Options{
StatePath: "/tmp/state.json",
Now: func() time.Time { return now },
Environ: func() []string { return []string{"TERM=xterm-256color"} },
Stderr: &stderr,
LoadState: func(path string) state.State {
calls = append(calls, "load:"+path)
return state.State{LastAttached: map[string]time.Time{"localhost": now.Add(-time.Hour)}}
},
SaveState: func(path string, st state.State) error {
calls = append(calls, "save:"+path)
saved = st
return nil
},
Exec: func(path string, argv []string, env []string) error {
calls = append(calls, "exec")
execPath = path
execArgv = append([]string(nil), argv...)
execEnv = append([]string(nil), env...)
return errors.New("exec unavailable")
},
})
if code != 127 {
t.Fatalf("Handoff exit code = %d, want 127", code)
}
wantCalls := []string{"load:/tmp/state.json", "save:/tmp/state.json", "exec"}
if !reflect.DeepEqual(calls, wantCalls) {
t.Fatalf("calls = %#v, want %#v", calls, wantCalls)
}
if got := saved.LastAttached["prod"]; !got.Equal(now.UTC()) {
t.Fatalf("saved prod timestamp = %s, want %s", got, now.UTC())
}
if _, ok := saved.LastAttached["localhost"]; !ok {
t.Fatal("existing localhost timestamp was not preserved")
}
if execPath != "ssh" || !reflect.DeepEqual(execArgv, []string{"ssh", "-t", "prod", "tmux", "new"}) {
t.Fatalf("exec saw %q %#v", execPath, execArgv)
}
if !reflect.DeepEqual(execEnv, []string{"TERM=xterm-256color"}) {
t.Fatalf("exec env = %#v", execEnv)
}
if got := stderr.String(); got != "limen: exec failed: exec unavailable\n" {
t.Fatalf("stderr = %q", got)
}
}
func TestHandoffTreatsStateSaveFailureAsAdvisory(t *testing.T) {
var calledExec bool
code := Handoff(Plan{HostName: "prod", Argv: []string{"ssh"}}, Options{
LoadState: func(path string) state.State {
return state.State{}
},
SaveState: func(path string, st state.State) error {
return errors.New("disk full")
},
Exec: func(path string, argv []string, env []string) error {
calledExec = true
return nil
},
})
if code != 0 {
t.Fatalf("Handoff exit code = %d, want 0", code)
}
if !calledExec {
t.Fatal("exec was not called after advisory state save failure")
}
}
func TestHandoffReportsEmptyArgvAsExecFailure(t *testing.T) {
var stderr bytes.Buffer
code := Handoff(Plan{HostName: "localhost"}, Options{Stderr: &stderr})
if code != 127 {
t.Fatalf("Handoff exit code = %d, want 127", code)
}
if got := stderr.String(); got != "limen: exec failed: empty argv\n" {
t.Fatalf("stderr = %q", got)
}
}
func repeatRune(r rune, count int) string {
var builder strings.Builder
for i := 0; i < count; i++ {
builder.WriteRune(r)
}
return builder.String()
}