Add inbox send fetch claim show commands

This commit is contained in:
2026-03-19 03:02:21 +08:00
parent 7b35f4dc5f
commit e9792dee88
7 changed files with 1085 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
package inbox
import (
"errors"
"fmt"
"ai-workflow-skill/internal/db"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type claimOptions struct {
agent string
threadID string
leaseSeconds int
}
func newClaimCmd(root *rootOptions) *cobra.Command {
opts := &claimOptions{}
cmd := &cobra.Command{
Use: "claim",
Short: "Acquire a lease on a pending thread",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
agent := opts.agent
if agent == "" {
agent = root.agent
}
if agent == "" {
return fmt.Errorf("agent is required")
}
sqlDB, err := db.Open(ctx, root.dbPath)
if err != nil {
return err
}
defer sqlDB.Close()
s := store.NewInboxStore(sqlDB)
result, err := s.ClaimThread(ctx, store.ClaimInput{
ThreadID: opts.threadID,
Agent: agent,
LeaseSeconds: opts.leaseSeconds,
})
if err != nil {
if errors.Is(err, store.ErrLeaseConflict) {
return fmt.Errorf("lease conflict: %w", err)
}
return err
}
resp := protocol.Success{
OK: true,
Command: "claim",
Data: map[string]any{
"thread": result.Thread,
"message": result.Message,
},
}
if root.json {
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "claimed thread %s\n", result.Thread.ThreadID)
return err
},
}
cmd.Flags().StringVar(&opts.agent, "agent", "", "Claiming agent")
cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID")
cmd.Flags().IntVar(&opts.leaseSeconds, "lease-seconds", 900, "Lease duration in seconds")
_ = cmd.MarkFlagRequired("thread")
return cmd
}
+92
View File
@@ -0,0 +1,92 @@
package inbox
import (
"fmt"
"strings"
"ai-workflow-skill/internal/db"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type fetchOptions struct {
agent string
statuses string
limit int
}
func newFetchCmd(root *rootOptions) *cobra.Command {
opts := &fetchOptions{}
cmd := &cobra.Command{
Use: "fetch",
Short: "List candidate threads for an agent",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
agent := opts.agent
if agent == "" {
agent = root.agent
}
sqlDB, err := db.Open(ctx, root.dbPath)
if err != nil {
return err
}
defer sqlDB.Close()
s := store.NewInboxStore(sqlDB)
threads, err := s.FetchThreads(ctx, store.FetchInput{
Agent: agent,
Statuses: parseCSV(opts.statuses),
Limit: opts.limit,
})
if err != nil {
return err
}
resp := protocol.Success{
OK: true,
Command: "fetch",
Data: map[string]any{
"threads": threads,
},
}
if root.json {
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
}
for _, thread := range threads {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", thread.ThreadID, thread.Status, thread.Subject); err != nil {
return err
}
}
return nil
},
}
cmd.Flags().StringVar(&opts.agent, "agent", "", "Assigned agent filter")
cmd.Flags().StringVar(&opts.statuses, "status", "pending", "Comma-separated status filter")
cmd.Flags().IntVar(&opts.limit, "limit", 20, "Maximum number of threads")
return cmd
}
func parseCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
raw := strings.Split(value, ",")
out := make([]string, 0, len(raw))
for _, entry := range raw {
entry = strings.TrimSpace(entry)
if entry != "" {
out = append(out, entry)
}
}
return out
}
+150
View File
@@ -0,0 +1,150 @@
package inbox
import (
"bytes"
"encoding/json"
"path/filepath"
"testing"
)
func TestInboxLifecycle(t *testing.T) {
t.Parallel()
dbPath := filepath.Join(t.TempDir(), "coord.db")
initOut := runInboxCommand(t, "--db", dbPath, "--json", "init")
var initResp map[string]any
mustDecodeJSON(t, initOut, &initResp)
if initResp["ok"] != true {
t.Fatalf("expected init ok=true, got %#v", initResp)
}
sendOut := runInboxCommand(
t,
"--db", dbPath,
"--json",
"send",
"--from", "leader",
"--to", "worker-a",
"--subject", "Implement feature X",
"--summary", "Add retry policy",
"--body", "Implement retry handling for the HTTP client.",
"--run", "run_blog_001",
"--task", "T1",
)
var sendResp map[string]any
mustDecodeJSON(t, sendOut, &sendResp)
threadID := nestedString(t, sendResp, "data", "thread", "thread_id")
threadStatus := nestedString(t, sendResp, "data", "thread", "status")
if threadStatus != "pending" {
t.Fatalf("expected pending thread, got %q", threadStatus)
}
fetchOut := runInboxCommand(
t,
"--db", dbPath,
"--json",
"fetch",
"--agent", "worker-a",
"--status", "pending",
)
var fetchResp map[string]any
mustDecodeJSON(t, fetchOut, &fetchResp)
threadsValue := nestedValue(t, fetchResp, "data", "threads")
threads, ok := threadsValue.([]any)
if !ok || len(threads) != 1 {
t.Fatalf("expected one fetched thread, got %#v", threadsValue)
}
claimOut := runInboxCommand(
t,
"--db", dbPath,
"--json",
"claim",
"--agent", "worker-a",
"--thread", threadID,
"--lease-seconds", "300",
)
var claimResp map[string]any
mustDecodeJSON(t, claimOut, &claimResp)
claimedStatus := nestedString(t, claimResp, "data", "thread", "status")
if claimedStatus != "claimed" {
t.Fatalf("expected claimed thread, got %q", claimedStatus)
}
showOut := runInboxCommand(
t,
"--db", dbPath,
"--json",
"show",
"--thread", threadID,
)
var showResp map[string]any
mustDecodeJSON(t, showOut, &showResp)
showStatus := nestedString(t, showResp, "data", "thread", "status")
if showStatus != "claimed" {
t.Fatalf("expected show status claimed, got %q", showStatus)
}
messagesValue := nestedValue(t, showResp, "data", "messages")
messages, ok := messagesValue.([]any)
if !ok || len(messages) != 2 {
t.Fatalf("expected two messages in thread history, got %#v", messagesValue)
}
}
func runInboxCommand(t *testing.T, args ...string) string {
t.Helper()
cmd := NewRootCmd()
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.SetOut(&stdout)
cmd.SetErr(&stderr)
cmd.SetArgs(args)
if err := cmd.Execute(); err != nil {
t.Fatalf("execute inbox command %v: %v\nstderr:\n%s", args, err, stderr.String())
}
return stdout.String()
}
func mustDecodeJSON(t *testing.T, raw string, target any) {
t.Helper()
if err := json.Unmarshal([]byte(raw), target); err != nil {
t.Fatalf("decode json %q: %v", raw, err)
}
}
func nestedString(t *testing.T, value map[string]any, keys ...string) string {
t.Helper()
current := nestedValue(t, value, keys...)
str, ok := current.(string)
if !ok {
t.Fatalf("expected string at %v, got %#v", keys, current)
}
return str
}
func nestedValue(t *testing.T, value map[string]any, keys ...string) any {
t.Helper()
var current any = value
for _, key := range keys {
obj, ok := current.(map[string]any)
if !ok {
t.Fatalf("expected object at %q in %v, got %#v", key, keys, current)
}
current, ok = obj[key]
if !ok {
t.Fatalf("missing key %q in %v", key, keys)
}
}
return current
}
+4
View File
@@ -23,6 +23,10 @@ func NewRootCmd() *cobra.Command {
cmd.PersistentFlags().StringVar(&opts.agent, "agent", "", "Agent identity")
cmd.AddCommand(newInitCmd(opts))
cmd.AddCommand(newSendCmd(opts))
cmd.AddCommand(newFetchCmd(opts))
cmd.AddCommand(newClaimCmd(opts))
cmd.AddCommand(newShowCmd(opts))
return cmd
}
+95
View File
@@ -0,0 +1,95 @@
package inbox
import (
"fmt"
"ai-workflow-skill/internal/db"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type sendOptions struct {
from string
to string
threadID string
runID string
taskID string
subject string
kind string
summary string
body string
payloadJSON string
priority string
}
func newSendCmd(root *rootOptions) *cobra.Command {
opts := &sendOptions{}
cmd := &cobra.Command{
Use: "send",
Short: "Create a thread with an initial directed message",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
sqlDB, err := db.Open(ctx, root.dbPath)
if err != nil {
return err
}
defer sqlDB.Close()
s := store.NewInboxStore(sqlDB)
thread, message, err := s.Send(ctx, store.SendInput{
ThreadID: opts.threadID,
RunID: opts.runID,
TaskID: opts.taskID,
Subject: opts.subject,
FromAgent: opts.from,
ToAgent: opts.to,
Kind: opts.kind,
Summary: opts.summary,
Body: opts.body,
PayloadJSON: opts.payloadJSON,
Priority: opts.priority,
})
if err != nil {
return err
}
resp := protocol.Success{
OK: true,
Command: "send",
Data: map[string]any{
"thread": thread,
"message": message,
},
}
if root.json {
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "created thread %s\n", thread.ThreadID)
return err
},
}
cmd.Flags().StringVar(&opts.from, "from", "", "Sending agent")
cmd.Flags().StringVar(&opts.to, "to", "", "Receiving agent")
cmd.Flags().StringVar(&opts.threadID, "thread", "", "Optional thread ID override")
cmd.Flags().StringVar(&opts.runID, "run", "", "Optional run ID override")
cmd.Flags().StringVar(&opts.taskID, "task", "", "Optional task ID override")
cmd.Flags().StringVar(&opts.subject, "subject", "", "Thread subject")
cmd.Flags().StringVar(&opts.kind, "kind", "task", "Initial message kind")
cmd.Flags().StringVar(&opts.summary, "summary", "", "Short message summary")
cmd.Flags().StringVar(&opts.body, "body", "", "Message body")
cmd.Flags().StringVar(&opts.payloadJSON, "payload-json", "", "Structured payload JSON string")
cmd.Flags().StringVar(&opts.priority, "priority", "normal", "Thread priority")
_ = cmd.MarkFlagRequired("from")
_ = cmd.MarkFlagRequired("to")
_ = cmd.MarkFlagRequired("subject")
return cmd
}
+67
View File
@@ -0,0 +1,67 @@
package inbox
import (
"fmt"
"ai-workflow-skill/internal/db"
"ai-workflow-skill/internal/protocol"
"ai-workflow-skill/internal/store"
"github.com/spf13/cobra"
)
type showOptions struct {
threadID string
}
func newShowCmd(root *rootOptions) *cobra.Command {
opts := &showOptions{}
cmd := &cobra.Command{
Use: "show",
Short: "Show one thread with message history",
RunE: func(cmd *cobra.Command, args []string) error {
ctx := cmd.Context()
sqlDB, err := db.Open(ctx, root.dbPath)
if err != nil {
return err
}
defer sqlDB.Close()
s := store.NewInboxStore(sqlDB)
detail, err := s.GetThread(ctx, opts.threadID)
if err != nil {
return err
}
resp := protocol.Success{
OK: true,
Command: "show",
Data: map[string]any{
"thread": detail.Thread,
"messages": detail.Messages,
},
}
if root.json {
return protocol.WriteJSON(cmd.OutOrStdout(), resp)
}
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%s\t%s\n", detail.Thread.ThreadID, detail.Thread.Status, detail.Thread.Subject); err != nil {
return err
}
for _, message := range detail.Messages {
if _, err := fmt.Fprintf(cmd.OutOrStdout(), "- %s\t%s\t%s\n", message.MessageID, message.Kind, message.Summary); err != nil {
return err
}
}
return nil
},
}
cmd.Flags().StringVar(&opts.threadID, "thread", "", "Thread ID")
_ = cmd.MarkFlagRequired("thread")
return cmd
}