feat(beads): implement channel message retention
Add two-layer retention for beads-native channel messages: 1. On-write cleanup (EnforceChannelRetention): - Called after posting to channel - Deletes oldest messages when count > retainCount 2. Deacon patrol backup (PruneAllChannels): - Scans all channels periodically - Uses 10% buffer to avoid thrashing - Catches edge cases: crashed mid-write, manual insertions Part of gt-xfqh1e.13 (channel retention task). Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Steve Yegge
parent
bf8bddb004
commit
012d50b2b2
@@ -375,3 +375,107 @@ func (b *Beads) LookupChannelByName(name string) (*Issue, *ChannelFields, error)
|
|||||||
|
|
||||||
return nil, nil, nil // Not found
|
return nil, nil, nil // Not found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnforceChannelRetention prunes old messages from a channel to enforce retention.
|
||||||
|
// Called after posting a new message to the channel (on-write cleanup).
|
||||||
|
// If channel has >= retainCount messages, deletes oldest until count < retainCount.
|
||||||
|
func (b *Beads) EnforceChannelRetention(name string) error {
|
||||||
|
// Get channel config
|
||||||
|
_, fields, err := b.GetChannelBead(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if fields == nil {
|
||||||
|
return fmt.Errorf("channel not found: %s", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no retention limit
|
||||||
|
if fields.RetentionCount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Query messages in this channel (oldest first)
|
||||||
|
out, err := b.run("list",
|
||||||
|
"--type=message",
|
||||||
|
"--label=channel:"+name,
|
||||||
|
"--json",
|
||||||
|
"--limit=0",
|
||||||
|
"--sort=created",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing channel messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &messages); err != nil {
|
||||||
|
return fmt.Errorf("parsing channel messages: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how many to delete
|
||||||
|
// We're being called after a new message is posted, so we want to end up with retainCount
|
||||||
|
toDelete := len(messages) - fields.RetentionCount
|
||||||
|
if toDelete <= 0 {
|
||||||
|
return nil // No pruning needed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete oldest messages (best-effort)
|
||||||
|
for i := 0; i < toDelete && i < len(messages); i++ {
|
||||||
|
// Use close instead of delete for audit trail
|
||||||
|
_, _ = b.run("close", messages[i].ID, "--reason=channel retention pruning")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneAllChannels enforces retention on all channels.
|
||||||
|
// Called by Deacon patrol as a backup cleanup mechanism.
|
||||||
|
// Uses a 10% buffer to avoid thrashing (only prunes if count > retainCount * 1.1).
|
||||||
|
func (b *Beads) PruneAllChannels() (int, error) {
|
||||||
|
channels, err := b.ListChannelBeads()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pruned := 0
|
||||||
|
for name, fields := range channels {
|
||||||
|
if fields.RetentionCount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count messages
|
||||||
|
out, err := b.run("list",
|
||||||
|
"--type=message",
|
||||||
|
"--label=channel:"+name,
|
||||||
|
"--json",
|
||||||
|
"--limit=0",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue // Skip on error
|
||||||
|
}
|
||||||
|
|
||||||
|
var messages []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(out, &messages); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10% buffer - only prune if significantly over limit
|
||||||
|
threshold := int(float64(fields.RetentionCount) * 1.1)
|
||||||
|
if len(messages) <= threshold {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prune down to exactly retainCount
|
||||||
|
toDelete := len(messages) - fields.RetentionCount
|
||||||
|
for i := 0; i < toDelete && i < len(messages); i++ {
|
||||||
|
if _, err := b.run("close", messages[i].ID, "--reason=patrol retention pruning"); err == nil {
|
||||||
|
pruned++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pruned, nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user