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
24 changes: 22 additions & 2 deletions cmd/agentsview/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,18 @@ func newServeCommand() *cobra.Command {
func newSyncCommand() *cobra.Command {
var cfg SyncConfig
cmd := &cobra.Command{
Use: "sync",
Short: "Sync session data without serving",
Use: "sync",
Short: "Sync session data without serving",
Long: "Sync session data into the local database without starting the\n" +
"HTTP server.\n\n" +
"With no --host, sync runs the local sync and then fans out to\n" +
"every host listed in the [[remote_hosts]] array in config.toml,\n" +
"syncing each over SSH. A failure on one configured host is logged\n" +
"and the run continues; the command exits non-zero if any\n" +
"configured host failed.\n\n" +
"With --host, sync ignores remote_hosts and syncs only that host.\n\n" +
"Remote sync uses your existing SSH configuration and requires\n" +
"key-based (passwordless) auth; it never prompts for a password.",
GroupID: groupCore,
SilenceUsage: true,
Args: cobra.NoArgs,
Expand Down Expand Up @@ -483,6 +493,16 @@ func writeRootHelp(w io.Writer, root *cobra.Command) {
fmt.Fprintln(w, " When set, these override default directory. Environment variables")
fmt.Fprintln(w, " override config file arrays.")
fmt.Fprintln(w)
fmt.Fprintln(w, "Remote hosts:")
fmt.Fprintln(w, " Add a [[remote_hosts]] array to ~/.agentsview/config.toml so that")
fmt.Fprintln(w, " \"agentsview sync\" (no --host) also syncs each host over SSH:")
fmt.Fprintln(w, " [[remote_hosts]]")
fmt.Fprintln(w, " host = \"devbox1\"")
fmt.Fprintln(w, " user = \"jesse\" # optional")
fmt.Fprintln(w, " port = 22 # optional")
fmt.Fprintln(w, " Each host must be unique.")
fmt.Fprintln(w, " Requires key-based (passwordless) SSH to each host.")
fmt.Fprintln(w)
fmt.Fprintln(w, "Data stored in ~/.agentsview/ by default.")
}

Expand Down
17 changes: 17 additions & 0 deletions cmd/agentsview/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,20 @@ func TestExecuteCLIWithLegacyFlagCompatWarnsOnce(t *testing.T) {
want := "warning: deprecated single-dash long flags detected; use GNU-style long flags instead: -version -> --version\n"
assert.Equal(t, want, stderr.String())
}

func TestRootHelpDocumentsRemoteHosts(t *testing.T) {
help, err := executeCommand(newRootCommand(), "--help")
require.NoError(t, err, "Execute")
for _, want := range []string{"remote_hosts", "passwordless"} {
assert.Contains(t, help, want,
"root help should document %q", want)
}
}

func TestSyncHelpMentionsConfiguredHosts(t *testing.T) {
help, err := executeCommand(newRootCommand(), "sync", "--help")
require.NoError(t, err, "Execute")
for _, want := range []string{"remote_hosts", "--host", "passwordless"} {
assert.Contains(t, help, want, "sync help missing %q", want)
}
}
118 changes: 109 additions & 9 deletions cmd/agentsview/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ type SyncConfig struct {
}

func runSync(cfg SyncConfig) {
if doSync(cfg) {
os.Exit(1)
}
}

// doSync performs the sync run and reports whether any configured
// remote host failed. It owns the deferred cleanup (profile stop,
// db close) so runSync can translate the result into a non-zero
// exit code without skipping that cleanup.
func doSync(cfg SyncConfig) (hadRemoteFailures bool) {
appCfg, err := config.LoadMinimal()
if err != nil {
log.Fatalf("loading config: %v", err)
Expand Down Expand Up @@ -64,26 +74,116 @@ func runSync(cfg SyncConfig) {

if cfg.Host != "" {
runRemoteSync(appCfg, database, cfg)
return
return false
}

runLocalSync(appCfg, database, cfg.Full)
if err := appCfg.ValidateRemoteHosts(); err != nil {
fatal("invalid remote_hosts config: %v", err)
}

failures := syncLocalAndRemotes(
appCfg.RemoteHosts, cfg.Full,
func() bool { return runLocalSync(appCfg, database, cfg.Full) },
func(rh config.RemoteHost, full bool) error {
return runRemoteSyncOnce(appCfg, database, rh, full)
},
)
reportRemoteFailures(failures)
return len(failures) > 0
}

// syncLocalAndRemotes runs the local sync, then the configured
// remote hosts. A local resync (forced via --full or an automatic
// data-version resync) forces every remote sync full as well, so
// remote sessions are re-parsed rather than skipped via the remote
// skip cache. localSync and remoteSync are injected for testing;
// localSync returns whether a full resync was performed.
func syncLocalAndRemotes(
hosts []config.RemoteHost, cfgFull bool,
localSync func() bool,
remoteSync func(config.RemoteHost, bool) error,
) []remoteHostFailure {
didResync := localSync()
full := cfgFull || didResync
return runRemoteHosts(hosts, full, remoteSync)
}

func runRemoteSync(
appCfg config.Config, database *db.DB, cfg SyncConfig,
) {
rh := config.RemoteHost{
Host: cfg.Host,
User: cfg.User,
Port: cfg.Port,
}
if err := runRemoteSyncOnce(
appCfg, database, rh, cfg.Full,
); err != nil {
fatal("remote sync: %v", err)
}
}

// runRemoteSyncOnce syncs a single remote host and returns any
// error instead of exiting, so it backs both the single-host
// --host path and the configured-hosts fan-out.
func runRemoteSyncOnce(
appCfg config.Config, database *db.DB,
rh config.RemoteHost, full bool,
) error {
rs := &ssh.RemoteSync{
Host: cfg.Host,
User: cfg.User,
Port: cfg.Port,
Full: cfg.Full,
Host: rh.Host,
User: rh.User,
Port: rh.Port,
Full: full,
DB: database,
BlockedResultCategories: appCfg.ResultContentBlockedCategories,
}
ctx := context.Background()
if _, err := rs.Run(ctx); err != nil {
fatal("remote sync: %v", err)
_, err := rs.Run(context.Background())
return err
}

// remoteHostFailure records a configured remote host that failed
// to sync. It keeps the full RemoteHost (not just the name) so
// duplicate hostnames that differ by user/port stay distinct.
type remoteHostFailure struct {
Host config.RemoteHost
Err error
}

// runRemoteHosts syncs each configured host in declared order via
// syncFn, continuing past failures, and returns the collected
// failures. It performs no logging so it can be unit-tested
// without capturing the global logger; callers own all output.
func runRemoteHosts(
hosts []config.RemoteHost, full bool,
syncFn func(config.RemoteHost, bool) error,
) []remoteHostFailure {
var failures []remoteHostFailure
for _, rh := range hosts {
if err := syncFn(rh, full); err != nil {
failures = append(failures, remoteHostFailure{
Host: rh,
Err: err,
})
}
}
return failures
}

// reportRemoteFailures writes per-host failures to the debug log
// and a summary to stderr, so unattended (cron) runs surface them
// even though setupLogFile redirects log output to a file.
func reportRemoteFailures(failures []remoteHostFailure) {
if len(failures) == 0 {
return
}
for _, f := range failures {
log.Printf("remote sync %s failed: %v", f.Host.Host, f.Err)
}
fmt.Fprintf(os.Stderr,
"sync: %d remote host(s) failed:\n", len(failures))
for _, f := range failures {
fmt.Fprintf(os.Stderr, " %s: %v\n", f.Host.Host, f.Err)
}
}

Expand Down
78 changes: 78 additions & 0 deletions cmd/agentsview/sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package main

import (
"errors"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/config"
)

func TestRunRemoteHosts_AttemptsAllAndCollectsFailures(t *testing.T) {
hosts := []config.RemoteHost{
{Host: "alpha"},
{Host: "beta", User: "u", Port: 2222},
{Host: "gamma"},
}
failBeta := errors.New("ssh down")

var attempted []config.RemoteHost
failures := runRemoteHosts(hosts, true, func(rh config.RemoteHost, full bool) error {
attempted = append(attempted, rh)
assert.True(t, full, "full flag should propagate to syncFn")
if rh.Host == "beta" {
return failBeta
}
return nil
})

// Every host attempted, in declared order, even after a failure.
require.Equal(t, hosts, attempted)
// Only beta failed; its full RemoteHost (user/port) is preserved.
require.Len(t, failures, 1)
assert.Equal(t, hosts[1], failures[0].Host)
assert.Equal(t, failBeta, failures[0].Err)
}

func TestRunRemoteHosts_AllSucceedReturnsEmpty(t *testing.T) {
hosts := []config.RemoteHost{{Host: "alpha"}, {Host: "beta"}}
failures := runRemoteHosts(hosts, false, func(config.RemoteHost, bool) error {
return nil
})
assert.Empty(t, failures)
}

func TestSyncLocalAndRemotes_ResyncForcesRemoteFull(t *testing.T) {
tests := []struct {
name string
cfgFull bool
didResync bool
wantFull bool
}{
{"no full, no resync", false, false, false},
{"automatic resync forces remote full", false, true, true},
{"cli --full", true, false, true},
{"both", true, true, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hosts := []config.RemoteHost{{Host: "alpha"}, {Host: "beta"}}
localCalled := false
var gotFull []bool
failures := syncLocalAndRemotes(hosts, tt.cfgFull,
func() bool { localCalled = true; return tt.didResync },
func(_ config.RemoteHost, full bool) error {
gotFull = append(gotFull, full)
return nil
})

require.True(t, localCalled, "local sync must run")
assert.Empty(t, failures)
require.Len(t, gotFull, len(hosts))
for _, full := range gotFull {
assert.Equal(t, tt.wantFull, full)
}
})
}
}
67 changes: 67 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,15 @@ type CustomModelRate struct {
CacheRead float64 `json:"cache_read,omitempty" toml:"cache_read"`
}

// RemoteHost describes one SSH target for config-driven
// `agentsview sync` fan-out. Host is required; User and Port are
// optional (Port 0 means the ssh default of 22).
type RemoteHost struct {
Host string `toml:"host" json:"host"`
User string `toml:"user,omitempty" json:"user,omitempty"`
Port int `toml:"port,omitempty" json:"port,omitempty"`
}

// Config holds all application configuration.
type Config struct {
Host string `json:"host" toml:"host"`
Expand Down Expand Up @@ -128,6 +137,12 @@ type Config struct {

CustomModelPricing map[string]CustomModelRate `json:"custom_model_pricing,omitempty" toml:"custom_model_pricing"`

// RemoteHosts is the config-file list of SSH targets that
// `agentsview sync` (with no --host) syncs after the local
// pass. CLI/config-file only; never serialized to the
// settings API, so there is no web-UI editing of this list.
RemoteHosts []RemoteHost `json:"-" toml:"-"`

// HostExplicit is true when the user passed --host on the CLI.
// Used to prevent auto-bind to 0.0.0.0 when the user
// explicitly requested a specific host.
Expand Down Expand Up @@ -158,6 +173,46 @@ func (c *Config) IsUserConfigured(
return c.agentDirSource[agent] != dirDefault
}

// ValidateRemoteHosts checks the configured remote_hosts entries
// for semantic errors: a non-empty host and a port within 0..65535
// (0 means the ssh default). It checks the trimmed values that
// loadFile already normalized, so what is validated here is exactly
// what is passed to ssh. Returns an aggregated error naming every
// offending entry, or nil when all entries are valid.
func (c Config) ValidateRemoteHosts() error {
var problems []string
seen := make(map[string]int, len(c.RemoteHosts))
for i, h := range c.RemoteHosts {
if h.Host == "" {
problems = append(problems,
fmt.Sprintf("entry %d: host is required", i+1))
}
if h.Port < 0 || h.Port > 65535 {
problems = append(problems,
fmt.Sprintf("entry %d (%q): invalid port %d",
i+1, h.Host, h.Port))
}
// Remote sync namespaces sessions and the skip cache by
// host alone (see ssh.RemoteSync), so two entries sharing a
// host collide regardless of user/port. Reject duplicates
// rather than silently share or overwrite cached state.
if h.Host != "" {
if first, ok := seen[h.Host]; ok {
problems = append(problems,
fmt.Sprintf("entry %d: duplicate host %q (already at entry %d)",
i+1, h.Host, first))
} else {
seen[h.Host] = i + 1
}
}
}
if len(problems) > 0 {
return fmt.Errorf("remote_hosts: %s",
strings.Join(problems, "; "))
}
return nil
}

// Default returns a Config with default values.
func Default() (Config, error) {
home, err := os.UserHomeDir()
Expand Down Expand Up @@ -375,6 +430,7 @@ func (c *Config) loadFile() error {
Agent map[string]AgentConfig `toml:"agent"`
EventsCoalesceInterval time.Duration `toml:"events_coalesce_interval"`
CustomModelPricing map[string]CustomModelRate `toml:"custom_model_pricing"`
RemoteHosts []RemoteHost `toml:"remote_hosts"`
}
meta, err := toml.DecodeFile(path, &file)
if err != nil {
Expand Down Expand Up @@ -457,6 +513,17 @@ func (c *Config) loadFile() error {
if len(file.CustomModelPricing) > 0 {
c.CustomModelPricing = file.CustomModelPricing
}
if len(file.RemoteHosts) > 0 {
hosts := make([]RemoteHost, len(file.RemoteHosts))
for i, h := range file.RemoteHosts {
hosts[i] = RemoteHost{
Host: strings.TrimSpace(h.Host),
User: strings.TrimSpace(h.User),
Port: h.Port,
}
}
c.RemoteHosts = hosts
}

// Parse config-file dir arrays for agents that have a
// ConfigKey. Only apply when not already set by env var.
Expand Down
Loading
Loading