Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions cmd/cull.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package cmd

import (
"encoding/json"
"fmt"
"time"

"github.com/quad341/cairn/internal/cairn"
"github.com/spf13/cobra"
)

const defaultCullDisuseAfter = 30 * 24 * time.Hour

func init() {
cullCandidatesCmd.Flags().Duration("disuse-after", defaultCullDisuseAfter,
"disuse threshold: report entries not recalled (or, if never recalled, not created) within this long (NFR-06)")
cullEvictCmd.Flags().String("reviewer", "",
"reviewer to mail for a shared-tier (rig/role/global) eviction proposal (default: $CAIRN_REVIEWER, else a per-tier computed default)")
rootCmd.AddCommand(cullCandidatesCmd, cullEvictCmd)
}

var cullCandidatesCmd = &cobra.Command{
Use: "cull-candidates",
Short: "Entries disused past a threshold, JSON (read-only; FR-10/NFR-06)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if identityRequested(cmd) {
return fmt.Errorf("cull-candidates covers every entry and does not filter by identity; " +
"use 'cairn map' or 'cairn prime' for a scoped view")
}
disuseAfter, _ := cmd.Flags().GetDuration("disuse-after")
findings, err := cairn.CullCandidates(cmd.Context(), storePath(), disuseAfter)
if err != nil {
return err
}
if findings == nil {
findings = []cairn.CullCandidateFinding{}
}
enc := json.NewEncoder(cmd.OutOrStdout())
enc.SetIndent("", " ")
return enc.Encode(findings)
},
}

var cullEvictCmd = &cobra.Command{
Use: "cull-evict <id>",
Short: "Evict an entry: direct delete for private scope, review-branch proposal for shared scope (NFR-07)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
e, err := cairn.EntryForEvict(cmd.Context(), storePath(), args[0])
if err != nil {
return fmt.Errorf("look up %s for eviction: %w", args[0], err)
}

if cairn.IsPrivateScope(e.Scope) {
sha, err := e.EvictDirect(cmd.Context(), storePath())
if err != nil {
return fmt.Errorf("evict entry: %w", err)
}
fmt.Printf("%s\n", sha)
return nil
}
return requestCullReview(cmd, e)
},
}
119 changes: 119 additions & 0 deletions cmd/cull_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package cmd

import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"

"github.com/quad341/cairn/internal/cairn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// gitCommitAll stages and commits everything in dir -- the cmd package has
// no shared helper for this (only gitOutput); mirrors
// internal/cairn/freshness_test.go's package-scoped gitCommitAll, which
// cannot be reused across packages.
func gitCommitAll(t *testing.T, dir, msg string) {
t.Helper()
gitOutput(t, dir, "add", "-A")
gitOutput(t, dir, "commit", "-q", "-m", msg)
}

// seedCommittedEntry creates and commits an entry directly to store's
// current branch -- cull-evict's precondition: both EvictDirect and
// EvictToReviewBranch operate via `git rm` on an already-tracked file,
// unlike remember's add-only commit primitives, so the fixture must be
// committed, not just written.
func seedCommittedEntry(t *testing.T, store, topic string, scope []string) *cairn.Entry {
t.Helper()
e, err := cairn.NewEntry(topic, scope, "a body", "agent:bot")
require.NoError(t, err)
require.NoError(t, e.Create(store))
gitCommitAll(t, store, "add "+e.ID)
return e
}

func resetCullEvictFlags(t *testing.T) {
t.Helper()
f := cullEvictCmd.Flags().Lookup("reviewer")
require.NotNil(t, f)
require.NoError(t, f.Value.Set(""))
f.Changed = false
}

// runCullEvict executes "cairn cull-evict <id>" against the shared
// rootCmd, stubbing gc to always succeed. Mirrors runRemember.
func runCullEvict(t *testing.T, store, id string, extraArgs ...string) error {
t.Helper()
resetCullEvictFlags(t)
t.Cleanup(func() { resetCullEvictFlags(t) })

stubGC(t)
args := append([]string{"cull-evict", "--store", store, id}, extraArgs...)
rootCmd.SetArgs(args)
rootCmd.SetOut(&bytes.Buffer{})
rootCmd.SetErr(&bytes.Buffer{})
return rootCmd.Execute()
}

func TestCullEvictPrivateTierDeletesDirectlyAndReportsSHA(t *testing.T) {
store := t.TempDir()
gitInit(t, store)
e := seedCommittedEntry(t, store, "old-fact", []string{"agent:bot"})

var runErr error
stdout := captureStdout(t, func() {
runErr = runCullEvict(t, store, e.ID)
})
require.NoError(t, runErr)

_, statErr := os.Stat(e.BodyPath)
assert.True(t, os.IsNotExist(statErr), "a private-tier cull-evict must delete the entry file directly")

head := strings.TrimSpace(gitOutput(t, store, "rev-parse", "HEAD"))
lines := strings.Fields(strings.TrimSpace(stdout))
require.Len(t, lines, 1, "a private-tier cull-evict must print only the eviction commit SHA")
assert.Equal(t, head, lines[0])

log := strings.TrimSpace(gitOutput(t, store, "log", "--oneline"))
assert.Len(t, strings.Split(log, "\n"), 3, "exactly one new commit (the eviction) on top of init+add")
}

func TestCullEvictSharedTierProposesReviewBranchAndDoesNotDeleteDirectly(t *testing.T) {
store := t.TempDir()
gitInit(t, store)
e := seedCommittedEntry(t, store, "old-fact", []string{"rig:web"})

var runErr error
stdout := captureStdout(t, func() {
runErr = runCullEvict(t, store, e.ID)
})
require.NoError(t, runErr)

_, statErr := os.Stat(e.BodyPath)
assert.NoError(t, statErr, "a shared-tier cull-evict must NOT delete the entry file on the store's own branch -- NFR-07")

branch := "cull/" + e.ID
gitOutput(t, store, "rev-parse", "--verify", branch)

rel, err := filepath.Rel(store, e.BodyPath)
require.NoError(t, err)
status := gitOutput(t, store, "diff-tree", "--no-commit-id", "--name-status", "-r", branch)
assert.Contains(t, status, "D\t"+rel, "the cull branch's commit must delete the entry file")

lines := strings.Split(strings.TrimSpace(stdout), "\n")
require.Len(t, lines, 2, "a shared-tier cull-evict must print the review branch and the mailed reviewer -- no commit SHA")
assert.Contains(t, lines[0], branch)
}

func TestCullEvictUnknownIDReturnsClearError(t *testing.T) {
store := t.TempDir()
gitInit(t, store)

err := runCullEvict(t, store, "does-not-exist")
require.Error(t, err)
assert.Contains(t, err.Error(), "does-not-exist")
}
38 changes: 38 additions & 0 deletions cmd/reviewer.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,41 @@ func mailSend(ctx context.Context, to, subject, body string) error {
}
return nil
}

// requestCullReview proposes e's eviction on a review branch and mails the
// tier-appropriate reviewer -- mirrors requestReview, applied to a delete
// instead of an add. Callers must only invoke this for a scope that has
// already resolved away from the private agent tier.
func requestCullReview(cmd *cobra.Command, e *cairn.Entry) error {
tier, value := cairn.ResolvedTier(e.Scope)

branch, err := e.EvictToReviewBranch(cmd.Context(), storePath())
if err != nil {
return fmt.Errorf("propose shared-tier eviction on a review branch: %w", err)
}
fmt.Printf("cull review branch: %s\n", branch)

reviewer, err := resolveReviewer(cmd, tier, value)
if err != nil {
return fmt.Errorf("entry %s proposed for eviction on branch %s, but resolving a reviewer to mail failed: %w", e.ID, branch, err)
}
if err := sendCullReviewMail(cmd.Context(), reviewer, e, branch); err != nil {
return fmt.Errorf("entry %s proposed for eviction on branch %s, but mail to reviewer %q failed: %w", e.ID, branch, reviewer, err)
}
fmt.Printf("mailed reviewer: %s\n", reviewer)
return nil
}

// sendCullReviewMail mirrors sendReviewMail, with cull-specific wording: the
// branch deletes the entry rather than adding one, and a reviewer merges it
// with plain git (cairn review merge cannot handle a pure-deletion branch).
func sendCullReviewMail(ctx context.Context, reviewer string, e *cairn.Entry, branch string) error {
subject := fmt.Sprintf("cairn cull review: %s", e.TopicKey)
body := fmt.Sprintf(
"Cairn entry %s (topic %q, scope %s) is proposed for eviction (disuse).\n\n"+
"Branch: %s\n\nThis branch deletes the entry's file. Merge it with plain git "+
"(not `cairn review merge`, which does not handle a pure-deletion branch) when satisfied; "+
"it does not auto-merge. Reject by simply not merging (or deleting the branch) to keep the entry.",
e.ID, e.TopicKey, strings.Join(e.Scope, " "), branch)
return mailSend(ctx, reviewer, subject, body)
}
84 changes: 84 additions & 0 deletions internal/cairn/cull.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package cairn

import (
"context"
"time"
)

// CullCandidateFinding is one entry disused past the configured threshold
// (FR-10/NFR-06): DisusedSince is LastRecalledAt if the entry was ever
// recalled, else CreatedAt.
type CullCandidateFinding struct {
EntryID string `json:"entry_id"`
TopicKey string `json:"topic_key,omitempty"`
Scope []string `json:"scope,omitempty"`
LastRecalledAt string `json:"last_recalled_at,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
DisusedSince string `json:"disused_since"`
}

func disuseReference(lastRecalledAt, createdAt string) (t time.Time, ok bool) {
if lastRecalledAt != "" {
if parsed, err := time.Parse(time.RFC3339, lastRecalledAt); err == nil {
return parsed, true
}
}
if createdAt != "" {
if parsed, err := time.Parse(time.DateOnly, createdAt); err == nil {
return parsed, true
}
}
return time.Time{}, false
}

// CullCandidates reports entries whose disuse (LastRecalledAt, falling back
// to CreatedAt if never recalled) exceeds disuseAfter (NFR-06). This is
// deliberately independent of FRESHNESS (anchor-drift, Check()) per FR-10:
// the query below never selects verified_at or any anchor_* column, so an
// entry's freshness state cannot leak into the disuse decision.
func CullCandidates(ctx context.Context, store string, disuseAfter time.Duration) ([]CullCandidateFinding, error) {
if err := ensureFresh(ctx, store); err != nil {
return nil, err
}
db, err := openDB(store)
if err != nil {
return nil, err
}
defer func() { _ = db.Close() }()

tags, err := scopeTags(ctx, db)
if err != nil {
return nil, err
}

rows, err := db.QueryContext(ctx, `SELECT id, topic_key, last_recalled_at, created_at FROM entries ORDER BY id`)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()

cutoff := time.Now().Add(-disuseAfter)
var findings []CullCandidateFinding
for rows.Next() {
var id, topicKey, lastRecalledAt, createdAt string
if err := rows.Scan(&id, &topicKey, &lastRecalledAt, &createdAt); err != nil {
return nil, err
}
ref, ok := disuseReference(lastRecalledAt, createdAt)
if !ok || ref.After(cutoff) {
continue
}
findings = append(findings, CullCandidateFinding{
EntryID: id,
TopicKey: topicKey,
Scope: tags[id],
LastRecalledAt: lastRecalledAt,
CreatedAt: createdAt,
DisusedSince: ref.Format(time.RFC3339),
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return findings, nil
}
Loading
Loading