Skip to content

Expose recurring rule pause, resume, and end lifecycle RPCs#46

Merged
jakewan merged 2 commits intomainfrom
feature/recurring-rule-lifecycle-api
Mar 15, 2026
Merged

Expose recurring rule pause, resume, and end lifecycle RPCs#46
jakewan merged 2 commits intomainfrom
feature/recurring-rule-lifecycle-api

Conversation

@jakewan
Copy link
Copy Markdown
Owner

@jakewan jakewan commented Mar 15, 2026

Overview

The purpose of this change is to complete the recurring rule lifecycle API so users can pause, resume, and end rules through MCP (and eventually the Qt app). It represents an incremental improvement -- several core domain capabilities already existed (pause/resume in core, ended event type defined) but were inaccessible outside the core package. This PR threads them through proto RPCs, daemon handlers, and MCP tools to close that gap.

Changes

  • Core: Add EndRecurringRule method following the event-sourced pattern (validate, begin tx, append RecurringRuleEnded event, update read model, commit)
  • Proto: Add PauseRecurringRule, ResumeRecurringRule, and EndRecurringRule RPCs with request/response messages
  • Daemon: Add 3 handler methods with input validation and gRPC error mapping
  • MCP: Add 3 tools (pause_recurring_rule, resume_recurring_rule, end_recurring_rule) with Input/Output structs and handler factories
  • Tests: Core unit tests for EndRecurringRule (happy path, event production, validation). MCP BDD-style tests for all 3 tools including a projection integration test verifying ended rules stop generating transactions after the end date.

Closes #28, closes #29

Test plan

  • just test — all Go tests pass (core, daemon, mcp)
  • just lint — all linters clean
  • just all — full build succeeds (proto, daemon, mcp)
  • Manual: start daemon, use MCP tools to pause/resume/end a rule, verify projections reflect the change

🤖 Generated with Claude Code

Complete the recurring rule lifecycle API so users can pause, resume,
and end rules through MCP (and eventually the Qt app). Adds
EndRecurringRule core method, 3 proto RPCs with messages, daemon
handlers, MCP tools, and tests across all layers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR exposes the recurring rule lifecycle (pause, resume, end) outside core/ by threading new lifecycle operations through the proto contract, daemon gRPC handlers, and MCP tool surface so clients (MCP now, Qt later) can manage recurring rules end-to-end.

Changes:

  • Added PauseRecurringRule, ResumeRecurringRule, and EndRecurringRule RPCs + messages to the proto API and implemented corresponding daemon handlers.
  • Implemented core.DB.EndRecurringRule using the existing event-sourcing approach and added core unit tests.
  • Added MCP tools (pause_recurring_rule, resume_recurring_rule, end_recurring_rule) and MCP tests including a projection-based regression test for ended rules.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
proto/finch/v1/finch.proto Adds lifecycle RPCs and request/response messages for pause/resume/end.
daemon/finchd/server.go Implements gRPC handlers for pause/resume/end and input parsing/validation.
core/recurring.go Adds EndRecurringRule method that appends an end event and updates the read model.
core/recurring_test.go Adds unit tests covering EndRecurringRule behavior and validation.
mcp/tools.go Registers new MCP tools and implements handler wrappers calling the new RPCs.
mcp/tools_test.go Adds MCP integration tests for the new tools and verifies projections stop after end date.

Comment thread core/recurring_test.go
Comment on lines +236 to +240
t.Run("rejects empty rule ID", func(t *testing.T) {
if err := db.EndRecurringRule(ctx, "", date(2025, 6, 30)); err == nil {
t.Fatal("expected error for empty rule ID")
}
})
Comment thread core/recurring.go
Comment on lines +315 to +342
tx, err := db.conn.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}

payload, err := json.Marshal(RecurringRuleEndedPayload{
EndDate: endDate.Format(time.DateOnly),
})
if err != nil {
_ = tx.Rollback()
return fmt.Errorf("marshal payload: %w", err)
}

if _, err := appendEvents(ctx, tx, aggregateTypeRecurringRule, ruleID, []NewEvent{
{EventType: EventRecurringRuleEnded, Payload: payload},
}); err != nil {
_ = tx.Rollback()
return fmt.Errorf("append event: %w", err)
}

if _, err := tx.ExecContext(ctx,
"UPDATE recurring_rules SET end_date = ? WHERE id = ?",
endDate.Format(time.DateOnly), ruleID); err != nil {
_ = tx.Rollback()
return fmt.Errorf("update read model: %w", err)
}

return tx.Commit()
Comment thread daemon/finchd/server.go
Comment on lines +154 to +161
func (s *Server) PauseRecurringRule(ctx context.Context, req *finchv1.PauseRecurringRuleRequest) (*finchv1.PauseRecurringRuleResponse, error) {
if req.RuleId == "" {
return nil, status.Error(codes.InvalidArgument, "rule_id must not be empty")
}
if err := s.db.PauseRecurringRule(ctx, req.RuleId); err != nil {
return nil, status.Errorf(codes.Internal, "pause recurring rule: %v", err)
}
return &finchv1.PauseRecurringRuleResponse{}, nil
Comment thread daemon/finchd/server.go
Comment on lines +164 to +172
func (s *Server) ResumeRecurringRule(ctx context.Context, req *finchv1.ResumeRecurringRuleRequest) (*finchv1.ResumeRecurringRuleResponse, error) {
if req.RuleId == "" {
return nil, status.Error(codes.InvalidArgument, "rule_id must not be empty")
}
if err := s.db.ResumeRecurringRule(ctx, req.RuleId); err != nil {
return nil, status.Errorf(codes.Internal, "resume recurring rule: %v", err)
}
return &finchv1.ResumeRecurringRuleResponse{}, nil
}
Comment thread daemon/finchd/server.go
Comment on lines +174 to +185
func (s *Server) EndRecurringRule(ctx context.Context, req *finchv1.EndRecurringRuleRequest) (*finchv1.EndRecurringRuleResponse, error) {
if req.RuleId == "" {
return nil, status.Error(codes.InvalidArgument, "rule_id must not be empty")
}
endDate, err := time.Parse(time.DateOnly, req.EndDate)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "invalid end_date: %v", err)
}
if err := s.db.EndRecurringRule(ctx, req.RuleId, endDate); err != nil {
return nil, status.Errorf(codes.Internal, "end recurring rule: %v", err)
}
return &finchv1.EndRecurringRuleResponse{}, nil
Prevent orphaned events and silent no-ops for nonexistent rule IDs
in PauseRecurringRule, ResumeRecurringRule, and EndRecurringRule.
Map not-found errors to codes.NotFound in daemon handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@jakewan jakewan marked this pull request as ready for review March 15, 2026 20:35
@jakewan jakewan merged commit 60fdabb into main Mar 15, 2026
3 checks passed
@jakewan jakewan deleted the feature/recurring-rule-lifecycle-api branch March 15, 2026 20:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recurring rules cannot be ended after creation Recurring rules cannot be paused or resumed

2 participants