Implement inbox read cursors for unread threads

This commit is contained in:
2026-03-19 03:43:10 +08:00
parent 02d98a78dd
commit 1927930570
19 changed files with 240 additions and 38 deletions
+1 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
}
+22
View File
@@ -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
}
+1 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
}
+1 -6
View File
@@ -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",
+103
View File
@@ -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()
+1 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
}
+1 -2
View File
@@ -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
}
+9 -3
View File
@@ -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
+1 -2
View File
@@ -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
}
+3 -2
View File
@@ -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 {
+1 -2
View File
@@ -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
}