feat: complete bd-kwro messaging & knowledge graph epic
- Add bd cleanup --ephemeral flag for transient message cleanup (bd-kwro.9) - Add Ephemeral filter to IssueFilter type - Add ephemeral filtering to SQLite storage queries Tests (bd-kwro.10): - Add internal/hooks/hooks_test.go for hook system - Add cmd/bd/mail_test.go for mail commands - Add internal/storage/sqlite/graph_links_test.go for graph links Documentation (bd-kwro.11): - Add docs/messaging.md for full messaging reference - Add docs/graph-links.md for graph link types - Update AGENTS.md with inter-agent messaging section - Update CHANGELOG.md with all bd-kwro features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,9 @@ Delete all closed issues and prune tombstones:
|
||||
Delete issues closed more than 30 days ago:
|
||||
bd cleanup --older-than 30 --force
|
||||
|
||||
Delete only closed ephemeral issues (transient messages):
|
||||
bd cleanup --ephemeral --force
|
||||
|
||||
Preview what would be deleted/pruned:
|
||||
bd cleanup --dry-run
|
||||
bd cleanup --older-than 90 --dry-run
|
||||
@@ -64,6 +67,7 @@ SEE ALSO:
|
||||
cascade, _ := cmd.Flags().GetBool("cascade")
|
||||
olderThanDays, _ := cmd.Flags().GetInt("older-than")
|
||||
hardDelete, _ := cmd.Flags().GetBool("hard")
|
||||
ephemeralOnly, _ := cmd.Flags().GetBool("ephemeral")
|
||||
|
||||
// Calculate custom TTL for --hard mode
|
||||
// When --hard is set, use --older-than days as the tombstone TTL cutoff
|
||||
@@ -108,6 +112,12 @@ SEE ALSO:
|
||||
filter.ClosedBefore = &cutoffTime
|
||||
}
|
||||
|
||||
// Add ephemeral filter if specified (bd-kwro.9)
|
||||
if ephemeralOnly {
|
||||
ephemeralTrue := true
|
||||
filter.Ephemeral = &ephemeralTrue
|
||||
}
|
||||
|
||||
// Get all closed issues matching filter
|
||||
closedIssues, err := store.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
@@ -124,11 +134,18 @@ SEE ALSO:
|
||||
if olderThanDays > 0 {
|
||||
result["filter"] = fmt.Sprintf("older than %d days", olderThanDays)
|
||||
}
|
||||
if ephemeralOnly {
|
||||
result["ephemeral"] = true
|
||||
}
|
||||
output, _ := json.MarshalIndent(result, "", " ")
|
||||
fmt.Println(string(output))
|
||||
} else {
|
||||
msg := "No closed issues to delete"
|
||||
if olderThanDays > 0 {
|
||||
if ephemeralOnly && olderThanDays > 0 {
|
||||
msg = fmt.Sprintf("No closed ephemeral issues older than %d days to delete", olderThanDays)
|
||||
} else if ephemeralOnly {
|
||||
msg = "No closed ephemeral issues to delete"
|
||||
} else if olderThanDays > 0 {
|
||||
msg = fmt.Sprintf("No closed issues older than %d days to delete", olderThanDays)
|
||||
}
|
||||
fmt.Println(msg)
|
||||
@@ -144,15 +161,23 @@ SEE ALSO:
|
||||
|
||||
// Show preview
|
||||
if !force && !dryRun {
|
||||
fmt.Fprintf(os.Stderr, "Would delete %d closed issue(s). Use --force to confirm or --dry-run to preview.\n", len(issueIDs))
|
||||
issueType := "closed"
|
||||
if ephemeralOnly {
|
||||
issueType = "closed ephemeral"
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Would delete %d %s issue(s). Use --force to confirm or --dry-run to preview.\n", len(issueIDs), issueType)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if !jsonOutput {
|
||||
issueType := "closed"
|
||||
if ephemeralOnly {
|
||||
issueType = "closed ephemeral"
|
||||
}
|
||||
if olderThanDays > 0 {
|
||||
fmt.Printf("Found %d closed issue(s) older than %d days\n", len(closedIssues), olderThanDays)
|
||||
fmt.Printf("Found %d %s issue(s) older than %d days\n", len(closedIssues), issueType, olderThanDays)
|
||||
} else {
|
||||
fmt.Printf("Found %d closed issue(s)\n", len(closedIssues))
|
||||
fmt.Printf("Found %d %s issue(s)\n", len(closedIssues), issueType)
|
||||
}
|
||||
if dryRun {
|
||||
fmt.Println(color.YellowString("DRY RUN - no changes will be made"))
|
||||
@@ -211,5 +236,6 @@ func init() {
|
||||
cleanupCmd.Flags().Bool("cascade", false, "Recursively delete all dependent issues")
|
||||
cleanupCmd.Flags().Int("older-than", 0, "Only delete issues closed more than N days ago (0 = all closed issues)")
|
||||
cleanupCmd.Flags().Bool("hard", false, "Bypass tombstone TTL safety; use --older-than days as cutoff")
|
||||
cleanupCmd.Flags().Bool("ephemeral", false, "Only delete closed ephemeral issues (transient messages)")
|
||||
rootCmd.AddCommand(cleanupCmd)
|
||||
}
|
||||
|
||||
376
cmd/bd/mail_test.go
Normal file
376
cmd/bd/mail_test.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/beads/internal/types"
|
||||
)
|
||||
|
||||
func TestMailSendAndInbox(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
|
||||
ctx := context.Background()
|
||||
|
||||
// Set up global state
|
||||
oldStore := store
|
||||
oldRootCtx := rootCtx
|
||||
oldActor := actor
|
||||
store = testStore
|
||||
rootCtx = ctx
|
||||
actor = "test-user"
|
||||
defer func() {
|
||||
store = oldStore
|
||||
rootCtx = oldRootCtx
|
||||
actor = oldActor
|
||||
}()
|
||||
|
||||
// Create a message (simulating mail send)
|
||||
now := time.Now()
|
||||
msg := &types.Issue{
|
||||
Title: "Test Subject",
|
||||
Description: "Test message body",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "worker-1",
|
||||
Sender: "manager",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := testStore.CreateIssue(ctx, msg, actor); err != nil {
|
||||
t.Fatalf("Failed to create message: %v", err)
|
||||
}
|
||||
|
||||
// Query inbox for worker-1
|
||||
messageType := types.TypeMessage
|
||||
openStatus := types.StatusOpen
|
||||
assignee := "worker-1"
|
||||
filter := types.IssueFilter{
|
||||
IssueType: &messageType,
|
||||
Status: &openStatus,
|
||||
Assignee: &assignee,
|
||||
}
|
||||
|
||||
messages, err := testStore.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(messages) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d", len(messages))
|
||||
}
|
||||
|
||||
if messages[0].Title != "Test Subject" {
|
||||
t.Errorf("Title = %q, want %q", messages[0].Title, "Test Subject")
|
||||
}
|
||||
if messages[0].Sender != "manager" {
|
||||
t.Errorf("Sender = %q, want %q", messages[0].Sender, "manager")
|
||||
}
|
||||
if !messages[0].Ephemeral {
|
||||
t.Error("Ephemeral should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailInboxEmpty(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
|
||||
ctx := context.Background()
|
||||
|
||||
// Query inbox for non-existent user
|
||||
messageType := types.TypeMessage
|
||||
openStatus := types.StatusOpen
|
||||
assignee := "nobody"
|
||||
filter := types.IssueFilter{
|
||||
IssueType: &messageType,
|
||||
Status: &openStatus,
|
||||
Assignee: &assignee,
|
||||
}
|
||||
|
||||
messages, err := testStore.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(messages) != 0 {
|
||||
t.Errorf("Expected 0 messages, got %d", len(messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailAck(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a message
|
||||
now := time.Now()
|
||||
msg := &types.Issue{
|
||||
Title: "Ack Test",
|
||||
Description: "Test body",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "recipient",
|
||||
Sender: "sender",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
|
||||
t.Fatalf("Failed to create message: %v", err)
|
||||
}
|
||||
|
||||
// Acknowledge (close) the message
|
||||
if err := testStore.CloseIssue(ctx, msg.ID, "acknowledged", "test"); err != nil {
|
||||
t.Fatalf("Failed to close message: %v", err)
|
||||
}
|
||||
|
||||
// Verify it's closed
|
||||
updated, err := testStore.GetIssue(ctx, msg.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if updated.Status != types.StatusClosed {
|
||||
t.Errorf("Status = %q, want %q", updated.Status, types.StatusClosed)
|
||||
}
|
||||
|
||||
// Verify it no longer appears in inbox
|
||||
messageType := types.TypeMessage
|
||||
openStatus := types.StatusOpen
|
||||
assignee := "recipient"
|
||||
filter := types.IssueFilter{
|
||||
IssueType: &messageType,
|
||||
Status: &openStatus,
|
||||
Assignee: &assignee,
|
||||
}
|
||||
|
||||
messages, err := testStore.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(messages) != 0 {
|
||||
t.Errorf("Expected 0 messages in inbox after ack, got %d", len(messages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailReply(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
|
||||
ctx := context.Background()
|
||||
|
||||
// Create original message
|
||||
now := time.Now()
|
||||
original := &types.Issue{
|
||||
Title: "Original Subject",
|
||||
Description: "Original body",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "worker",
|
||||
Sender: "manager",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := testStore.CreateIssue(ctx, original, "test"); err != nil {
|
||||
t.Fatalf("Failed to create original message: %v", err)
|
||||
}
|
||||
|
||||
// Create reply
|
||||
reply := &types.Issue{
|
||||
Title: "Re: Original Subject",
|
||||
Description: "Reply body",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "manager", // Reply goes to original sender
|
||||
Sender: "worker",
|
||||
Ephemeral: true,
|
||||
RepliesTo: original.ID, // Thread link
|
||||
CreatedAt: now.Add(time.Minute),
|
||||
UpdatedAt: now.Add(time.Minute),
|
||||
}
|
||||
|
||||
if err := testStore.CreateIssue(ctx, reply, "test"); err != nil {
|
||||
t.Fatalf("Failed to create reply: %v", err)
|
||||
}
|
||||
|
||||
// Verify reply has correct thread link
|
||||
savedReply, err := testStore.GetIssue(ctx, reply.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetIssue failed: %v", err)
|
||||
}
|
||||
|
||||
if savedReply.RepliesTo != original.ID {
|
||||
t.Errorf("RepliesTo = %q, want %q", savedReply.RepliesTo, original.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailPriority(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
|
||||
ctx := context.Background()
|
||||
|
||||
// Create messages with different priorities
|
||||
now := time.Now()
|
||||
messages := []struct {
|
||||
title string
|
||||
priority int
|
||||
}{
|
||||
{"Normal message", 2},
|
||||
{"Urgent message", 0},
|
||||
{"High priority", 1},
|
||||
}
|
||||
|
||||
for i, m := range messages {
|
||||
msg := &types.Issue{
|
||||
Title: m.title,
|
||||
Description: "Body",
|
||||
Status: types.StatusOpen,
|
||||
Priority: m.priority,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "inbox",
|
||||
Sender: "sender",
|
||||
Ephemeral: true,
|
||||
CreatedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
|
||||
t.Fatalf("Failed to create message %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Query all messages
|
||||
messageType := types.TypeMessage
|
||||
openStatus := types.StatusOpen
|
||||
assignee := "inbox"
|
||||
filter := types.IssueFilter{
|
||||
IssueType: &messageType,
|
||||
Status: &openStatus,
|
||||
Assignee: &assignee,
|
||||
}
|
||||
|
||||
results, err := testStore.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("Expected 3 messages, got %d", len(results))
|
||||
}
|
||||
|
||||
// Verify we can filter by priority
|
||||
urgentPriority := 0
|
||||
urgentFilter := types.IssueFilter{
|
||||
IssueType: &messageType,
|
||||
Status: &openStatus,
|
||||
Assignee: &assignee,
|
||||
Priority: &urgentPriority,
|
||||
}
|
||||
|
||||
urgent, err := testStore.SearchIssues(ctx, "", urgentFilter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
if len(urgent) != 1 {
|
||||
t.Errorf("Expected 1 urgent message, got %d", len(urgent))
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailTypeValidation(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a regular issue (not a message)
|
||||
now := time.Now()
|
||||
task := &types.Issue{
|
||||
Title: "Regular Task",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeTask,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
if err := testStore.CreateIssue(ctx, task, "test"); err != nil {
|
||||
t.Fatalf("Failed to create task: %v", err)
|
||||
}
|
||||
|
||||
// Query for messages should not return the task
|
||||
messageType := types.TypeMessage
|
||||
filter := types.IssueFilter{
|
||||
IssueType: &messageType,
|
||||
}
|
||||
|
||||
messages, err := testStore.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
for _, m := range messages {
|
||||
if m.ID == task.ID {
|
||||
t.Errorf("Task %s should not appear in message query", task.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMailSenderField(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
testStore := newTestStore(t, tmpDir+"/.beads/beads.db")
|
||||
ctx := context.Background()
|
||||
|
||||
// Create messages from different senders
|
||||
now := time.Now()
|
||||
senders := []string{"alice", "bob", "charlie"}
|
||||
|
||||
for i, sender := range senders {
|
||||
msg := &types.Issue{
|
||||
Title: "Message from " + sender,
|
||||
Description: "Body",
|
||||
Status: types.StatusOpen,
|
||||
Priority: 2,
|
||||
IssueType: types.TypeMessage,
|
||||
Assignee: "inbox",
|
||||
Sender: sender,
|
||||
Ephemeral: true,
|
||||
CreatedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
UpdatedAt: now.Add(time.Duration(i) * time.Minute),
|
||||
}
|
||||
if err := testStore.CreateIssue(ctx, msg, "test"); err != nil {
|
||||
t.Fatalf("Failed to create message from %s: %v", sender, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Query all messages and verify sender
|
||||
messageType := types.TypeMessage
|
||||
filter := types.IssueFilter{
|
||||
IssueType: &messageType,
|
||||
}
|
||||
|
||||
messages, err := testStore.SearchIssues(ctx, "", filter)
|
||||
if err != nil {
|
||||
t.Fatalf("SearchIssues failed: %v", err)
|
||||
}
|
||||
|
||||
senderSet := make(map[string]bool)
|
||||
for _, m := range messages {
|
||||
if m.Sender != "" {
|
||||
senderSet[m.Sender] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, s := range senders {
|
||||
if !senderSet[s] {
|
||||
t.Errorf("Sender %q not found in messages", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user