feat: add inbox
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
type Config struct {
|
||||
Events Events `toml:"events"`
|
||||
Notify Notify `toml:"notify"`
|
||||
Inbox Inbox `toml:"inbox"`
|
||||
}
|
||||
|
||||
type Events struct {
|
||||
@@ -28,6 +29,15 @@ type Notify struct {
|
||||
BodyTool string `toml:"body_tool"`
|
||||
}
|
||||
|
||||
type Inbox struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
Socket string `toml:"socket"`
|
||||
RemoteSocket string `toml:"remote_socket"`
|
||||
Addr string `toml:"addr"`
|
||||
FallbackLocal bool `toml:"fallback_local"`
|
||||
TimeoutMS int `toml:"timeout_ms"`
|
||||
}
|
||||
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Events: Events{Stop: true, Response: true, Idle: false, Tool: false},
|
||||
@@ -38,9 +48,32 @@ func Default() Config {
|
||||
BodyIdle: "空闲 60s+,等待输入",
|
||||
BodyTool: "工具执行完成",
|
||||
},
|
||||
Inbox: Inbox{
|
||||
Enabled: true,
|
||||
Socket: DefaultInboxSocket(),
|
||||
RemoteSocket: DefaultInboxRemoteSocket(),
|
||||
Addr: "127.0.0.1:17777",
|
||||
FallbackLocal: true,
|
||||
TimeoutMS: 500,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultInboxRemoteSocket() string {
|
||||
user := os.Getenv("USER")
|
||||
if user == "" {
|
||||
user = "user"
|
||||
}
|
||||
return filepath.Join("/tmp", "agent-notify-"+user+".sock")
|
||||
}
|
||||
|
||||
func DefaultInboxSocket() string {
|
||||
if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" {
|
||||
return filepath.Join(runtimeDir, "agent-notify.sock")
|
||||
}
|
||||
return filepath.Join(os.Getenv("HOME"), ".local", "state", "agent-notify", "agent-notify.sock")
|
||||
}
|
||||
|
||||
func DefaultPath() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".config", "agent-notify", "config.toml")
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
t.Setenv("USER", "example")
|
||||
cfg := Default()
|
||||
if !cfg.Events.Stop {
|
||||
t.Fatal("expected stop=true by default")
|
||||
@@ -17,6 +18,24 @@ func TestDefaultConfig(t *testing.T) {
|
||||
if cfg.Notify.Protocol != "osc777" {
|
||||
t.Fatalf("expected osc777, got %q", cfg.Notify.Protocol)
|
||||
}
|
||||
if !cfg.Inbox.Enabled {
|
||||
t.Fatal("expected inbox enabled by default")
|
||||
}
|
||||
if cfg.Inbox.Addr != "127.0.0.1:17777" {
|
||||
t.Fatalf("expected default inbox addr, got %q", cfg.Inbox.Addr)
|
||||
}
|
||||
if cfg.Inbox.Socket == "" {
|
||||
t.Fatal("expected default inbox socket")
|
||||
}
|
||||
if cfg.Inbox.RemoteSocket != "/tmp/agent-notify-example.sock" {
|
||||
t.Fatalf("expected default remote socket, got %q", cfg.Inbox.RemoteSocket)
|
||||
}
|
||||
if !cfg.Inbox.FallbackLocal {
|
||||
t.Fatal("expected local fallback enabled by default")
|
||||
}
|
||||
if cfg.Inbox.TimeoutMS != 500 {
|
||||
t.Fatalf("expected 500ms timeout, got %d", cfg.Inbox.TimeoutMS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFromFile(t *testing.T) {
|
||||
@@ -29,6 +48,14 @@ tool = true
|
||||
|
||||
[notify]
|
||||
body_stop = "custom stop"
|
||||
|
||||
[inbox]
|
||||
enabled = false
|
||||
socket = "/tmp/custom-agent-notify.sock"
|
||||
remote_socket = "/tmp/custom-remote-agent-notify.sock"
|
||||
addr = "127.0.0.1:18888"
|
||||
fallback_local = false
|
||||
timeout_ms = 250
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -46,6 +73,35 @@ body_stop = "custom stop"
|
||||
if cfg.Notify.BodyStop != "custom stop" {
|
||||
t.Fatalf("got %q", cfg.Notify.BodyStop)
|
||||
}
|
||||
if cfg.Inbox.Enabled {
|
||||
t.Fatal("expected inbox disabled")
|
||||
}
|
||||
if cfg.Inbox.Socket != "/tmp/custom-agent-notify.sock" {
|
||||
t.Fatalf("got inbox socket %q", cfg.Inbox.Socket)
|
||||
}
|
||||
if cfg.Inbox.RemoteSocket != "/tmp/custom-remote-agent-notify.sock" {
|
||||
t.Fatalf("got inbox remote socket %q", cfg.Inbox.RemoteSocket)
|
||||
}
|
||||
if cfg.Inbox.Addr != "127.0.0.1:18888" {
|
||||
t.Fatalf("got inbox addr %q", cfg.Inbox.Addr)
|
||||
}
|
||||
if cfg.Inbox.FallbackLocal {
|
||||
t.Fatal("expected fallback_local=false")
|
||||
}
|
||||
if cfg.Inbox.TimeoutMS != 250 {
|
||||
t.Fatalf("got timeout %d", cfg.Inbox.TimeoutMS)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultInboxSocketPrefersXDGRuntimeDir(t *testing.T) {
|
||||
t.Setenv("XDG_RUNTIME_DIR", "/run/user/1234")
|
||||
t.Setenv("HOME", "/home/example")
|
||||
|
||||
got := DefaultInboxSocket()
|
||||
want := "/run/user/1234/agent-notify.sock"
|
||||
if got != want {
|
||||
t.Fatalf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventEnabled(t *testing.T) {
|
||||
|
||||
@@ -53,5 +53,6 @@ func RunClaude(r io.Reader, cfg config.Config, event string, w io.Writer) error
|
||||
return err
|
||||
}
|
||||
logx.Append("hook claude event=%s terminalSequence OK title=%q", event, title)
|
||||
recordInbox(cfg, "Claude", event, meta.CWD, title, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func RunCursor(r io.Reader, cfg config.Config, event string, _ io.Writer) error
|
||||
return err
|
||||
}
|
||||
logx.Append("hook cursor event=%s send OK via %s title=%q", event, result.Method, title)
|
||||
recordInbox(cfg, "Cursor", event, meta.CWD, title, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,10 +3,12 @@ package hook
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/inbox"
|
||||
)
|
||||
|
||||
func TestCursorStopHookDisabled(t *testing.T) {
|
||||
@@ -46,3 +48,105 @@ func TestClaudeStopHookActiveSkips(t *testing.T) {
|
||||
t.Fatalf("expected {}, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorHookUploadsInboxRecord(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
var uploaded inbox.Record
|
||||
stubInbox(t,
|
||||
func(rec inbox.Record, cfg config.Config) error {
|
||||
uploaded = rec
|
||||
return nil
|
||||
},
|
||||
func(rec inbox.Record) error {
|
||||
t.Fatalf("unexpected fallback append: %+v", rec)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if uploaded.Agent != "Cursor" || uploaded.Event != "stop" || uploaded.CWD != "/tmp/proj" {
|
||||
t.Fatalf("unexpected upload record: %+v", uploaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCursorHookFallbacksWhenInboxUploadFails(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
var fallback inbox.Record
|
||||
stubInbox(t,
|
||||
func(rec inbox.Record, cfg config.Config) error {
|
||||
return errors.New("offline")
|
||||
},
|
||||
func(rec inbox.Record) error {
|
||||
fallback = rec
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
err := RunCursor(bytes.NewReader([]byte(`{"workspace_roots":["/tmp/proj"]}`)), cfg, "stop", &bytes.Buffer{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fallback.Source != inbox.SourceFallback || fallback.Title == "" {
|
||||
t.Fatalf("unexpected fallback record: %+v", fallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledHookDoesNotRecordInbox(t *testing.T) {
|
||||
stubCursorSend(t)
|
||||
stubInbox(t,
|
||||
func(rec inbox.Record, cfg config.Config) error {
|
||||
t.Fatalf("unexpected upload: %+v", rec)
|
||||
return nil
|
||||
},
|
||||
func(rec inbox.Record) error {
|
||||
t.Fatalf("unexpected fallback: %+v", rec)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
cfg.Events.Stop = false
|
||||
if err := RunCursor(bytes.NewReader([]byte(`{}`)), cfg, "stop", &bytes.Buffer{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeHookUploadsInboxRecord(t *testing.T) {
|
||||
var uploaded inbox.Record
|
||||
stubInbox(t,
|
||||
func(rec inbox.Record, cfg config.Config) error {
|
||||
uploaded = rec
|
||||
return nil
|
||||
},
|
||||
func(rec inbox.Record) error {
|
||||
t.Fatalf("unexpected fallback append: %+v", rec)
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
cfg := config.Default()
|
||||
var out bytes.Buffer
|
||||
if err := RunClaude(strings.NewReader(`{"stop_hook_active":false}`), cfg, "stop", &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if uploaded.Agent != "Claude" || uploaded.Event != "stop" || uploaded.Title == "" {
|
||||
t.Fatalf("unexpected upload record: %+v", uploaded)
|
||||
}
|
||||
}
|
||||
|
||||
func stubInbox(t *testing.T, upload func(inbox.Record, config.Config) error, appendLocal func(inbox.Record) error) {
|
||||
t.Helper()
|
||||
prevUpload := uploadInboxRecord
|
||||
prevAppend := appendInboxRecord
|
||||
uploadInboxRecord = upload
|
||||
appendInboxRecord = appendLocal
|
||||
t.Cleanup(func() {
|
||||
uploadInboxRecord = prevUpload
|
||||
appendInboxRecord = prevAppend
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package hook
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/longbin/agent-notify/internal/config"
|
||||
"github.com/longbin/agent-notify/internal/inbox"
|
||||
"github.com/longbin/agent-notify/internal/logx"
|
||||
)
|
||||
|
||||
var (
|
||||
uploadInboxRecord = defaultUploadInboxRecord
|
||||
appendInboxRecord = func(rec inbox.Record) error {
|
||||
return inbox.NewStore("").Append(rec)
|
||||
}
|
||||
)
|
||||
|
||||
func recordInbox(cfg config.Config, agent, event, cwd, title, body string) {
|
||||
if !cfg.Inbox.Enabled {
|
||||
return
|
||||
}
|
||||
rec := inbox.BuildRecord(inbox.BuildInput{
|
||||
Agent: agent,
|
||||
Event: event,
|
||||
CWD: cwd,
|
||||
Title: title,
|
||||
Body: body,
|
||||
Source: inbox.SourceRemote,
|
||||
})
|
||||
if err := uploadInboxRecord(rec, cfg); err != nil {
|
||||
logx.Append("inbox upload failed: %v", err)
|
||||
if !cfg.Inbox.FallbackLocal {
|
||||
return
|
||||
}
|
||||
rec.Source = inbox.SourceFallback
|
||||
if err := appendInboxRecord(rec); err != nil {
|
||||
logx.Append("inbox fallback append failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func defaultUploadInboxRecord(rec inbox.Record, cfg config.Config) error {
|
||||
timeout := time.Duration(cfg.Inbox.TimeoutMS) * time.Millisecond
|
||||
client := inbox.NewClient(inbox.ClientConfig{
|
||||
Socket: cfg.Inbox.RemoteSocket,
|
||||
Addr: cfg.Inbox.Addr,
|
||||
Timeout: timeout,
|
||||
})
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return client.Upload(ctx, rec)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BuildInput struct {
|
||||
Agent string
|
||||
Event string
|
||||
CWD string
|
||||
Title string
|
||||
Body string
|
||||
Source string
|
||||
}
|
||||
|
||||
func BuildRecord(input BuildInput) Record {
|
||||
host, _ := os.Hostname()
|
||||
source := input.Source
|
||||
if source == "" {
|
||||
source = SourceLocal
|
||||
}
|
||||
return Record{
|
||||
ID: NewID(time.Now()),
|
||||
Time: time.Now(),
|
||||
Host: host,
|
||||
Agent: input.Agent,
|
||||
Event: input.Event,
|
||||
CWD: input.CWD,
|
||||
Title: input.Title,
|
||||
Body: input.Body,
|
||||
Status: StatusPending,
|
||||
Source: source,
|
||||
Tmux: TmuxContext{
|
||||
Pane: os.Getenv("TMUX_PANE"),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package inbox
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBuildRecordPopulatesMetadata(t *testing.T) {
|
||||
t.Setenv("TMUX_PANE", "%12")
|
||||
|
||||
rec := BuildRecord(BuildInput{
|
||||
Agent: "Cursor",
|
||||
Event: "stop",
|
||||
CWD: "/work/proj",
|
||||
Title: "Cursor - proj",
|
||||
Body: "等待输入",
|
||||
Source: SourceLocal,
|
||||
})
|
||||
|
||||
if rec.ID == "" {
|
||||
t.Fatal("expected id")
|
||||
}
|
||||
if rec.Time.IsZero() {
|
||||
t.Fatal("expected time")
|
||||
}
|
||||
if rec.Host == "" {
|
||||
t.Fatal("expected host")
|
||||
}
|
||||
if rec.Agent != "Cursor" || rec.Event != "stop" || rec.CWD != "/work/proj" {
|
||||
t.Fatalf("unexpected record: %+v", rec)
|
||||
}
|
||||
if rec.Status != StatusPending || rec.Source != SourceLocal {
|
||||
t.Fatalf("unexpected status/source: %+v", rec)
|
||||
}
|
||||
if rec.Tmux.Pane != "%12" {
|
||||
t.Fatalf("unexpected tmux pane: %+v", rec.Tmux)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ClientConfig struct {
|
||||
URL string
|
||||
Socket string
|
||||
Addr string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
cfg ClientConfig
|
||||
}
|
||||
|
||||
func NewClient(cfg ClientConfig) Client {
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 500 * time.Millisecond
|
||||
}
|
||||
return Client{cfg: cfg}
|
||||
}
|
||||
|
||||
func (c Client) Upload(ctx context.Context, rec Record) error {
|
||||
body, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := c.url()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url+"/inbox", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: c.cfg.Timeout}
|
||||
if c.cfg.Socket != "" {
|
||||
socket := c.cfg.Socket
|
||||
client.Transport = &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
},
|
||||
}
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return fmt.Errorf("inbox upload failed: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Client) url() string {
|
||||
if c.cfg.URL != "" {
|
||||
return strings.TrimRight(c.cfg.URL, "/")
|
||||
}
|
||||
if c.cfg.Socket != "" {
|
||||
return "http://unix"
|
||||
}
|
||||
if c.cfg.Addr != "" {
|
||||
return "http://" + c.cfg.Addr
|
||||
}
|
||||
return "http://127.0.0.1:17777"
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package inbox
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusDone = "done"
|
||||
)
|
||||
|
||||
const (
|
||||
SourceLocal = "local"
|
||||
SourceRemote = "remote"
|
||||
SourceFallback = "fallback"
|
||||
)
|
||||
|
||||
type TmuxContext struct {
|
||||
Session string `json:"session,omitempty"`
|
||||
Window string `json:"window,omitempty"`
|
||||
Pane string `json:"pane,omitempty"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
Event string `json:"event,omitempty"`
|
||||
CWD string `json:"cwd,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Tmux TmuxContext `json:"tmux,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func NewHandler(store Store) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/inbox", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
var rec Record
|
||||
if err := json.NewDecoder(r.Body).Decode(&rec); err != nil {
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
CompleteRecord(&rec)
|
||||
if err := store.Append(rec); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "id": rec.ID})
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
func CompleteRecord(rec *Record) {
|
||||
if rec.ID == "" {
|
||||
rec.ID = NewID(time.Now())
|
||||
}
|
||||
if rec.Time.IsZero() {
|
||||
rec.Time = time.Now()
|
||||
}
|
||||
if rec.Status == "" {
|
||||
rec.Status = StatusPending
|
||||
}
|
||||
}
|
||||
|
||||
func NewID(t time.Time) string {
|
||||
var b [3]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("%s-000000", t.Format("20060102-150405"))
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", t.Format("20060102-150405"), hex.EncodeToString(b[:]))
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHandlerAppendsInboxRecord(t *testing.T) {
|
||||
store := NewStore(t.TempDir() + "/inbox.jsonl")
|
||||
handler := NewHandler(store)
|
||||
body := bytes.NewBufferString(`{"host":"remote-a","agent":"cursor","event":"stop","title":"done"}`)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/inbox", body)
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
recs, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||
}
|
||||
if recs[0].ID == "" || recs[0].Time.IsZero() || recs[0].Status != StatusPending {
|
||||
t.Fatalf("record not completed: %+v", recs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRejectsInvalidRequests(t *testing.T) {
|
||||
handler := NewHandler(NewStore(t.TempDir() + "/inbox.jsonl"))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
body string
|
||||
want int
|
||||
}{
|
||||
{name: "method", method: http.MethodGet, body: `{}`, want: http.StatusMethodNotAllowed},
|
||||
{name: "json", method: http.MethodPost, body: `{bad`, want: http.StatusBadRequest},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tc.method, "/inbox", bytes.NewBufferString(tc.body))
|
||||
rr := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rr, req)
|
||||
if rr.Code != tc.want {
|
||||
t.Fatalf("status=%d want=%d", rr.Code, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientPostsRecord(t *testing.T) {
|
||||
var got Record
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/inbox" {
|
||||
t.Fatalf("path=%s", r.URL.Path)
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(ClientConfig{URL: server.URL, Timeout: time.Second})
|
||||
if err := client.Upload(context.Background(), Record{ID: "id-1", Title: "ready"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.ID != "id-1" || got.Title != "ready" {
|
||||
t.Fatalf("unexpected upload body: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientReturnsErrorForServerFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nope", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(ClientConfig{URL: server.URL, Timeout: time.Second})
|
||||
if err := client.Upload(context.Background(), Record{}); err == nil {
|
||||
t.Fatal("expected upload error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
sshConfigBegin = "# BEGIN agent-notify inbox"
|
||||
sshConfigEnd = "# END agent-notify inbox"
|
||||
)
|
||||
|
||||
func DefaultSSHConfigPath() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".ssh", "config")
|
||||
}
|
||||
|
||||
func SSHConfigBlock(remoteSocket, localSocket string) string {
|
||||
return strings.Join([]string{
|
||||
sshConfigBegin,
|
||||
"Host *",
|
||||
" RemoteForward " + remoteSocket + " " + localSocket,
|
||||
" ExitOnForwardFailure no",
|
||||
" ServerAliveInterval 30",
|
||||
sshConfigEnd,
|
||||
"",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func InstallSSHConfig(path, remoteSocket, localSocket string) (string, error) {
|
||||
if path == "" {
|
||||
path = DefaultSSHConfigPath()
|
||||
}
|
||||
block := SSHConfigBlock(remoteSocket, localSocket)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var current string
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
current = string(data)
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
next := replaceManagedBlock(current, block)
|
||||
if err := os.WriteFile(path, []byte(next), 0600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func replaceManagedBlock(current, block string) string {
|
||||
start := strings.Index(current, sshConfigBegin)
|
||||
end := strings.Index(current, sshConfigEnd)
|
||||
if start >= 0 && end >= start {
|
||||
end += len(sshConfigEnd)
|
||||
for end < len(current) && (current[end] == '\n' || current[end] == '\r') {
|
||||
end++
|
||||
}
|
||||
prefix := strings.TrimRight(current[:start], "\n")
|
||||
suffix := strings.TrimLeft(current[end:], "\n")
|
||||
var parts []string
|
||||
if prefix != "" {
|
||||
parts = append(parts, prefix)
|
||||
}
|
||||
parts = append(parts, strings.TrimRight(block, "\n"))
|
||||
if suffix != "" {
|
||||
parts = append(parts, suffix)
|
||||
}
|
||||
return strings.Join(parts, "\n\n") + "\n"
|
||||
}
|
||||
if strings.TrimSpace(current) == "" {
|
||||
return block
|
||||
}
|
||||
return strings.TrimRight(current, "\n") + "\n\n" + block
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallSSHConfigCreatesManagedBlock(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config")
|
||||
|
||||
block, err := InstallSSHConfig(path, "/tmp/remote-agent-notify.sock", "/run/user/1000/agent-notify.sock")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
if !strings.Contains(text, "# BEGIN agent-notify inbox") {
|
||||
t.Fatalf("missing managed block: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "RemoteForward /tmp/remote-agent-notify.sock /run/user/1000/agent-notify.sock") {
|
||||
t.Fatalf("missing RemoteForward: %s", text)
|
||||
}
|
||||
if !strings.Contains(block, "Host *") || !strings.Contains(block, "RemoteForward") {
|
||||
t.Fatalf("unexpected returned block: %s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallSSHConfigReplacesManagedBlockAndPreservesUserConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "config")
|
||||
existing := `Host prod
|
||||
HostName prod.example
|
||||
|
||||
# BEGIN agent-notify inbox
|
||||
Host *
|
||||
RemoteForward /old.sock /old.sock
|
||||
# END agent-notify inbox
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(existing), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err := InstallSSHConfig(path, "/remote-new.sock", "/local-new.sock")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(data)
|
||||
if strings.Count(text, "# BEGIN agent-notify inbox") != 1 {
|
||||
t.Fatalf("expected one managed block: %s", text)
|
||||
}
|
||||
if !strings.Contains(text, "Host prod") {
|
||||
t.Fatalf("user config not preserved: %s", text)
|
||||
}
|
||||
if strings.Contains(text, "/old.sock") || !strings.Contains(text, "/remote-new.sock") || !strings.Contains(text, "/local-new.sock") {
|
||||
t.Fatalf("block not replaced: %s", text)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
}
|
||||
|
||||
func DefaultStorePath() string {
|
||||
return filepath.Join(os.Getenv("HOME"), ".local", "state", "agent-notify", "inbox.jsonl")
|
||||
}
|
||||
|
||||
func NewStore(path string) Store {
|
||||
if path == "" {
|
||||
path = DefaultStorePath()
|
||||
}
|
||||
return Store{path: path}
|
||||
}
|
||||
|
||||
func (s Store) Path() string {
|
||||
return s.path
|
||||
}
|
||||
|
||||
func (s Store) Append(rec Record) error {
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
if err := json.NewEncoder(f).Encode(rec); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Store) List() ([]Record, error) {
|
||||
f, err := os.Open(s.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var records []Record
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
var rec Record
|
||||
if err := json.Unmarshal(scanner.Bytes(), &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
func (s Store) Pending() ([]Record, error) {
|
||||
records, err := s.List()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var pending []Record
|
||||
for _, rec := range records {
|
||||
if rec.Status == StatusPending {
|
||||
pending = append(pending, rec)
|
||||
}
|
||||
}
|
||||
return pending, nil
|
||||
}
|
||||
|
||||
func (s Store) MarkDone(ids []string) (int, error) {
|
||||
idSet := makeSet(ids)
|
||||
return s.rewrite(func(rec Record) (Record, bool, bool) {
|
||||
if _, ok := idSet[rec.ID]; !ok {
|
||||
return rec, true, false
|
||||
}
|
||||
if rec.Status == StatusDone {
|
||||
return rec, true, false
|
||||
}
|
||||
rec.Status = StatusDone
|
||||
return rec, true, true
|
||||
})
|
||||
}
|
||||
|
||||
func (s Store) Remove(ids []string) (int, error) {
|
||||
idSet := makeSet(ids)
|
||||
return s.rewrite(func(rec Record) (Record, bool, bool) {
|
||||
if _, ok := idSet[rec.ID]; ok {
|
||||
return rec, false, true
|
||||
}
|
||||
return rec, true, false
|
||||
})
|
||||
}
|
||||
|
||||
func (s Store) ClearDone() (int, error) {
|
||||
return s.rewrite(func(rec Record) (Record, bool, bool) {
|
||||
if rec.Status == StatusDone {
|
||||
return rec, false, true
|
||||
}
|
||||
return rec, true, false
|
||||
})
|
||||
}
|
||||
|
||||
func (s Store) ClearAll() (int, error) {
|
||||
records, err := s.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := os.WriteFile(s.path, nil, 0644); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return len(records), nil
|
||||
}
|
||||
|
||||
func (s Store) rewrite(fn func(Record) (Record, bool, bool)) (int, error) {
|
||||
records, err := s.List()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(s.path), 0755); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(s.path), "inbox-*.jsonl")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
changed := 0
|
||||
enc := json.NewEncoder(tmp)
|
||||
for _, rec := range records {
|
||||
next, keep, didChange := fn(rec)
|
||||
if didChange {
|
||||
changed++
|
||||
}
|
||||
if !keep {
|
||||
continue
|
||||
}
|
||||
if err := enc.Encode(next); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return 0, err
|
||||
}
|
||||
if err := os.Rename(tmpPath, s.path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return 0, err
|
||||
}
|
||||
return changed, nil
|
||||
}
|
||||
|
||||
func makeSet(values []string) map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
set[value] = struct{}{}
|
||||
}
|
||||
return set
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAppendCreatesJSONLFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
store := NewStore(filepath.Join(dir, "inbox.jsonl"))
|
||||
|
||||
rec := Record{
|
||||
ID: "id-1",
|
||||
Time: time.Date(2026, 5, 26, 16, 0, 0, 0, time.UTC),
|
||||
Host: "host-a",
|
||||
Agent: "cursor",
|
||||
Event: "stop",
|
||||
CWD: "/work/proj",
|
||||
Title: "Cursor - proj",
|
||||
Body: "等待输入",
|
||||
Status: StatusPending,
|
||||
}
|
||||
if err := store.Append(rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, "inbox.jsonl")
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("expected inbox file: %v", err)
|
||||
}
|
||||
recs, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 1 {
|
||||
t.Fatalf("expected 1 record, got %d", len(recs))
|
||||
}
|
||||
if recs[0].ID != "id-1" || recs[0].Status != StatusPending {
|
||||
t.Fatalf("unexpected record: %+v", recs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingFiltersDoneRecords(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
|
||||
mustAppend(t, store, Record{ID: "pending", Status: StatusPending})
|
||||
mustAppend(t, store, Record{ID: "done", Status: StatusDone})
|
||||
|
||||
recs, err := store.Pending()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 1 || recs[0].ID != "pending" {
|
||||
t.Fatalf("unexpected pending records: %+v", recs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkDoneRewritesMatchingRecords(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
|
||||
mustAppend(t, store, Record{ID: "a", Status: StatusPending})
|
||||
mustAppend(t, store, Record{ID: "b", Status: StatusPending})
|
||||
|
||||
n, err := store.MarkDone([]string{"b"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 updated, got %d", n)
|
||||
}
|
||||
recs, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recs[0].Status != StatusPending || recs[1].Status != StatusDone {
|
||||
t.Fatalf("unexpected statuses: %+v", recs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveAndClearDone(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
|
||||
mustAppend(t, store, Record{ID: "a", Status: StatusPending})
|
||||
mustAppend(t, store, Record{ID: "b", Status: StatusDone})
|
||||
mustAppend(t, store, Record{ID: "c", Status: StatusPending})
|
||||
|
||||
removed, err := store.Remove([]string{"a"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("expected 1 removed, got %d", removed)
|
||||
}
|
||||
cleared, err := store.ClearDone()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cleared != 1 {
|
||||
t.Fatalf("expected 1 cleared, got %d", cleared)
|
||||
}
|
||||
recs, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 1 || recs[0].ID != "c" {
|
||||
t.Fatalf("unexpected records: %+v", recs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSkipsInvalidJSONLLines(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "inbox.jsonl")
|
||||
data := []byte("{bad json\n{\"id\":\"ok\",\"status\":\"pending\"}\n")
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
store := NewStore(path)
|
||||
|
||||
recs, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recs) != 1 || recs[0].ID != "ok" {
|
||||
t.Fatalf("unexpected records: %+v", recs)
|
||||
}
|
||||
}
|
||||
|
||||
func mustAppend(t *testing.T, store Store, rec Record) {
|
||||
t.Helper()
|
||||
if err := store.Append(rec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
type tuiModel struct {
|
||||
store Store
|
||||
records []Record
|
||||
cursor int
|
||||
detail bool
|
||||
message string
|
||||
}
|
||||
|
||||
func RunTUI(store Store) error {
|
||||
_, err := tea.NewProgram(newTUIModel(store)).Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func newTUIModel(store Store) tuiModel {
|
||||
model := tuiModel{store: store}
|
||||
model.reload()
|
||||
return model
|
||||
}
|
||||
|
||||
func (m tuiModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
key, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
switch key.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "up", "k":
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case "down", "j":
|
||||
if m.cursor < len(m.records)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case "enter":
|
||||
m.detail = !m.detail
|
||||
case "r":
|
||||
m.reload()
|
||||
case "d":
|
||||
if len(m.records) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
rec := m.records[m.cursor]
|
||||
n, err := m.store.MarkDone([]string{rec.ID})
|
||||
if err != nil {
|
||||
m.message = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
m.message = fmt.Sprintf("marked %d done", n)
|
||||
m.reload()
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m tuiModel) View() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("agent-notify inbox\n")
|
||||
b.WriteString("j/k move enter details d done r reload q quit\n\n")
|
||||
if m.message != "" {
|
||||
b.WriteString(m.message)
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
if len(m.records) == 0 {
|
||||
b.WriteString("No pending notifications.\n")
|
||||
return b.String()
|
||||
}
|
||||
for i, rec := range m.records {
|
||||
prefix := " "
|
||||
if i == m.cursor {
|
||||
prefix = "> "
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s %s %s/%s %s %s\n", prefix, rec.ID, rec.Host, rec.Agent, rec.Event, rec.CWD, rec.Title)
|
||||
if i == m.cursor && m.detail {
|
||||
if rec.Body != "" {
|
||||
fmt.Fprintf(&b, " body: %s\n", rec.Body)
|
||||
}
|
||||
if !rec.Time.IsZero() {
|
||||
fmt.Fprintf(&b, " time: %s\n", rec.Time.Local().Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
if rec.Tmux.Pane != "" {
|
||||
fmt.Fprintf(&b, " tmux pane: %s\n", rec.Tmux.Pane)
|
||||
}
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *tuiModel) reload() {
|
||||
records, err := m.store.Pending()
|
||||
if err != nil {
|
||||
m.message = err.Error()
|
||||
return
|
||||
}
|
||||
m.records = records
|
||||
if m.cursor >= len(m.records) {
|
||||
m.cursor = len(m.records) - 1
|
||||
}
|
||||
if m.cursor < 0 {
|
||||
m.cursor = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestTUIViewShowsRecords(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
|
||||
mustAppend(t, store, Record{ID: "id-1", Status: StatusPending, Host: "host", Agent: "Cursor", Event: "stop", Title: "ready"})
|
||||
|
||||
model := newTUIModel(store)
|
||||
view := model.View()
|
||||
if !strings.Contains(view, "ready") || !strings.Contains(view, "id-1") {
|
||||
t.Fatalf("unexpected view: %s", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTUIDoneMarksSelectedRecordDone(t *testing.T) {
|
||||
store := NewStore(filepath.Join(t.TempDir(), "inbox.jsonl"))
|
||||
mustAppend(t, store, Record{ID: "id-1", Status: StatusPending, Title: "ready"})
|
||||
|
||||
model := newTUIModel(store)
|
||||
updated, _ := model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'d'}})
|
||||
model = updated.(tuiModel)
|
||||
|
||||
recs, err := store.List()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recs[0].Status != StatusDone {
|
||||
t.Fatalf("expected done, got %+v", recs[0])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user