Expose recurring rule pause, resume, and end lifecycle RPCs#46
Merged
Conversation
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>
There was a problem hiding this comment.
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, andEndRecurringRuleRPCs + messages to the proto API and implemented corresponding daemon handlers. - Implemented
core.DB.EndRecurringRuleusing 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 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 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 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 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 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
EndRecurringRulemethod following the event-sourced pattern (validate, begin tx, appendRecurringRuleEndedevent, update read model, commit)PauseRecurringRule,ResumeRecurringRule, andEndRecurringRuleRPCs with request/response messagespause_recurring_rule,resume_recurring_rule,end_recurring_rule) with Input/Output structs and handler factoriesEndRecurringRule(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 cleanjust all— full build succeeds (proto, daemon, mcp)🤖 Generated with Claude Code