Add inbox send fetch claim show commands
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user