Skip to content

post_sync: run a local command when teammates' changes land - #163

Merged
ssowonny merged 1 commit into
mainfrom
bea-137-post-sync-local-hook-run-a-command-on-this-device-when
Aug 13, 2026
Merged

post_sync: run a local command when teammates' changes land#163
ssowonny merged 1 commit into
mainfrom
bea-137-post-sync-local-hook-run-a-command-on-this-device-when

Conversation

@ssowonny

Copy link
Copy Markdown
Contributor

TL;DR

  • A teammate's edit lands on your disk and nothing local knows — so anything downstream of the synced folder (search index, cache, notifier) has to poll. Now it gets an event.
  • Set post_sync in the folder's own .bdrive/config.json and it runs after a cycle applies peers' changes, with the applied paths as JSON on stdin. Off unless you set it.
  • Once per cycle, not once per file: an initial sync of 400 files is one invocation. Local-edit-only cycles fire nothing.
  • Never touches sync: the command is spawned detached, and one that hangs, exits non-zero or doesn't exist is logged and forgotten.
  • Two known gaps, both flagged in the docs: only write/delete (no created-vs-modified split), and .bdrive/ travels with a folder you copy by hand, so a hand-copied teammate folder brings their post_sync along.

What it looks like

// .bdrive/config.json
{ "id": "m-5a10b713", "volume": "notes",
  "remote": "https://drive.example.com/p/7f3a…",
  "post_sync": "qmd update && qmd embed" }

stdin, with the command's working directory set to the folder:

{ "project": "m-5a10b713", "folder": "/Users/you/notes",
  "changed": [ { "path": "wiki/onboarding.md", "op": "write" },
               { "path": "notes/retired.md",   "op": "delete" } ] }

It lives in .bdrive/config.json and only there. That directory is in config.ReservedDirs and never syncs, so no hub response and no teammate's journal op can put a command on someone else's machine — which is what the issue required.

The one thing to actually review

The inbound spool (internal/store/inbound.go) looks like the obvious source for the changed paths. It is a trap: DrainInbound() is destructive and cmd/bdrive/hooksync.go is its only consumer, so a second drainer would silently empty the agent's "teammates changed X, re-read before editing" context about half the time, with no error anywhere.

So the batch rides out on a new Result.Inbound instead, and logInbound writes to both — the spool for the cross-process agent hook, the Result for the in-process post_sync hook. TestPostSyncLeavesInboundSpool is the regression guard, and the comments on both sides now say why they coexist.

Shape

Cycle is now a six-line wrapper: cycleLocked does today's work under the volume flock, then firePostSync spawns after the lock drops. That makes the ordering a property of the code rather than a rule each of the seven Cycle call sites has to remember.

stdin is an unlinked temp file, not a bytes.Reader: with a non-*os.File stdin, os/exec serves the child from a goroutine in the parent, and a one-shot bdrive sync exits before the child has read it — delivering truncated JSON.

Deliberately not built (each argued in the issue's plan): no single-flight guard, no timeout, no Setsid, no created/modified split. The spawn carries a ponytail: comment naming the pileup ceiling and its upgrade path.

Where I deviated from the plan

The plan claimed a hook spawned inside the flock would deadlock when it runs a bdrive command, and that the e2e test proves the ordering. Neither is true: the child is detached and nothing waits on it, so spawning inside the lock only stalls the child on LOCK_EX until the cycle ends. I verified this by moving firePostSync inside cycleLocked and watching the e2e test still pass.

Firing after the unlock is still right — a hook shouldn't queue behind a long push — but it is a code-shape property, not something the test asserts. The e2e test is kept for what it does prove: the real binary, a real flock, a hook running bdrive sync and completing with the right batch on stdin. Both the test comment and this PR say so rather than implying a guarantee that isn't there.

Acceptance

  • go build ./..., go vet ./..., go test ./... — all pass (full suite, twice).
  • New multi-device syncer tests (internal/syncer/postsync_test.go): fire-once-per-batch, silent without config, no-fire on a local-only cycle, deletes reported as op:"delete", exit 3 + nonexistent binary leaving Result and the next cycle untouched, and the spool left intact for bdrive sync --hook.
  • internal/config: post_sync round-trips and is omitted from the file when unset.
  • internal/webapp/cli_postsync_e2e_test.go: real binary, two devices, one hub, a post_sync that runs bdrive sync.
  • No frontend change, so no npm run e2e and no UI evaluation — nothing under internal/webapp/frontend is touched.
  • "A paused project fires nothing" needs no new code or test: bdrive stop exits the daemon and syncBlocked refuses a manual sync, so no cycle runs at all.

Docs: README.md, web/docs/src/content/docs/reference/project-files.md. reference/cli.md untouched — this is config, not a flag.

Architecture changes

architecture/cli-sync.md: Session gains cycleLocked, firePostSync and logInbound (with Cycle demoted to a wrapper), Result gains Inbound, and Project gains PostSync. Store is deliberately unchanged — LogInbound/DrainInbound keep their exact semantics, which is the point of the section above. Nothing was removed.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Session["<div style='text-align:left'><b>Session</b><br/>+Folder string<br/>+MountID string<br/>+Store *store.Store<br/>+Backend remote.Backend<br/>+Cycle(ctx) Result<br/>+Restore(ctx, path, sha) error<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -cycleLocked(ctx) Result</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -firePostSync(res)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ -logInbound(rel, deleted)</span></div>"]
    Result["<div style='text-align:left'><b>Result</b><br/>+LocalOps +PulledOps<br/>+Conflicts +Adopted +Pruned +Materialized<br/>+Pushed +Offline +OfflineErr<br/>+ReadOnly +NoAccess +AccessErr<br/>+Reason() string<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +Inbound []store.InboundEvent</span></div>"]
    Project["<div style='text-align:left'><b>Project</b><br/>+ID stable mount id<br/>+Volume +Remote<br/>+Include legacy, read-only<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +PostSync local hook command</span></div>"]
    Store["<div style='text-align:left'><b>Store</b><br/>+LogInbound / DrainInbound<br/>+Lock() flock</div>"]
    Wrapper["Cycle is now a thin wrapper:<br/>cycleLocked does the work under Lock()<br/>firePostSync spawns sh -c PostSync AFTER it drops<br/>so a hook running a bdrive command<br/>does not block on the flock"]
    Both["logInbound records each peer path TWICE, on purpose:<br/>Result.Inbound -&gt; post_sync hook (same process)<br/>store spool -&gt; bdrive sync --hook (later process)<br/>DrainInbound is destructive and single-consumer,<br/>so the hook must never drain it"]
    Session -- "returns" --> Result
    Session -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ reads PostSync after unlock</span>" --> Project
    Session -- "Lock() + LogInbound, unchanged" --> Store
    Session -.- Wrapper
    Result -.- Both
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class Wrapper noteBox
    class Both noteBox
    linkStyle 1 stroke:#22c55e,stroke-width:2px
Loading

Closes BEA-137.

Build session

cd $(git worktree list | grep bea-137 | awk '{print $1}') && claude --resume baebef15-7f72-413a-b931-1418ea48ad0d

(only works on this machine)

Inbound sync was invisible to the machine it landed on — a local index,
cache or notifier had to poll. A `post_sync` command in the folder's own
.bdrive/config.json now runs once per cycle that applied peer changes,
with the batch as JSON on stdin.

The batch rides out on a new Result.Inbound rather than the inbound
spool: DrainInbound is destructive and `bdrive sync --hook` is its only
consumer, so a second drainer would silently empty the agent's
"teammates changed X" context. Both are kept, and both comments now say
why.

Cycle becomes a thin wrapper over cycleLocked so the hook is spawned
after the volume flock drops — a property of the code shape, not a rule
each of the seven call sites has to remember.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ssowonny
ssowonny requested a review from thefron August 13, 2026 17:18
@ssowonny
ssowonny merged commit edfe46c into main Aug 13, 2026
3 checks passed
@ssowonny
ssowonny deleted the bea-137-post-sync-local-hook-run-a-command-on-this-device-when branch August 13, 2026 18:04
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.

1 participant