Skip to content

feat: add body modification and ETag concurrency control#57

Closed
matleh wants to merge 3 commits intohmans:mainfrom
matleh:feature/update-body
Closed

feat: add body modification and ETag concurrency control#57
matleh wants to merge 3 commits intohmans:mainfrom
matleh:feature/update-body

Conversation

@matleh
Copy link
Copy Markdown
Contributor

@matleh matleh commented Jan 15, 2026

Motivation

The Problem: Beans Folder Outside Repository

The beans data directory can be configured to live outside the repository (via .beans.yml). When this happens, agents struggle because:

  1. They try to read/edit .beans/ in the repo root (wrong location)
  2. They need to parse config to find the actual beans path
  3. They need permission to access files outside the repository
  4. Many agent sandboxes restrict file access to within the repo

Previously, modifying a bean's body required direct file access:

# Agent needs to know beans path, have permissions, handle file I/O
beans show <id> --body-only > /tmp/bean.md
# edit file...
beans update <id> --body-file /tmp/bean.md

The Solution: CLI-Based Body Modifications

The new flags allow agents to modify bean bodies entirely through the CLI:

beans update <id> --body-replace-old "- [ ] Task" --body-replace-new "- [x] Task"

Benefits:

  • No direct file access required - CLI handles all I/O
  • Agents don't need to know or care where beans are stored
  • No permission issues for external directories
  • Works regardless of beans folder location

Concurrency Control

When multiple agents work concurrently, changes can be lost. The --if-match flag enables optimistic locking using ETags, and --etag-only makes extraction simple:

ETAG=$(beans show <id> --etag-only)
beans update <id> --status completed --if-match "$ETAG"

Atomic Operations

By integrating into update, agents can combine body and metadata changes atomically with a single etag check.

Changes

Commit 1: ETag Support

  • --etag-only flag on beans show for easy extraction
  • --if-match flag on beans update for optimistic locking
  • etag field in all JSON output and GraphQL queries
  • FNV-1a 64-bit hash of rendered bean content

Commit 2: Body Modification Flags

  • --body-replace-old / --body-replace-new for partial text replacement
  • --body-append for appending content (supports stdin with -)
  • Mutual exclusivity with existing --body/--body-file flags

Usage

# Get etag
ETAG=$(beans show <id> --etag-only)

# Atomic update with concurrency protection
beans update <id> \
  --body-replace-old "- [ ] Deploy" --body-replace-new "- [x] Deploy" \
  --status completed \
  --if-match "$ETAG"

# On conflict, get clear error with current etag
# Error: etag mismatch (expected abc123, got def456)

Testing

  • 28 new test cases covering all functionality
  • All existing tests pass

- Add ETag() method to Bean using FNV-1a 64-bit hash of rendered content
- Include etag field in JSON output via custom MarshalJSON
- Add etag field to GraphQL schema and regenerate resolvers
- Add --if-match flag to beans update command for optimistic locking
- Add --etag-only flag to beans show for easy etag extraction
- Add ErrConflict error code for etag mismatch scenarios
- Add comprehensive tests for ETag functionality

ETag enables concurrent modification detection:
  ETAG=$(beans show <id> --etag-only)
  beans update <id> --status completed --if-match "$ETAG"

On conflict, returns CONFLICT error with current etag.
- Add --body-replace-old/--body-replace-new for partial text replacement
- Add --body-append for appending content (supports stdin with -)
- Add helper functions: applyBodyReplace, applyBodyAppend, resolveAppendContent
- Update mutual exclusivity rules for body operation flags
- Update documentation with usage examples
- Add comprehensive tests for body modification functions

Enables atomic operations combining metadata and body changes:
  beans update <id> \
    --body-replace-old "- [ ] Task" --body-replace-new "- [x] Task" \
    --status completed \
    --if-match "$ETAG"

Body modification flags are mutually exclusive with --body/--body-file.
@hmans
Copy link
Copy Markdown
Owner

hmans commented Jan 16, 2026

Hi, thanks for the PR! I have a question: what use case are you aiming at with this? If this is about support for multi-agent workflows, I intend to go into another direction (enabling the GraphQL API via HTTP and instructing agents to coordinate via that.)

If it's not about support for multi-agent workflows, what are your reasons for having the .beans directory outside of your repo? (I am simply trying to get a little more context for the suggested change.)

@matleh
Copy link
Copy Markdown
Contributor Author

matleh commented Jan 16, 2026

Hi @hmans,

My motivation: use beans in repos where I don't want to store .beans inside the repo (like beads' stealth mode):

  • When clients/coworkers shouldn't be bothered with git changes to bean files
  • To allow parallel work on features in different worktrees while still having a unified/up-to-date view of tasks

Actually, my current main feature is the --body-replace-old/new functionality - my agents were struggling to update beans when the .beans folder is outside the current folder. I've been using it for several hours now with this addition, and it works like a charm.

The etag/if-match feature is more of a first safeguard to prevent agents working in parallel from clobbering each other's bean updates - actually, I haven't used it yet. To be really useful, I would add a config option require_if_match or an env var that makes --if-match obligatory. But this part is not (yet) high prio for me.

@matleh
Copy link
Copy Markdown
Contributor Author

matleh commented Jan 16, 2026

If you want, I can split it up into two PRs - one about body-replace-old/new and another about etag/if-match.

@hmans
Copy link
Copy Markdown
Owner

hmans commented Jan 16, 2026

I'll need to give this some thought. I appreciate your input and your efforts here, but these are some non-trivial changes, and I need to make sure that they are something that I want to support in future versions. This is complicated by how this PR and also its motivations conflate a number of concerns:

Using Beans without committing:

I think my recommendation here would be to just put .beans.yml and .beans/ into a .gitignore file.

Multi-agent workflows:

I want to fully bet on the GraphQL-over-HTTP approach I mentioned above for this, and this PR doesn't expose the new functionality in the GraphQL schema at all (with the exception of adding etag to the Bean type.) I won't be recommending people who are going for multi-agent workflows to use the CLI, so adding multi-agent support features to the CLI only is not the way forward.

Safety-net style features (basic OCC):

I like this part very much actually, including the etag mechanism. In fact I would, from the top of my head, fully bet on this or a similar mechanism: have beans generate a hash based on the content, and allow GraphQL mutations/CLI commands to specify this hash for OCC.

I'll give this some more thought; if you want to submit a separate PR that implements the etag mechanism only and makes it available via both GraphQL as well as the Cobra CLI commands, I would happily merge it.

@matleh
Copy link
Copy Markdown
Contributor Author

matleh commented Jan 16, 2026

superseded by #59

@matleh matleh closed this Jan 16, 2026
hmans added a commit that referenced this pull request Jan 19, 2026
## Summary

Adds CLI flags and GraphQL mutations for partial body modifications,
enabling agents to update bean content without direct file access.
Builds on #59 (ETag support) and supersedes #57.

## Changes

### CLI: Body Modification Flags
- `--body-replace-old` / `--body-replace-new` for exact text replacement
(must match exactly once)
- `--body-append` for appending content (supports stdin with `-`)
- Mutual exclusivity with existing `--body`/`--body-file` flags

### GraphQL: Partial Body Mutations
- `replaceInBody(id, old, new, ifMatch)` - replace exactly one
occurrence of text
- `appendToBody(id, content, ifMatch)` - append content with blank line
separator
- Both support `ifMatch` for optimistic locking

### Shared Logic
- Extract `ReplaceOnce` and `AppendWithSeparator` to
`internal/bean/content.go`
- Reused by both CLI and GraphQL resolvers

### Documentation
- Add GraphQL API section to README with examples

## Usage

```bash
# CLI: Check off a task
beans update <id> --body-replace-old "- [ ] Task" --body-replace-new "- [x] Task"

# CLI: Append notes
beans update <id> --body-append "## Notes\n\nSome notes"

# GraphQL: Check off a task
beans query 'mutation { replaceInBody(id: "bean-xxx", old: "- [ ] Task", new: "- [x] Task") { body } }'

# GraphQL: Append content
beans query 'mutation { appendToBody(id: "bean-xxx", content: "## Notes") { body } }'
```

## Why This Matters for Agents

Agents can now modify bean bodies entirely through CLI/GraphQL without
needing:
- Direct file access to the `.beans/` directory
- Knowledge of where beans are stored (which can be configured outside
the repo)
- Special file permissions

## Testing

- Comprehensive tests for shared logic in
`internal/bean/content_test.go`
- GraphQL resolver tests for both mutations
- CLI tests updated for new behavior

---------

Co-authored-by: Hendrik Mans <[email protected]>
@matleh matleh deleted the feature/update-body branch January 22, 2026 09:31
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.

2 participants