Skip to content
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
e3bd936
chore(lib): extract Conversation interface
johnstcn Jan 22, 2026
e5f1bda
Merge branch 'main' into cj/refactor-conversation
35C4n0r Jan 26, 2026
a0f8bb5
feat: implement state persistence
35C4n0r Jan 31, 2026
ca3cdff
feat: pid file writing and clearing and improved error handling for l…
35C4n0r Jan 31, 2026
1c224e9
refactor: remove redundant save logic
35C4n0r Jan 31, 2026
30f82d7
feat: improve logic for first run with empty state file
35C4n0r Feb 2, 2026
12bed1c
feat: implement platform-specific signal handling
35C4n0r Feb 3, 2026
e366e8b
feat: refactor cfg -> Config and move pid ops to server
35C4n0r Feb 5, 2026
26fdf81
feat: unregister the signal handlers on teardown
35C4n0r Feb 5, 2026
021e33f
Merge branch 'main' into 35C4n0r/agentapi-state-persistence
35C4n0r Feb 16, 2026
5795db7
feat: resolve conflicts and improve shutdown sequence
35C4n0r Feb 17, 2026
b44fe5d
Merge branch 'main' into 35C4n0r/agentapi-state-persistence
35C4n0r Feb 17, 2026
9deab88
feat: resolve conflicts
35C4n0r Feb 17, 2026
18fb1e4
chore: not dirty after load state
35C4n0r Feb 17, 2026
b719dac
feat: add tests
35C4n0r Feb 17, 2026
3959002
feat: remove comment
35C4n0r Feb 17, 2026
7e389d2
feat: remove comments
35C4n0r Feb 17, 2026
1d7aaed
wip: address comments
35C4n0r Feb 18, 2026
058b18f
feat: remove anti-pattern for graceful shutdown
35C4n0r Feb 18, 2026
2565a3c
feat: remove additional message upon load state fail
35C4n0r Feb 18, 2026
1033cd7
wip: apply suggestions from cian
35C4n0r Feb 18, 2026
cfb7601
wip: apply suggestions from cian
35C4n0r Feb 18, 2026
9d7eb5a
feat: update tests
35C4n0r Feb 18, 2026
759ec53
feat: improved initial prompt handling
35C4n0r Feb 19, 2026
03c6f16
chore: comments
35C4n0r Feb 19, 2026
bd75240
chore: address cian's file permission comments
35C4n0r Feb 19, 2026
b1ab615
feat: implement error handling for agent events
35C4n0r Feb 19, 2026
31d27a7
fix: no screen adjustment in case of loadState failure
35C4n0r Feb 19, 2026
220d360
feat: add three e2e tests for statePersistence
35C4n0r Feb 20, 2026
eef927d
feat: address maf's review
35C4n0r Feb 20, 2026
33460d2
feat: address ai's review
35C4n0r Feb 23, 2026
ad19496
feat: address maf's comments and remove adjustScreenAfterLoadState
35C4n0r Feb 23, 2026
7c42d35
chore: add missing files
35C4n0r Feb 23, 2026
d7d7744
feat: add check for existing pid
35C4n0r Feb 24, 2026
b2cbf56
fix: address review findings from #177 (#195)
mafredri Feb 26, 2026
410e29b
Merge branch 'main' into 35C4n0r/agentapi-state-persistence
35C4n0r Feb 26, 2026
db97306
feat: check for conflicting ACP and state persistence flags
35C4n0r Feb 26, 2026
2fd2110
feat: fix tests
35C4n0r Feb 26, 2026
f1b6ba6
chore: throw error on file not found
35C4n0r Feb 26, 2026
2188089
chore: don't emit file not found error
35C4n0r Feb 26, 2026
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
93 changes: 90 additions & 3 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import (
"log/slog"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"

"github.com/coder/agentapi/lib/screentracker"
"github.com/mattn/go-isatty"
"github.com/spf13/cobra"
"github.com/spf13/viper"
Expand Down Expand Up @@ -103,6 +106,44 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
}
}

// Get the variables related to state management
stateFile := viper.GetString(StateFile)
loadState := false
saveState := false

// Validate state file configuration
if stateFile != "" {
if !viper.IsSet(LoadState) {
loadState = true
} else {
loadState = viper.GetBool(LoadState)
}

if !viper.IsSet(SaveState) {
saveState = true
} else {
saveState = viper.GetBool(SaveState)
}
} else {
if viper.IsSet(LoadState) && viper.GetBool(LoadState) {
return xerrors.Errorf("--load-state requires --state-file to be set")
}
if viper.IsSet(SaveState) && viper.GetBool(SaveState) {
return xerrors.Errorf("--save-state requires --state-file to be set")
}
}

pidFile := viper.GetString(PidFile)
Comment thread
35C4n0r marked this conversation as resolved.
Outdated

// Write PID file if configured
if pidFile != "" {
if err := writePIDFile(pidFile, logger); err != nil {
return xerrors.Errorf("failed to write PID file: %w", err)
}
// Ensure PID file is cleaned up on exit
defer cleanupPIDFile(pidFile, logger)
Comment thread
35C4n0r marked this conversation as resolved.
}

printOpenAPI := viper.GetBool(FlagPrintOpenAPI)
var process *termexec.Process
if printOpenAPI {
Expand All @@ -128,14 +169,21 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
AllowedHosts: viper.GetStringSlice(FlagAllowedHosts),
AllowedOrigins: viper.GetStringSlice(FlagAllowedOrigins),
InitialPrompt: initialPrompt,
StatePersistenceConfig: screentracker.StatePersistenceConfig{
StateFile: stateFile,
LoadState: loadState,
SaveState: saveState,
},
})

if err != nil {
return xerrors.Errorf("failed to create server: %w", err)
}
if printOpenAPI {
fmt.Println(srv.GetOpenAPI())
return nil
}
handleSignals(ctx, logger, srv, process)
logger.Info("Starting server on port", "port", port)
processExitCh := make(chan error, 1)
go func() {
Expand All @@ -147,11 +195,13 @@ func runServer(ctx context.Context, logger *slog.Logger, argsToPass []string) er
processExitCh <- xerrors.Errorf("failed to wait for process: %w", err)
}
}
if err := srv.Stop(ctx); err != nil {
logger.Error("Failed to stop server", "error", err)
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Stop(shutdownCtx); err != nil {
logger.Error("Failed to stop server after process exit", "error", err)
}
}()
if err := srv.Start(); err != nil && err != context.Canceled && err != http.ErrServerClosed {
if err := srv.Start(); err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, http.ErrServerClosed) {
return xerrors.Errorf("failed to start server: %w", err)
}
select {
Expand All @@ -171,6 +221,35 @@ var agentNames = (func() []string {
return names
})()

// writePIDFile writes the current process ID to the specified file
func writePIDFile(pidFile string, logger *slog.Logger) error {
pid := os.Getpid()
pidContent := fmt.Sprintf("%d\n", pid)

// Create directory if it doesn't exist
dir := filepath.Dir(pidFile)
if err := os.MkdirAll(dir, 0o755); err != nil {
Comment thread
35C4n0r marked this conversation as resolved.
Outdated
return xerrors.Errorf("failed to create PID file directory: %w", err)
}

// Write PID file
if err := os.WriteFile(pidFile, []byte(pidContent), 0o644); err != nil {
Comment thread
35C4n0r marked this conversation as resolved.
Outdated
return xerrors.Errorf("failed to write PID file: %w", err)
}

logger.Info("Wrote PID file", "pidFile", pidFile, "pid", pid)
return nil
}
Comment thread
35C4n0r marked this conversation as resolved.

// cleanupPIDFile removes the PID file if it exists
func cleanupPIDFile(pidFile string, logger *slog.Logger) {
if err := os.Remove(pidFile); err != nil && !os.IsNotExist(err) {
logger.Error("Failed to remove PID file", "pidFile", pidFile, "error", err)
} else if err == nil {
logger.Info("Removed PID file", "pidFile", pidFile)
}
}

type flagSpec struct {
name string
shorthand string
Expand All @@ -190,6 +269,10 @@ const (
FlagAllowedOrigins = "allowed-origins"
FlagExit = "exit"
FlagInitialPrompt = "initial-prompt"
StateFile = "state-file"
LoadState = "load-state"
SaveState = "save-state"
PidFile = "pid-file"
Comment thread
35C4n0r marked this conversation as resolved.
Outdated
)

func CreateServerCmd() *cobra.Command {
Expand Down Expand Up @@ -228,6 +311,10 @@ func CreateServerCmd() *cobra.Command {
// localhost:3284 is the default origin when you open the chat interface in your browser. localhost:3000 and 3001 are used during development.
{FlagAllowedOrigins, "o", []string{"http://localhost:3284", "http://localhost:3000", "http://localhost:3001"}, "HTTP allowed origins. Use '*' for all, comma-separated list via flag, space-separated list via AGENTAPI_ALLOWED_ORIGINS env var", "stringSlice"},
{FlagInitialPrompt, "I", "", "Initial prompt for the agent. Recommended only if the agent doesn't support initial prompt in interaction mode. Will be read from stdin if piped (e.g., echo 'prompt' | agentapi server -- my-agent)", "string"},
{StateFile, "s", "", "Path to file for saving/loading server state", "string"},
{LoadState, "", false, "Load state from state-file on startup (defaults to true when state-file is set)", "bool"},
{SaveState, "", false, "Save state to state-file on shutdown (defaults to true when state-file is set)", "bool"},
{PidFile, "", "", "Path to file where the server process ID will be written for shutdown scripts", "string"},
}

for _, spec := range flagSpecs {
Expand Down
214 changes: 214 additions & 0 deletions cmd/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package server

import (
"fmt"
"io"
"log/slog"
"os"
"strings"
"testing"
Expand Down Expand Up @@ -477,6 +479,218 @@ func TestServerCmd_AllowedHosts(t *testing.T) {
}
}

func TestServerCmd_StatePersistenceFlags(t *testing.T) {
// NOTE: These tests use --exit flag to test flag parsing and defaults.
// Runtime validation that happens in runServer (e.g., "--load-state requires --state-file")
// would call os.Exit(1) which terminates the test process, so those validations
// are tested through integration/E2E tests instead.

t.Run("state-file with defaults", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--state-file", "/tmp/state.json", "--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/tmp/state.json", viper.GetString(StateFile))
// load-state and save-state default to true when state-file is set (validated in runServer)
})

t.Run("state-file with explicit load-state=false", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--state-file", "/tmp/state.json", "--load-state=false", "--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/tmp/state.json", viper.GetString(StateFile))
assert.Equal(t, false, viper.GetBool(LoadState))
})

t.Run("state-file with explicit save-state=false", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--state-file", "/tmp/state.json", "--save-state=false", "--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/tmp/state.json", viper.GetString(StateFile))
assert.Equal(t, false, viper.GetBool(SaveState))
})

t.Run("state-file with explicit load-state=true and save-state=true", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{
"--state-file", "/tmp/state.json",
"--load-state=true",
"--save-state=true",
"--exit", "dummy-command",
})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/tmp/state.json", viper.GetString(StateFile))
assert.Equal(t, true, viper.GetBool(LoadState))
assert.Equal(t, true, viper.GetBool(SaveState))
})

t.Run("load-state flag can be parsed", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--load-state", "--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

// Flag is parsed correctly (validation happens in runServer)
assert.Equal(t, true, viper.GetBool(LoadState))
})

t.Run("save-state flag can be parsed", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--save-state", "--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

// Flag is parsed correctly (validation happens in runServer)
assert.Equal(t, true, viper.GetBool(SaveState))
})

t.Run("pid-file can be set independently", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{"--pid-file", "/tmp/server.pid", "--exit", "dummy-command"})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/tmp/server.pid", viper.GetString(PidFile))
})

t.Run("state-file and pid-file can be set together", func(t *testing.T) {
isolateViper(t)

serverCmd := CreateServerCmd()
setupCommandOutput(t, serverCmd)
serverCmd.SetArgs([]string{
"--state-file", "/tmp/state.json",
"--pid-file", "/tmp/server.pid",
"--exit", "dummy-command",
})
err := serverCmd.Execute()
require.NoError(t, err)

assert.Equal(t, "/tmp/state.json", viper.GetString(StateFile))
assert.Equal(t, "/tmp/server.pid", viper.GetString(PidFile))
})
}

func TestPIDFileOperations(t *testing.T) {
discardLogger := slog.New(slog.NewTextHandler(io.Discard, nil))

t.Run("writePIDFile creates file with process ID", func(t *testing.T) {
tmpDir := t.TempDir()
pidFile := tmpDir + "/test.pid"

err := writePIDFile(pidFile, discardLogger)
require.NoError(t, err)

// Verify file exists
_, err = os.Stat(pidFile)
require.NoError(t, err)

// Verify content contains current PID
data, err := os.ReadFile(pidFile)
require.NoError(t, err)

expectedPID := fmt.Sprintf("%d\n", os.Getpid())
assert.Equal(t, expectedPID, string(data))
})

t.Run("writePIDFile creates directory if not exists", func(t *testing.T) {
tmpDir := t.TempDir()
pidFile := tmpDir + "/nested/deep/test.pid"

err := writePIDFile(pidFile, discardLogger)
require.NoError(t, err)

// Verify file exists
_, err = os.Stat(pidFile)
require.NoError(t, err)

// Verify directory was created
_, err = os.Stat(tmpDir + "/nested/deep")
require.NoError(t, err)
})

t.Run("writePIDFile overwrites existing file", func(t *testing.T) {
tmpDir := t.TempDir()
pidFile := tmpDir + "/test.pid"

// Write initial PID file
err := os.WriteFile(pidFile, []byte("12345\n"), 0o644)
require.NoError(t, err)

// Overwrite with current PID
err = writePIDFile(pidFile, discardLogger)
require.NoError(t, err)

// Verify content is updated
data, err := os.ReadFile(pidFile)
require.NoError(t, err)

expectedPID := fmt.Sprintf("%d\n", os.Getpid())
assert.Equal(t, expectedPID, string(data))
})

t.Run("cleanupPIDFile removes file", func(t *testing.T) {
tmpDir := t.TempDir()
pidFile := tmpDir + "/test.pid"

// Create PID file
err := os.WriteFile(pidFile, []byte("12345\n"), 0o644)
require.NoError(t, err)

// Cleanup
cleanupPIDFile(pidFile, discardLogger)

// Verify file is removed
_, err = os.Stat(pidFile)
assert.True(t, os.IsNotExist(err))
})

t.Run("cleanupPIDFile handles non-existent file", func(t *testing.T) {
tmpDir := t.TempDir()
pidFile := tmpDir + "/nonexistent.pid"

// Should not panic or error
cleanupPIDFile(pidFile, discardLogger)
})

t.Run("cleanupPIDFile handles directory removal error gracefully", func(t *testing.T) {
// Create a file in a protected directory (this is system-dependent)
// Just verify it doesn't panic when it can't remove the file
pidFile := "/this/should/not/exist/test.pid"

// Should not panic
cleanupPIDFile(pidFile, discardLogger)
})
}

func TestServerCmd_AllowedOrigins(t *testing.T) {
tests := []struct {
name string
Expand Down
Loading