f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
283 lines
8.0 KiB
Go
283 lines
8.0 KiB
Go
// ABOUTME: session command group root — programmatic CLI
|
|
// ABOUTME: surface for the SessionService interface.
|
|
package main
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/pflag"
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/service"
|
|
"go.kenn.io/agentsview/internal/timeutil"
|
|
)
|
|
|
|
func newSessionCommand() *cobra.Command {
|
|
cmd := &cobra.Command{
|
|
Use: "session",
|
|
Short: "Programmatic access to session data",
|
|
GroupID: groupData,
|
|
SilenceUsage: true,
|
|
Args: cobra.NoArgs,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
return cmd.Help()
|
|
},
|
|
}
|
|
registerFormatFlags(cmd.PersistentFlags())
|
|
cmd.PersistentFlags().String(
|
|
"server", "",
|
|
"Remote daemon URL",
|
|
)
|
|
cmd.PersistentFlags().String(
|
|
"server-token-file", "",
|
|
"File containing bearer token for explicit --server requests",
|
|
)
|
|
cmd.PersistentFlags().Bool(
|
|
"pg", false,
|
|
"Read session data from configured PostgreSQL",
|
|
)
|
|
|
|
cmd.AddCommand(newSessionGetCommand())
|
|
cmd.AddCommand(newSessionUsageCommand())
|
|
cmd.AddCommand(newSessionListCommand())
|
|
cmd.AddCommand(newSessionMessagesCommand())
|
|
cmd.AddCommand(newSessionToolCallsCommand())
|
|
cmd.AddCommand(newSessionExportCommand())
|
|
cmd.AddCommand(newSessionSyncCommand())
|
|
cmd.AddCommand(newSessionWatchCommand())
|
|
cmd.AddCommand(newSessionSearchCommand())
|
|
return cmd
|
|
}
|
|
|
|
// resolveService constructs the SessionService matching the
|
|
// current transport: HTTP when a daemon is discoverable, direct
|
|
// SQLite otherwise. Callers MUST defer the returned cleanup.
|
|
func resolveService(
|
|
cmd *cobra.Command,
|
|
) (service.SessionService, func(), error) {
|
|
remote, _ := cmd.Flags().GetString("server")
|
|
if remote != "" {
|
|
if pgReadRequested(cmd) {
|
|
return nil, nil, errors.New(
|
|
"--server and --pg are mutually exclusive",
|
|
)
|
|
}
|
|
token, err := explicitServerToken(cmd)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return service.NewHTTPBackend(remote, token, false),
|
|
func() {}, nil
|
|
}
|
|
cfg, err := config.LoadPFlags(cmd.Flags())
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"loading config: %w", err,
|
|
)
|
|
}
|
|
pgCfg, usePG, err := resolvePGReadConfig(cmd, cfg)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if usePG {
|
|
return newPGReadService(cfg, pgCfg)
|
|
}
|
|
tr, err := ensureTransport(&cfg, transportIntentRead, 0)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return newService(cfg, tr)
|
|
}
|
|
|
|
// resolveSinceFlag validates the --since/--active-since pair shared by
|
|
// `session list` and `session search`: setting both is an error, since they
|
|
// describe the same active-window filter two different ways. When --since is
|
|
// set, it resolves against the current time via timeutil.ParseSince and
|
|
// returns the RFC3339 string to use as ActiveSince; otherwise activeSince
|
|
// passes through unchanged.
|
|
func resolveSinceFlag(since, activeSince string) (string, error) {
|
|
if since == "" {
|
|
return activeSince, nil
|
|
}
|
|
if activeSince != "" {
|
|
return "", errors.New(
|
|
"--since and --active-since are mutually exclusive")
|
|
}
|
|
t, err := timeutil.ParseSince(time.Now(), since)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return t.UTC().Format(time.RFC3339), nil
|
|
}
|
|
|
|
// resolveWritableService constructs a write-capable SessionService:
|
|
// HTTP when a writable daemon is reachable, otherwise a direct
|
|
// backend wired with a real sync.Engine. It refuses read-only daemons
|
|
// and unreachable writable daemons. Callers MUST defer the returned cleanup.
|
|
// Read-only commands should use resolveService instead.
|
|
func resolveWritableService(
|
|
cmd *cobra.Command,
|
|
) (service.SessionService, func(), error) {
|
|
return resolveWritableServiceWithIntent(cmd, false)
|
|
}
|
|
|
|
func resolveFreshWritableService(
|
|
cmd *cobra.Command,
|
|
) (service.SessionService, func(), error) {
|
|
return resolveWritableServiceWithIntent(cmd, true)
|
|
}
|
|
|
|
func resolveWritableServiceWithIntent(
|
|
cmd *cobra.Command, fresh bool,
|
|
) (service.SessionService, func(), error) {
|
|
if remote, _ := cmd.Flags().GetString("server"); remote != "" {
|
|
if pgReadRequested(cmd) {
|
|
return nil, nil, errors.New(
|
|
"--server and --pg are mutually exclusive",
|
|
)
|
|
}
|
|
token, err := explicitServerToken(cmd)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return service.NewHTTPBackend(remote, token, false),
|
|
func() {}, nil
|
|
}
|
|
if pgReadRequested(cmd) {
|
|
return nil, nil, errors.New(
|
|
"--pg is read-only and cannot be used with write commands",
|
|
)
|
|
}
|
|
cfg, err := config.LoadPFlags(cmd.Flags())
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("loading config: %w", err)
|
|
}
|
|
var tr transport
|
|
if fresh {
|
|
tr, err = ensureTransport(&cfg, transportIntentArchiveWrite, 0)
|
|
} else {
|
|
tr, err = detectTransport(cfg.DataDir, cfg.AuthToken, 0)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if tr.Mode == transportHTTP && tr.ReadOnly {
|
|
return nil, nil, fmt.Errorf(
|
|
"daemon at %s is read-only; cannot write: stop the "+
|
|
"read-only serve process and use the local DB, "+
|
|
"or start a local daemon",
|
|
tr.URL,
|
|
)
|
|
}
|
|
if tr.Mode == transportDirect && tr.DirectReadOnly {
|
|
reason := tr.DirectReason
|
|
if reason == "" {
|
|
reason = "local daemon owns the SQLite archive but is not responding"
|
|
}
|
|
return nil, nil, errors.New(
|
|
reason + "; refusing to write directly. Retry once the daemon " +
|
|
"is reachable and compatible, or stop it to write locally",
|
|
)
|
|
}
|
|
return syncService(cfg, tr)
|
|
}
|
|
|
|
func resolvePGReadConfig(
|
|
cmd *cobra.Command, cfg config.Config,
|
|
) (config.PGConfig, bool, error) {
|
|
if !pgReadRequested(cmd) {
|
|
return config.PGConfig{}, false, nil
|
|
}
|
|
pgCfg, err := cfg.ResolvePG()
|
|
if err != nil {
|
|
return config.PGConfig{}, false,
|
|
fmt.Errorf("resolving pg config: %w", err)
|
|
}
|
|
if pgCfg.URL == "" {
|
|
return config.PGConfig{}, false, errors.New(
|
|
"pg url not configured; set AGENTSVIEW_PG_URL, use a legacy [pg].url, or configure default_pg with named [pg.NAME] targets",
|
|
)
|
|
}
|
|
return pgCfg, true, nil
|
|
}
|
|
|
|
func pgReadRequested(cmd *cobra.Command) bool {
|
|
if cmd == nil {
|
|
return false
|
|
}
|
|
v, err := cmd.Flags().GetBool("pg")
|
|
return err == nil && v
|
|
}
|
|
|
|
func explicitServerToken(cmd *cobra.Command) (string, error) {
|
|
if cmd == nil {
|
|
return "", nil
|
|
}
|
|
path, err := cmd.Flags().GetString("server-token-file")
|
|
if err == nil && strings.TrimSpace(path) != "" {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("reading --server-token-file: %w", err)
|
|
}
|
|
return strings.TrimSpace(string(b)), nil
|
|
}
|
|
return strings.TrimSpace(os.Getenv("AGENTSVIEW_SERVER_TOKEN")), nil
|
|
}
|
|
|
|
// formatFlag restricts --format to "human" or "json", so a typo fails at
|
|
// parse time rather than silently degrading to human output. Type returns
|
|
// the allowed values, which --help renders as `--format human|json`.
|
|
type formatFlag string
|
|
|
|
func (f *formatFlag) String() string { return string(*f) }
|
|
|
|
func (f *formatFlag) Set(v string) error {
|
|
switch v {
|
|
case "human", "json":
|
|
*f = formatFlag(v)
|
|
return nil
|
|
default:
|
|
return errors.New("must be human or json")
|
|
}
|
|
}
|
|
|
|
func (*formatFlag) Type() string { return "human|json" }
|
|
|
|
// registerFormatFlags installs the --format/--json pair shared by every
|
|
// machine-readable command. Read the result with outputFormat.
|
|
func registerFormatFlags(flags *pflag.FlagSet) {
|
|
f := formatFlag("human")
|
|
flags.Var(&f, "format", "Output format: human or json")
|
|
flags.Bool("json", false, "Emit JSON output (alias for --format json)")
|
|
}
|
|
|
|
// rejectFormatFlags errors when --format or --json was set on a command
|
|
// that streams a fixed format: such commands inherit the pair from the
|
|
// session group but cannot honor it.
|
|
func rejectFormatFlags(cmd *cobra.Command, cmdName, streams string) error {
|
|
if cmd.Flags().Changed("format") || cmd.Flags().Changed("json") {
|
|
return fmt.Errorf(
|
|
"%s: streams %s; --format/--json not supported",
|
|
cmdName, streams,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// outputFormat resolves the output format to "human" or "json". The
|
|
// --json alias wins when set; otherwise the --format value, default
|
|
// "human".
|
|
func outputFormat(cmd *cobra.Command) string {
|
|
if jsonOutput, _ := cmd.Flags().GetBool("json"); jsonOutput {
|
|
return "json"
|
|
}
|
|
if f := cmd.Flag("format"); f != nil {
|
|
return f.Value.String()
|
|
}
|
|
return "human"
|
|
}
|