feat: add inbox
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/inbox"
|
||||
)
|
||||
|
||||
func cmdInbox(args []string, stdout, stderr io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent-notify inbox <list|show|done|rm|clear|serve|ssh-config>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
return inboxList(args[1:], stdout)
|
||||
case "show":
|
||||
return inboxShow(args[1:], stdout)
|
||||
case "done":
|
||||
return inboxDone(args[1:], stdout)
|
||||
case "rm":
|
||||
return inboxRemove(args[1:], stdout)
|
||||
case "clear":
|
||||
return inboxClear(args[1:], stdout)
|
||||
case "serve":
|
||||
return inboxServe(args[1:], stderr)
|
||||
case "ssh-config":
|
||||
return inboxSSHConfig(args[1:], stdout)
|
||||
case "tui":
|
||||
return inbox.RunTUI(inbox.NewStore(""))
|
||||
default:
|
||||
return fmt.Errorf("unknown inbox command %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func inboxList(args []string, stdout io.Writer) error {
|
||||
fs := flag.NewFlagSet("inbox list", flag.ExitOnError)
|
||||
all := fs.Bool("all", false, "include all records")
|
||||
done := fs.Bool("done", false, "show done records")
|
||||
_ = fs.Parse(args)
|
||||
records, err := inbox.NewStore("").List()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if !*all && !*done && rec.Status != inbox.StatusPending {
|
||||
continue
|
||||
}
|
||||
if *done && rec.Status != inbox.StatusDone {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s\t%s\t%s\t%s/%s\t%s\t%s\n",
|
||||
rec.ID, rec.Status, rec.Host, rec.Agent, rec.Event, rec.CWD, rec.Title)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func inboxShow(args []string, stdout io.Writer) error {
|
||||
if len(args) != 1 {
|
||||
return fmt.Errorf("usage: agent-notify inbox show <id>")
|
||||
}
|
||||
rec, ok, err := findRecord(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("inbox record %q not found", args[0])
|
||||
}
|
||||
enc := json.NewEncoder(stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(rec)
|
||||
}
|
||||
|
||||
func inboxDone(args []string, stdout io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent-notify inbox done <id...>")
|
||||
}
|
||||
n, err := inbox.NewStore("").MarkDone(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stdout, "marked %d done\n", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func inboxRemove(args []string, stdout io.Writer) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent-notify inbox rm <id...>")
|
||||
}
|
||||
n, err := inbox.NewStore("").Remove(args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stdout, "removed %d\n", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func inboxClear(args []string, stdout io.Writer) error {
|
||||
fs := flag.NewFlagSet("inbox clear", flag.ExitOnError)
|
||||
done := fs.Bool("done", false, "clear done records only")
|
||||
_ = fs.Parse(args)
|
||||
var (
|
||||
n int
|
||||
err error
|
||||
)
|
||||
if *done {
|
||||
n, err = inbox.NewStore("").ClearDone()
|
||||
} else {
|
||||
n, err = inbox.NewStore("").ClearAll()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stdout, "cleared %d\n", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
func inboxServe(args []string, stderr io.Writer) error {
|
||||
cfg, err := config.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fs := flag.NewFlagSet("inbox serve", flag.ExitOnError)
|
||||
socket := fs.String("socket", cfg.Inbox.Socket, "Unix socket path")
|
||||
addr := fs.String("addr", "", "TCP address")
|
||||
_ = fs.Parse(args)
|
||||
|
||||
handler := inbox.NewHandler(inbox.NewStore(""))
|
||||
server := &http.Server{Handler: handler}
|
||||
if *addr != "" {
|
||||
ln, err := net.Listen("tcp", *addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stderr, "agent-notify inbox listening on tcp %s\n", *addr)
|
||||
return server.Serve(ln)
|
||||
}
|
||||
if err := os.RemoveAll(*socket); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(*socket), 0700); err != nil {
|
||||
return err
|
||||
}
|
||||
ln, err := net.Listen("unix", *socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(*socket, 0600); err != nil {
|
||||
ln.Close()
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stderr, "agent-notify inbox listening on unix %s\n", *socket)
|
||||
return server.Serve(ln)
|
||||
}
|
||||
|
||||
func inboxSSHConfig(args []string, stdout io.Writer) error {
|
||||
if len(args) == 0 || args[0] != "install" {
|
||||
return fmt.Errorf("usage: agent-notify inbox ssh-config install [--path PATH] [--socket PATH]")
|
||||
}
|
||||
cfg, err := config.LoadDefault()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fs := flag.NewFlagSet("inbox ssh-config install", flag.ExitOnError)
|
||||
path := fs.String("path", inbox.DefaultSSHConfigPath(), "SSH config path")
|
||||
socket := fs.String("socket", cfg.Inbox.Socket, "local agent-notify Unix socket path")
|
||||
remoteSocket := fs.String("remote-socket", cfg.Inbox.RemoteSocket, "remote agent-notify Unix socket path")
|
||||
_ = fs.Parse(args[1:])
|
||||
|
||||
block, err := inbox.InstallSSHConfig(*path, *remoteSocket, *socket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(stdout, "wrote SSH config: %s\n\n%s\nReconnect existing SSH sessions for RemoteForward to take effect.\n", *path, block)
|
||||
return nil
|
||||
}
|
||||
|
||||
func findRecord(id string) (inbox.Record, bool, error) {
|
||||
records, err := inbox.NewStore("").List()
|
||||
if err != nil {
|
||||
return inbox.Record{}, false, err
|
||||
}
|
||||
for _, rec := range records {
|
||||
if rec.ID == id {
|
||||
return rec, true, nil
|
||||
}
|
||||
}
|
||||
return inbox.Record{}, false, nil
|
||||
}
|
||||
|
||||
func uploadTestRecord(cfg config.Config) error {
|
||||
rec := inbox.BuildRecord(inbox.BuildInput{
|
||||
Agent: "agent-notify",
|
||||
Event: "test",
|
||||
Title: "agent-notify test",
|
||||
Body: "inbox test",
|
||||
Source: inbox.SourceLocal,
|
||||
})
|
||||
timeout := time.Duration(cfg.Inbox.TimeoutMS) * time.Millisecond
|
||||
client := inbox.NewClient(inbox.ClientConfig{Socket: cfg.Inbox.Socket, Addr: cfg.Inbox.Addr, Timeout: timeout})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return client.Upload(ctx, rec)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/inbox"
|
||||
)
|
||||
|
||||
func TestInboxListAndDoneCommands(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
store := inbox.NewStore("")
|
||||
if err := store.Append(inbox.Record{ID: "id-1", Status: inbox.StatusPending, Host: "host-a", Agent: "Cursor", Event: "stop", CWD: "/tmp/proj", Title: "ready"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := cmdInbox([]string{"list"}, &out, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "id-1") || !strings.Contains(out.String(), "ready") {
|
||||
t.Fatalf("unexpected list output: %s", out.String())
|
||||
}
|
||||
|
||||
out.Reset()
|
||||
if err := cmdInbox([]string{"done", "id-1"}, &out, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "marked 1 done") {
|
||||
t.Fatalf("unexpected done output: %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := cmdInbox([]string{"list"}, &out, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(out.String(), "id-1") {
|
||||
t.Fatalf("done record should be hidden by default: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxShowAndRemoveCommands(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
store := inbox.NewStore("")
|
||||
if err := store.Append(inbox.Record{ID: "id-1", Status: inbox.StatusPending, Body: "body text", Title: "title"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := cmdInbox([]string{"show", "id-1"}, &out, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "body text") {
|
||||
t.Fatalf("unexpected show output: %s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
if err := cmdInbox([]string{"rm", "id-1"}, &out, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "removed 1") {
|
||||
t.Fatalf("unexpected rm output: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxSSHConfigInstallCommandPrintsBlock(t *testing.T) {
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
path := filepath.Join(t.TempDir(), "ssh_config")
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := cmdInbox([]string{"ssh-config", "install", "--path", path, "--remote-socket", "/tmp/remote-agent-notify.sock", "--socket", "/run/user/1000/agent-notify.sock"}, &out, &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(data), "RemoteForward /tmp/remote-agent-notify.sock /run/user/1000/agent-notify.sock") {
|
||||
t.Fatalf("config not written: %s", string(data))
|
||||
}
|
||||
if !strings.Contains(out.String(), "wrote SSH config") || !strings.Contains(out.String(), "# BEGIN agent-notify inbox") {
|
||||
t.Fatalf("block not printed: %s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ func run(cmd string, args []string) error {
|
||||
return cmdDoctor()
|
||||
case "logs":
|
||||
return cmdLogs(args)
|
||||
case "inbox":
|
||||
return cmdInbox(args, os.Stdout, os.Stderr)
|
||||
case "version", "-V":
|
||||
return cmdVersion()
|
||||
case "help", "-h", "--help":
|
||||
@@ -202,6 +204,7 @@ Commands:
|
||||
test cursor [--try-all]
|
||||
test claude [--apply]
|
||||
logs [--tail 30]
|
||||
inbox <list|show|done|rm|clear|serve|ssh-config>
|
||||
version
|
||||
doctor
|
||||
`)
|
||||
|
||||
Reference in New Issue
Block a user