feat: atomic bean updates with body modifications#72
Conversation
hmans
left a comment
There was a problem hiding this comment.
Wonderful PR, thank you very much! Also, apologies for taking a bit to respond -- just working on too much stuff in parallel. Aaaaaah!
|
@matleh May I ask you to resolve the merge conflicts with |
Agents can now atomically update status, metadata, and body content together with a single etag validation, eliminating race conditions. - Add BodyModification input type supporting multiple replacements and append - Add bodyMod field to UpdateBeanInput for structured body modifications - Remove separate replaceInBody/appendToBody mutations (consolidated) - Update CLI to allow combining --body-replace-old and --body-append - Implement transactional all-or-nothing semantics - Add comprehensive test coverage
Move etag validation inside Core.Update() under write lock to prevent lost updates in concurrent scenarios. - Add ETagMismatchError and ETagRequiredError to beancore package - Update Core.Update() signature to accept ifMatch parameter - Validate etag against on-disk content to handle Go pointer aliasing - Update all Core.Update() call sites to pass ifMatch - Remove validateETag from graph resolver (now in Core) - Add comprehensive tests for etag validation scenarios - Add setupTestCoreWithRequireIfMatch helper for testing The race condition occurred when two threads validated etags before either acquired the write lock, allowing both to pass validation and the second update to overwrite the first. Now validation happens atomically with the update under the write lock, preventing lost updates.
d1e9b77 to
a186d7e
Compare
Rebase Completed - Conflicts ResolvedI've resolved the merge conflicts and rebased this PR on Upstream Changes IncorporatedFrom PR #67: Added From PR #60: Added Conflict Resolution Decisions1. Removed These are superseded by the The
2. Applied ETag race condition fix to new upstream mutations The upstream
Testing✅ All tests passing Let me know if you'd like me to adjust anything about the conflict resolution approach! |
|
also see follow-up PR #81 |
|
There seems to be a driveby addition of a session Markdown file, may I ask you to remove it? |
Add comprehensive etag validation tests for: - updateBean - setParent - addBlocking - removeBlocking Each mutation now has tests verifying: - Success with correct etag - Failure with wrong etag returning ETagMismatchError
a186d7e to
2b39d4b
Compare
I am sorry - removed. |
|
Merged, thanks for the PR! 🙏 |
### Motivation **Depends on:** #72 PR #72 added atomic body modifications via the `bodyMod` field in `updateBean`. This PR extends the same pattern to relationship fields (parent, blocking, blockedBy). Currently, updating relationships requires separate mutations with manual ETag extraction: 1. **No way to combine relationship updates atomically** - Setting parent and adding blocking relationships requires separate mutations with ETag chaining 2. **Cannot combine relationships with other updates** - Changing status, updating body, and setting parent requires multiple separate mutations 3. **ETag chaining complexity** - Client must extract ETag from each response and pass to next mutation 4. **Race conditions with \`--if-match\`** - Sequential mutations can have intermediate states visible to other clients 5. **Harder for AI agents** - Must orchestrate multiple mutations and handle ETag extraction, increasing cognitive load and error potential This PR consolidates relationship management into \`updateBean\` for truly atomic operations. ### Changes #### GraphQL API Improvements **Added relationship fields to \`UpdateBeanInput\`:** - \`parent: String\` - Set parent bean (validates type hierarchy and cycles) - \`addBlocking: [String!]\` - Add beans that this bean blocks - \`removeBlocking: [String!]\` - Remove beans from blocking list - \`addBlockedBy: [String!]\` - Add beans that block this bean - \`removeBlockedBy: [String!]\` - Remove beans from blocked-by list **New capabilities:** - Update parent, blocking, and blocked-by relationships atomically - Combine relationship updates with metadata changes (status, priority, tags, etc.) - Combine relationship updates with body modifications (via \`bodyMod\` from #72) - All operations atomic with single ETag validation - All existing validations preserved (type hierarchy, cycle detection, existence checks) #### Implementation Details **Extracted validation helpers to \`resolver.go\`:** - \`validateAndSetParent()\` - Type hierarchy validation and parent cycle detection - \`validateAndAddBlocking()\` - Self-reference check, existence check, bidirectional cycle detection - \`validateAndAddBlockedBy()\` - Same validations for blocked-by relationships - \`removeBlockingRelationships()\` - Safe removal without validation - \`removeBlockedByRelationships()\` - Safe removal without validation **Updated \`updateBean\` resolver:** - Handles all relationship fields before calling \`Core.Update()\` - All validations run before any changes are saved (transactional) - Single ETag check at the end under write lock (atomic) **Updated CLI (\`cmd/update.go\`):** - Modified \`buildUpdateInput()\` to populate relationship fields in \`UpdateBeanInput\` - Updated \`hasFieldUpdates()\` to check for relationship fields - Removed separate mutation calls (\`SetParent\`, \`AddBlocking\`, \`RemoveBlocking\`, \`AddBlockedBy\`, \`RemoveBlockedBy\`) - All updates now happen atomically via single \`UpdateBean\` mutation - Fixes ETag chaining bugs when using \`--if-match\` with relationship flags #### Backward Compatibility Standalone relationship mutations remain available: - \`setParent\` - Still works as before - \`addBlocking\` / \`removeBlocking\` - Still works as before - \`addBlockedBy\` / \`removeBlockedBy\` - Still works as before No breaking changes. The new fields in \`updateBean\` are purely additive. ### Examples **CLI - Combined operations:** ```bash beans update task-123 \\ --status completed \\ --parent epic-456 \\ --blocking task-789 \\ --body-replace-old "- [ ] Deploy" \\ --body-replace-new "- [x] Deploy" \\ --body-append "## Summary\\n\\nAll done!" \\ --if-match "abc123" ``` **GraphQL - Atomic update:** ```graphql mutation { updateBean( id: "task-123" input: { status: "completed" parent: "epic-456" addBlocking: ["task-789", "task-790"] bodyMod: { replace: [ { old: "- [ ] Write tests", new: "- [x] Write tests" } { old: "- [ ] Deploy", new: "- [x] Deploy" } ] append: "## Summary\\n\\nDeployment completed!" } ifMatch: "abc123" } ) { id status parentId blocking body etag } } ``` **Before (multiple mutations with ETag chaining):** ```graphql # ❌ Complex, error-prone, not atomic mutation { m1: updateBean(id: "task-123", input: { status: "done", ifMatch: "abc" }) { etag # Returns "def" } } # Extract etag from m1 response, make new request... mutation { m2: setParent(id: "task-123", parentId: "epic-456", ifMatch: "def") { etag # Returns "ghi" } } # Extract etag from m2 response, make new request... mutation { m3: addBlocking(id: "task-123", targetId: "task-789", ifMatch: "ghi") { id } } ``` **After (single atomic mutation):** ```graphql # ✅ Simple, atomic, single ETag mutation { updateBean(id: "task-123", input: { status: "done" parent: "epic-456" addBlocking: ["task-789"] ifMatch: "abc" }) { id etag } } ``` ### Execution Order 1. Metadata updates applied (title, status, type, priority, tags) 2. Body modifications applied (via \`bodyMod\` if provided) 3. Relationship updates applied: - Parent relationship (with validation) - Add blocking relationships (with validation) - Remove blocking relationships - Add blocked-by relationships (with validation) - Remove blocked-by relationships 4. Single ETag validation and save under write lock (atomic) 5. If any step fails, entire mutation fails (transactional) ### Validations All existing validations preserved: **Parent validation:** - ✅ Type hierarchy rules enforced (e.g., task can have epic/feature/milestone as parent) - ✅ Cycle detection (cannot create parent cycles) **Blocking/Blocked-by validation:** - ✅ Self-reference check (bean cannot block itself) - ✅ Target existence check - ✅ Bidirectional cycle detection (checks both blocking and blocked-by paths) ### Testing **Unit tests added:** - 16 comprehensive test cases for relationship updates - Atomic operations (status + parent + blocking + bodyMod combined) - Parent type hierarchy validation - Parent removal (empty string) - Blocking self-reference validation - Blocking cycle detection - Blocking target existence validation - Multiple blocking additions at once - Relationship removals - Combined add/remove operations - BlockedBy self-reference validation - BlockedBy cycle detection - BlockedBy target existence validation - All relationship types combined in one update **Manual testing:** - ✅ CLI atomic updates confirmed working - ✅ ETag validation working correctly - ✅ All validations functioning as expected All tests passing ✅ (42 graph tests total) ### Benefits - ✅ **Truly atomic updates** - All changes succeed or fail together - ✅ **No ETag chaining bugs** - Single ETag for entire operation - ✅ **Simpler for AI agents** - One mutation with clear input structure instead of orchestrating multiple mutations with manual ETag extraction and state management - ✅ **Better UX** - Combine any fields in a single operation - ✅ **Prevents race conditions** - All validations and updates happen atomically - ✅ **Backward compatible** - Standalone mutations still work - ✅ **Consistent API design** - Follows same pattern as \`bodyMod\` from #72 - ✅ **Fixes CLI bugs** - No more ETag chaining failures when using \`--if-match\` with relationship flags ### Questions for Consideration **Should we deprecate and remove standalone relationship mutations?** Following the same reasoning as in #72 (where we removed \`replaceInBody\` and \`appendToBody\`), we could deprecate the standalone relationship mutations to: - ✅ **Keep the schema slim** - Fewer mutations means less API surface area to maintain - ✅ **Cleaner mental model for AI agents** - One clear way to update beans instead of multiple overlapping approaches - ✅ **Consistency** - Matches the decision made for body modifications - ✅ **Simpler documentation** - One pattern to explain and understand **Affected mutations:** - \`setParent\` → use \`updateBean\` with \`parent\` field - \`addBlocking\` / \`removeBlocking\` → use \`updateBean\` with \`addBlocking\` / \`removeBlocking\` fields - \`addBlockedBy\` / \`removeBlockedBy\` → use \`updateBean\` with \`addBlockedBy\` / \`removeBlockedBy\` fields **Migration would be straightforward:** ```graphql # Before setParent(id: "task-123", parentId: "epic-456", ifMatch: "abc") # After updateBean(id: "task-123", input: { parent: "epic-456", ifMatch: "abc" }) ``` Alternatively, we could keep both approaches available if there's value in having specialized mutations for specific use cases. **What's your preference?**
Motivation
A previous PR introduced
replaceInBodyandappendToBodymutations for modifying bean body content. While these worked well for single operations, we quickly realized limitations:This PR consolidates body modification capabilities into a single atomic operation and fixes the concurrency issue.
Changes
GraphQL API Improvements
Consolidated body modifications into
updateBean:BodyModificationinput type supporting multiplereplaceoperations andappendReplaceOperationinput type witholdandnewfieldsbodyModfield toUpdateBeanInputfor structured body modificationsreplaceInBodyandappendToBodymutations (redundant, breaking change)New capabilities:
Race Condition Fix
Moved etag validation under write lock:
ETagMismatchErrorandETagRequiredErrorto beancore packageCore.Update()signature to acceptifMatchparameterCore.Update()under write lockThe race condition occurred when two threads validated etags before either acquired the write lock, allowing both to pass validation. The second update would silently overwrite the first. Now validation happens atomically with the update operation.
CLI Updates
--body-replace-old/newand--body-appendtogetherapplyBodyMod()to properly check forbodyModfield--bodyand--body-file(full replacement)beans querycommandDocumentation
cmd/prompt.tmplwith concise body modification examplesbodyModusage for multiple replacementsExamples
CLI - Combined operations:
GraphQL - Multiple replacements:
Execution Order
replaceoperations applied sequentially (each operates on result of previous)appendoperation applied to final resultBreaking Changes
replaceInBodymutation removed (useupdateBeanwithbodyMod.replace)appendToBodymutation removed (useupdateBeanwithbodyMod.append)Migration:
Testing
Manual testing performed:
Unit tests added:
UpdateBeanwithbodyModtest coverage (10 cases)All tests passing ✅