Implement inbox read cursors for unread threads
This commit is contained in:
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -38,7 +37,7 @@ func newCancelCmd(root *rootOptions) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -34,7 +33,7 @@ func newClaimCmd(root *rootOptions) *cobra.Command {
|
||||
return protocol.InvalidInput("agent is required", nil)
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package inbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
)
|
||||
|
||||
func openInboxDB(ctx context.Context, dbPath string) (*sql.DB, error) {
|
||||
sqlDB, err := db.Open(ctx, dbPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := db.ApplyMigrations(ctx, sqlDB); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return sqlDB, nil
|
||||
}
|
||||
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -54,7 +53,7 @@ func newCompleteCmd(root *rootOptions, mode string) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -32,7 +31,7 @@ func newFetchCmd(root *rootOptions) *cobra.Command {
|
||||
agent = root.agent
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -16,16 +15,12 @@ func newInitCmd(opts *rootOptions) *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := db.Open(ctx, opts.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, opts.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if err := db.ApplyMigrations(ctx, sqlDB); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp := protocol.Success{
|
||||
OK: true,
|
||||
Command: "init",
|
||||
|
||||
@@ -316,6 +316,7 @@ func TestInboxRenewWaitReplyAndCancel(t *testing.T) {
|
||||
go func() {
|
||||
stdout, stderr, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-c",
|
||||
"--json",
|
||||
"wait-reply",
|
||||
"--thread", threadID,
|
||||
@@ -359,6 +360,18 @@ func TestInboxRenewWaitReplyAndCancel(t *testing.T) {
|
||||
t.Fatalf("expected answer wake message, got %q", kind)
|
||||
}
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-c",
|
||||
"--json",
|
||||
"fetch",
|
||||
"--status", "blocked",
|
||||
"--unread",
|
||||
)
|
||||
if exitCode != 10 {
|
||||
t.Fatalf("expected blocked unread list to be cleared after wait-reply, got exit %d with %s", exitCode, stdout)
|
||||
}
|
||||
|
||||
cancelOut := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
@@ -533,6 +546,96 @@ func TestInboxWatchListUnreadAndAppend(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxUnreadReadCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dbPath := filepath.Join(t.TempDir(), "coord.db")
|
||||
|
||||
runInboxCommand(t, "--db", dbPath, "--json", "init")
|
||||
|
||||
sendOut := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--from", "leader",
|
||||
"--to", "worker-e",
|
||||
"--subject", "Review navbar copy",
|
||||
"--summary", "Check top nav wording",
|
||||
)
|
||||
|
||||
var sendResp map[string]any
|
||||
mustDecodeJSON(t, sendOut, &sendResp)
|
||||
threadID := nestedString(t, sendResp, "data", "thread", "thread_id")
|
||||
|
||||
fetchOut := runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"fetch",
|
||||
"--agent", "worker-e",
|
||||
"--status", "pending",
|
||||
"--unread",
|
||||
)
|
||||
|
||||
var fetchResp map[string]any
|
||||
mustDecodeJSON(t, fetchOut, &fetchResp)
|
||||
threads, ok := nestedValue(t, fetchResp, "data", "threads").([]any)
|
||||
if !ok || len(threads) != 1 {
|
||||
t.Fatalf("expected one unread pending thread, got %#v", nestedValue(t, fetchResp, "data", "threads"))
|
||||
}
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--agent", "worker-e",
|
||||
"--json",
|
||||
"show",
|
||||
"--thread", threadID,
|
||||
"--mark-read",
|
||||
)
|
||||
|
||||
stdout, _, exitCode := executeInboxCommand(
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"fetch",
|
||||
"--agent", "worker-e",
|
||||
"--status", "pending",
|
||||
"--unread",
|
||||
)
|
||||
if exitCode != 10 {
|
||||
t.Fatalf("expected unread fetch to clear after mark-read, got exit %d with %s", exitCode, stdout)
|
||||
}
|
||||
|
||||
runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"send",
|
||||
"--from", "leader",
|
||||
"--to", "worker-e",
|
||||
"--thread", threadID,
|
||||
"--summary", "Use sentence case",
|
||||
"--body", "Keep the nav labels in sentence case.",
|
||||
)
|
||||
|
||||
fetchOut = runInboxCommand(
|
||||
t,
|
||||
"--db", dbPath,
|
||||
"--json",
|
||||
"fetch",
|
||||
"--agent", "worker-e",
|
||||
"--status", "pending",
|
||||
"--unread",
|
||||
)
|
||||
|
||||
mustDecodeJSON(t, fetchOut, &fetchResp)
|
||||
threads, ok = nestedValue(t, fetchResp, "data", "threads").([]any)
|
||||
if !ok || len(threads) != 1 {
|
||||
t.Fatalf("expected unread thread to reappear after new message, got %#v", nestedValue(t, fetchResp, "data", "threads"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxJSONErrorsAndExitCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -32,7 +31,7 @@ func newListCmd(root *rootOptions) *cobra.Command {
|
||||
agent = root.agent
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -33,7 +32,7 @@ func newRenewCmd(root *rootOptions) *cobra.Command {
|
||||
return protocol.InvalidInput("agent is required", nil)
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -48,7 +47,7 @@ func newReplyCmd(root *rootOptions) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -55,7 +54,7 @@ func newSendCmd(root *rootOptions) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
|
||||
type showOptions struct {
|
||||
threadID string
|
||||
markRead bool
|
||||
}
|
||||
|
||||
func newShowCmd(root *rootOptions) *cobra.Command {
|
||||
@@ -23,14 +23,19 @@ func newShowCmd(root *rootOptions) *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
s := store.NewInboxStore(sqlDB)
|
||||
detail, err := s.GetThread(ctx, opts.threadID)
|
||||
agent := root.agent
|
||||
if opts.markRead && agent == "" {
|
||||
return protocol.InvalidInput("agent is required when using --mark-read", nil)
|
||||
}
|
||||
|
||||
detail, err := s.GetThreadForAgent(ctx, opts.threadID, agent, opts.markRead)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -66,6 +71,7 @@ func newShowCmd(root *rootOptions) *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID")
|
||||
cmd.Flags().BoolVar(&opts.markRead, "mark-read", false, "Advance the caller's read cursor to the latest message")
|
||||
_ = cmd.MarkFlagRequired("thread")
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -3,7 +3,6 @@ package inbox
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -47,7 +46,7 @@ func newUpdateCmd(root *rootOptions) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -28,18 +27,20 @@ func newWaitReplyCmd(root *rootOptions) *cobra.Command {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
s := store.NewInboxStore(sqlDB)
|
||||
agent := root.agent
|
||||
result, err := s.WaitReply(ctx, store.WaitReplyInput{
|
||||
ThreadID: opts.threadID,
|
||||
AfterMessageID: opts.afterMessageID,
|
||||
AfterEventID: opts.afterEventID,
|
||||
Kinds: parseCSV(opts.kinds),
|
||||
Agent: agent,
|
||||
Timeout: time.Duration(opts.timeoutSeconds) * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"ai-workflow-skill/internal/db"
|
||||
"ai-workflow-skill/internal/protocol"
|
||||
"ai-workflow-skill/internal/store"
|
||||
|
||||
@@ -32,7 +31,7 @@ func newWatchCmd(root *rootOptions) *cobra.Command {
|
||||
agent = root.agent
|
||||
}
|
||||
|
||||
sqlDB, err := db.Open(ctx, root.dbPath)
|
||||
sqlDB, err := openInboxDB(ctx, root.dbPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user