Skip to content

feat: bbolt-backed command store (daemon-owned, feature-gated)#284

Merged
AnnatarHe merged 9 commits into
mainfrom
claude/gifted-allen-ojXBf
Jun 10, 2026
Merged

feat: bbolt-backed command store (daemon-owned, feature-gated)#284
AnnatarHe merged 9 commits into
mainfrom
claude/gifted-allen-ojXBf

Conversation

@AnnatarHe

@AnnatarHe AnnatarHe commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Why

The CLI self-maintains three append-only text files (pre.txt, post.txt, cursor.txt) in ~/.shelltime/commands/. They're written synchronously from the shell hook (shelltime track ... &> /dev/null, no trailing &), so the write sits on the prompt's critical path — which is why fast, lock-free appends are used.

This PR adds go.etcd.io/bbolt as a cleaner, more maintainable store. The key constraint: bbolt takes an exclusive OS file lock on Open (single writer process), so opening + fsync-committing on every hook invocation in the CLI would be slower than the current append and would serialize concurrent terminals. So the daemon owns the bbolt DB, the hook becomes a fire-and-forget socket write, and the txt files remain the no-daemon fallback. Feature-gated via config; clean cutover (no migration of existing txt data).

What (by commit)

  1. feat(model)CommandStore interface + factory (storage.engine: file default | bolt); fileStore (wraps today's txt logic) and boltStore (bbolt, active/archived/meta buckets, time-ordered collision-free keys). Reuses the pre-existing activeBucket/archivedBucket constants.
  2. feat(daemon) — daemon owns the bolt store for its lifetime; new track_pre/track_post socket messages + handlers persist to bolt and run the flush/sync/cursor/prune cycle. Extracted sendTrackArgsToServer (shared send path) and model.BuildTrackingData (shared payload assembly).
  3. feat(cli) — the track hook routes events to the daemon when bolt is enabled and the socket is ready, falling back to the txt store otherwise; trySyncLocalToServer now reuses BuildTrackingData against an explicit file store.
  4. feat(cli)shelltime ls queries the daemon (new list_commands request/response) in bolt mode, since the CLI can't open the locked DB; shared model.BuildListedCommands keeps output identical across both paths.
  5. fix(cli)gc skips txt command-file compaction in bolt mode (the daemon prunes; post.txt may not exist).

Verification

  • go vet + go test ./model/ ./commands/ ./daemon/ pass (the pre-existing, environment-dependent TestGitInfoTestSuite failure is unrelated — it fails on main too in this sandbox). New unit tests cover the bolt store (save/read, time-ordering, equal-timestamp collisions, cursor, prune), the track handlers (fake store + config, no mockery), and BuildListedCommands.
  • End-to-end with a real daemon + stub API: pre/post → socket → bolt buckets; flush → sync (source: 1) → cursor advance → prune; sub-threshold commands stay buffered and are returned by ls via the daemon; with the daemon down, track falls back to pre.txt/post.txt and syncs via direct HTTP, writing cursor.txt.

Notes

  • Default behavior is unchanged: with no storage config the txt file store is used everywhere.
  • bbolt opens with a bounded lock timeout so a stale lock can't hang the daemon.

https://claude.ai/code/session_01TePAhXoF7CR7aXRi6nTMsd

Introduce a CommandStore interface that abstracts local command buffering,
with two implementations:

- fileStore: wraps the existing append-only txt files (pre/post/cursor),
  the always-available fallback. Behavior unchanged.
- boltStore: a bbolt-backed store intended to be owned by the single
  long-lived daemon (bbolt holds an exclusive file lock). Pre commands go
  in the "active" bucket, post in "archived", and the sync cursor in "meta",
  matching the pre-existing activeBucket/archivedBucket constants.

Keys are time-ordered (8-byte big-endian UnixNano + sequence) so iteration
is chronological and identical timestamps never collide.

Add a feature-gated config field (storage.engine: file|bolt, default file)
selected via NewCommandStore. Nothing is wired into the track hot path or
daemon yet; this is an additive, behavior-neutral first step.
@codecov

codecov Bot commented Jun 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 59.65251% with 209 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
model/store_file.go 47.25% 27 Missing and 21 partials ⚠️
model/store_bolt.go 66.90% 26 Missing and 20 partials ⚠️
daemon/handlers.track.go 64.51% 13 Missing and 9 partials ⚠️
commands/track.go 19.23% 16 Missing and 5 partials ⚠️
model/tracking_build.go 74.60% 8 Missing and 8 partials ⚠️
daemon/client.go 65.51% 5 Missing and 5 partials ⚠️
cmd/daemon/main.go 0.00% 8 Missing ⚠️
daemon/socket.go 61.90% 4 Missing and 4 partials ⚠️
model/store.go 68.00% 5 Missing and 3 partials ⚠️
commands/ls.go 50.00% 4 Missing and 2 partials ⚠️
... and 5 more
Flag Coverage Δ
unittests 42.05% <59.65%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
daemon/handlers.sync.go 55.26% <100.00%> (+1.20%) ⬆️
commands/gc.go 41.66% <33.33%> (+5.21%) ⬆️
daemon/base.go 40.00% <0.00%> (-10.00%) ⬇️
model/path.go 93.33% <0.00%> (-3.22%) ⬇️
daemon/handlers.go 64.28% <0.00%> (-10.72%) ⬇️
commands/ls.go 69.09% <50.00%> (+69.09%) ⬆️
model/list.go 79.31% <79.31%> (ø)
cmd/daemon/main.go 0.00% <0.00%> (ø)
daemon/socket.go 61.68% <61.90%> (+0.03%) ⬆️
model/store.go 68.00% <68.00%> (ø)
... and 6 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a CommandStore interface to abstract command persistence, adding a new bbolt-based storage engine (boltStore) alongside the existing text file implementation (fileStore). Feedback on the new boltStore implementation focuses on safety and transaction efficiency. Specifically, the reviewer recommends refactoring the Prune method to load post commands within the write transaction to prevent race conditions, adding defensive nil checks on bbolt buckets to avoid nil pointer dereferences, and implementing a length check in decodeKeyNano to prevent potential out-of-bounds panics.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread model/store_bolt.go Outdated
Comment on lines +163 to +186
postCommands, err := s.GetPostCommands(ctx)
if err != nil {
return err
}
cursorNano := cursor.UnixNano()

return s.db.Update(func(tx *bolt.Tx) error {
archived := tx.Bucket([]byte(archivedBucket))
var delArchived [][]byte
if err := archived.ForEach(func(k, v []byte) error {
if decodeKeyNano(k) <= cursorNano {
delArchived = append(delArchived, append([]byte(nil), k...))
}
return nil
}); err != nil {
return err
}
for _, k := range delArchived {
if err := archived.Delete(k); err != nil {
return err
}
}

active := tx.Bucket([]byte(activeBucket))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

Refactor Prune to load postCommands inside the write transaction. This provides several benefits:

  1. Consistency: Eliminates potential race conditions where post commands are modified between the read and write transactions.
  2. Efficiency: Reduces the number of database transactions from 2 to 1, and allows populating postCommands and identifying keys to delete in a single pass over the archived bucket.
  3. Safety: Adds defensive nil checks for both archived and active buckets to prevent nil pointer dereference panics.
	cursorNano := cursor.UnixNano()

	return s.db.Update(func(tx *bolt.Tx) error {
		archived := tx.Bucket([]byte(archivedBucket))
		if archived == nil {
			return fmt.Errorf("bucket %s not found", archivedBucket)
		}

		var postCommands []*Command
		var delArchived [][]byte
		if err := archived.ForEach(func(k, v []byte) error {
			cmd := new(Command)
			if err := json.Unmarshal(v, cmd); err == nil {
				cmd.RecordingTime = time.Unix(0, decodeKeyNano(k))
				postCommands = append(postCommands, cmd)
			}
			if decodeKeyNano(k) <= cursorNano {
				delArchived = append(delArchived, append([]byte(nil), k...))
			}
			return nil
		}); err != nil {
			return err
		}
		for _, k := range delArchived {
			if err := archived.Delete(k); err != nil {
				return err
			}
		}

		active := tx.Bucket([]byte(activeBucket))
		if active == nil {
			return fmt.Errorf("bucket %s not found", activeBucket)
		}

Comment thread model/store_bolt.go
Comment on lines +67 to +69
func decodeKeyNano(key []byte) int64 {
return int64(binary.BigEndian.Uint64(key[0:8]))
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

To prevent potential out-of-bounds panics if a key is corrupted or unexpectedly short, add a length check before accessing the slice in decodeKeyNano.

func decodeKeyNano(key []byte) int64 {
	if len(key) < 8 {
		return 0
	}
	return int64(binary.BigEndian.Uint64(key[0:8]))
}

Comment thread model/store_bolt.go
Comment on lines +77 to +78
b := tx.Bucket([]byte(bucket))
seq, err := b.NextSequence()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

In bbolt, tx.Bucket returns nil if the bucket does not exist. Although buckets are initialized during store creation, performing a nil check is a best practice to prevent nil pointer dereference panics if the database is modified externally or corrupted.

Suggested change
b := tx.Bucket([]byte(bucket))
seq, err := b.NextSequence()
b := tx.Bucket([]byte(bucket))
if b == nil {
return fmt.Errorf("bucket %s not found", bucket)
}
seq, err := b.NextSequence()

Comment thread model/store_bolt.go
Comment on lines +89 to +90
b := tx.Bucket([]byte(bucket))
return b.ForEach(func(k, v []byte) error {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Add a nil check for the bucket returned by tx.Bucket to prevent potential nil pointer dereference panics.

		b := tx.Bucket([]byte(bucket))
		if b == nil {
			return fmt.Errorf("bucket %s not found", bucket)
		}
		return b.ForEach(func(k, v []byte) error {

Comment thread model/store_bolt.go Outdated
Comment on lines +139 to +145
v := tx.Bucket([]byte(metaBucket)).Get([]byte(cursorKey))
if v == nil {
noCursorExist = true
cursorTime = time.Time{}
return nil
}
cursorTime = time.Unix(0, int64(binary.BigEndian.Uint64(v)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Add a nil check for the metaBucket to prevent potential nil pointer dereference panics when calling .Get.

		b := tx.Bucket([]byte(metaBucket))
		if b == nil {
			return fmt.Errorf("bucket %s not found", metaBucket)
		}
		v := b.Get([]byte(cursorKey))
		if v == nil {
			noCursorExist = true
			cursorTime = time.Time{}
			return nil
		}
		cursorTime = time.Unix(0, int64(binary.BigEndian.Uint64(v)))

Comment thread model/store_bolt.go Outdated
buf := make([]byte, 8)
binary.BigEndian.PutUint64(buf, uint64(cursor.UnixNano()))
return s.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket([]byte(metaBucket)).Put([]byte(cursorKey), buf)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Add a nil check for the metaBucket to prevent potential nil pointer dereference panics when calling .Put.

		b := tx.Bucket([]byte(metaBucket))
		if b == nil {
			return fmt.Errorf("bucket %s not found", metaBucket)
		}
		return b.Put([]byte(cursorKey), buf)

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3de7b8c5fa

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread model/store_bolt.go Outdated
return nil
}
cmd.RecordingTime = time.Unix(0, nano)
closest := cmd.FindClosestCommand(postCommands, true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Use the post command when pruning matched pre rows

When pruning a normal completed command where the post hook runs after the pre hook, this calls FindClosestCommand on the pre command. That helper only accepts candidates with receiver.Time - candidate.Time >= 0, so a later post command is never considered a match and the active/pre row survives every prune; in bolt mode this leaves synced pre commands accumulating indefinitely and keeps stale rows in future pre-tree lookups. The same directionality issue is copied into fileStore.Prune, so the abstraction should match posts from the post side or otherwise compare times explicitly.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — confirmed and fixed in 771e371. You're right that pre.FindClosestCommand(posts) can't match a later post (it only accepts candidates at/before the receiver's time), so synced pre rows never got pruned. Prune now matches from the post side via a shared preHasSyncedPost helper (same unique key + post.Time >= pre.Time + post synced at/before the cursor), applied to both the bolt and file stores, with a regression test for the post.Time > pre.Time case.


Generated by Claude Code

claude added 4 commits June 2, 2026 09:49
Wire the daemon to own the bbolt CommandStore and accept raw command
events over the socket when the bolt storage engine is enabled.

- Add track_pre / track_post socket message types and TrackEventPayload;
  route them through the existing pub/sub topic to new handlers.
- handlePubSubTrackPre persists to the active bucket; handlePubSubTrackPost
  persists to archived, then runs the flush/sync/cursor/prune cycle against
  the store (the daemon-side equivalent of trySyncLocalToServer). On send
  failure it leaves data in bolt and does not advance the cursor.
- Extract the server-send path (terminal resolution, daemon source,
  encryption, circuit breaker) from handlePubSubSync into a reusable
  sendTrackArgsToServer, shared by the sync and track handlers.
- Extract the pre/post -> []TrackingData assembly into model.BuildTrackingData
  so both the CLI fallback and the daemon build payloads from one path.
- The daemon constructs and owns the bolt store for its lifetime in
  cmd/daemon/main.go (gated on storage.engine == bolt).
- Make CommandStore.GetPreTree return map[string][]*Command so the interface
  is implementable/fakeable outside the model package.

Adds self-contained track handler tests (fake store + config, no mockery).
Nothing in the CLI hot path emits these events yet; that follows next.
When storage.engine == bolt and the daemon socket is ready, the track hook
sends the raw pre/post command event to the daemon (fire-and-forget) instead
of writing the txt files locally; the daemon owns persistence, sync, and
pruning. When bolt is disabled or the daemon is down, the CLI falls back to
the existing txt file store and local sync, unchanged.

Also dedupe the sync assembly: trySyncLocalToServer now builds its payload via
model.BuildTrackingData against an explicit file store (NewFileStore) and
advances the cursor through the store interface, removing the duplicated
inline assembly and updateCursorToFile.

Add daemon.SendTrackEvent and model.NewFileStore.
In bolt mode the daemon owns the exclusively-locked DB, so the CLI cannot
read it directly. Add a list_commands request/response socket message: `ls`
queries the daemon when bolt is enabled and the socket is ready, and falls
back to reading the txt file store otherwise.

Extract the pre/post pairing for display into model.BuildListedCommands
(returning model.ListedCommand) so the daemon and the CLI fallback render
identical output, and replace ls.go's inline anonymous struct with it.
In bolt mode the daemon prunes synced commands from the DB after each sync,
and post.txt typically does not exist, so cleanCommandFiles would error when
opening it. Skip the txt compaction when storage.engine == bolt; log cleanup
still runs.
@AnnatarHe AnnatarHe changed the title feat(model): add CommandStore abstraction with bbolt backend feat: bbolt-backed command store (daemon-owned, feature-gated) Jun 2, 2026
@AnnatarHe

Copy link
Copy Markdown
Contributor Author

@claude review it

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @AnnatarHe's task in 3m 49s —— View job


Code Review: feat: bbolt-backed command store

  • Gather context and understand request
  • Read core model files (store.go, store_bolt.go, store_file.go, tracking_build.go, list.go)
  • Read daemon changes (handlers.track.go, socket.go, client.go, base.go)
  • Read CLI command changes (track.go, ls.go, gc.go)
  • Review tests
  • Post comprehensive review

The architecture is sound — delegating the exclusive-lock DB to the daemon, keeping txt as fallback, and routing via the existing socket. The implementation is clean. Several correctness and safety issues need attention before merge.

Critical

1. Nil pointer panics in boltStoremodel/store_bolt.go

bolt.Tx.Bucket() returns nil if the bucket doesn't exist (DB corruption, external tooling, format version change). There are four unguarded call sites that would panic instead of returning an error:

  • put() (line 77): b := tx.Bucket(...)b.NextSequence() panics if nil
  • all() (line 90): b.ForEach(...) panics if nil
  • GetLastCursor() (line 139): tx.Bucket(...).Get(...) — immediate nil deref
  • SetCursor() (line 155): same pattern

All four need a nil check with a descriptive fmt.Errorf(...) return, as Gemini's review shows. Fix this →

2. decodeKeyNano bounds panic — model/store_bolt.go:68

func decodeKeyNano(key []byte) int64 {
    return int64(binary.BigEndian.Uint64(key[0:8]))  // panics on corrupted key < 8 bytes
}

Add if len(key) < 8 { return 0 }. Fix this →

3. Prune two-transaction race — model/store_bolt.go:162-213

postCommands is fetched in a separate View transaction before the Update transaction:

postCommands, err := s.GetPostCommands(ctx)  // View tx #1 — can be stale
// ...
return s.db.Update(func(tx *bolt.Tx) error { // Write tx #2
    // uses stale postCommands for active-bucket matching

New post commands can be inserted between the two transactions by a concurrent goroutine. Gemini's suggestion of collapsing both into a single Update transaction is the right fix. Fix this →

4. Circuit breaker bypassed on bolt track-post path — daemon/handlers.track.go:47 / daemon/handlers.sync.go:13

handlePubSubSync guards with:

if syncCircuitBreaker != nil && syncCircuitBreaker.IsOpen() { /* save for retry */ }

handlePubSubTrackPost calls sendTrackArgsToServer directly, skipping this check. If the server is down and the circuit is open, every shell command's post event still attempts an HTTP call. This can cause noticeable latency (connection timeout) on every command completion in the worst case. The circuit-breaker check should be applied consistently.


High

5. Silent empty ls when daemon is down in bolt mode — commands/ls.go:72-76

} else {
    commands, err = model.BuildListedCommands(ctx, model.NewFileStore())

If storage.engine = bolt and the daemon isn't running, this silently falls back to the txt file store, which will be empty for users who've been running in bolt mode. The user has no idea why ls returns nothing. This should surface a clear error: "daemon is not running; cannot read bolt store. Start the daemon or switch to file storage engine." Fix this →

6. No write deadline on SendTrackEventdaemon/client.go:57-85

RequestListCommands sets conn.SetDeadline(time.Now().Add(timeout)), but SendTrackEvent does not set any deadline after dialing. If the daemon's socket buffer fills (heavy load, slow consumer), the shell hook write hangs indefinitely, stalling the prompt. DialTimeout bounds the connect phase but not the write. Add conn.SetDeadline(time.Now().Add(someShortDeadline)) after dial. Fix this →

7. Config re-read on every post command — daemon/handlers.track.go:61

cfg, err := stConfig.ReadConfigFile(ctx)

This reads and parses TOML from disk on every track_post message — meaning every shell command completion. If ConfigService already caches (depends on implementation), this is fine, but if not, this is expensive on the hot path. Worth verifying or adding a note.


Medium

8. Lost in-flight events on daemon shutdown — cmd/daemon/main.go:87-96

daemon.InitCommandStore(store)
defer store.Close()

On SIGTERM, pubsub.Close() is called before processor.Stop(), but messages already published to the GoChannel but not yet consumed by SocketTopicProcessor are lost. Track events in the queue at shutdown time are silently dropped. A brief drain window before closing the channel would reduce data loss.

9. FindClosestCommand direction in Prune — pre-existing but worth noting

cmd.FindClosestCommand(postCommands, true) (both store_bolt.go:198 and store_file.go:124) requires pre.Time >= post.Time to find a match. For commands completing in under 1 second (Unix-second resolution), this works. For longer-running commands, the pre is never matched and is never pruned — it accumulates indefinitely. This is a pre-existing issue carried forward from cleanCommandFiles, not new to this PR, but bolt mode makes it more visible since old txt GC ran separately.

10. Two separate time.Now() calls for the same logical instant — commands/track.go:93,110

instance := &model.Command{ Time: time.Now() }  // L93
// ...
now := time.Now()  // L110 — different timestamp
return daemon.SendTrackEvent(ctx, ..., *instance, now)

instance.Time and recordingTime are microseconds apart. While unlikely to cause issues in practice, using a single now captured once at the top of the function would be cleaner and eliminates any ordering ambiguity.


Low

11. Missing test for happy path of handlePubSubTrackPostdaemon/handlers.track_test.go

The test covers: nil store, pre persistence, and below-flush-threshold. Missing: the case where len(result.Data) >= FlushCount (or noCursorExist) — verifying that cursor is advanced and Prune is called after a successful send. Since sendTrackArgsToServer makes an HTTP call, this requires either mocking the HTTP call or extracting the send into an injectable interface.

12. handleListCommands uses context.Background()daemon/socket.go:257

commands, err := model.BuildListedCommands(context.Background(), commandStore)

The connection's context (and any cancellation if the client disconnects) isn't threaded through. Minor but inconsistent with how other handlers could propagate cancellation.

13. SendTrackEvent vs SendLocalDataToSocket dial styles

SendTrackEvent uses net.Dial (no timeout), while SendSessionProject uses net.DialTimeout(10ms). For a fire-and-forget hook path, DialTimeout with a tight bound (e.g., 20ms) is safer and more consistent.


Tests

The bolt store tests are well-structured and cover the important scenarios. The TestPrune test uses same-time pre/post records, which as noted above masks the FindClosestCommand direction issue — a test with post.Time = pre.Time + time.Second would expose it. The fakeCommandStore and fakeConfigService in the handler tests are clean; no mockery needed here.

Coverage gaps from Codecov (model/store_file.go: 2.17%, model/tracking_build.go: 0%, daemon/client.go: 0%) are expected for infrastructure paths that require integration setup, and acceptable for the feature-gate PR.


Summary: The design and overall implementation quality is good. Issues #1–4 (nil panics, bounds check, transaction race, circuit breaker bypass) should be fixed before merge. #5 (silent empty ls) is a UX correctness issue also worth fixing. The rest are improvements.

claude added 4 commits June 2, 2026 10:26
Prune matched pre rows via pre.FindClosestCommand(posts), but that helper
only accepts candidates at or before the pre's time. A post always runs
after its pre, so it was never matched and synced pre commands were never
pruned — the bolt active bucket (and the pre.txt fallback) grew unbounded.

Match from the post side instead: a pre is finished when a synced post
(recording time <= cursor) with the same unique key ran at or after it. Extract
this as model.preHasSyncedPost, shared by the bolt and file stores.

Also: run boltStore.Prune in a single write transaction (collect posts and the
keys to delete in one pass over the archived bucket) and add defensive nil
checks on tx.Bucket plus a length guard in decodeKeyNano, per review feedback.

Adds a regression test covering the realistic case where post.Time > pre.Time.
Raise patch coverage on the new code with high-value unit tests:
- model.BuildTrackingData (assembly, exclude filtering, empty case)
- fileStore round trip (save/read/cursor/prune) over a temp HOME
- daemon handlePubSubTrackPost happy path (flush -> sync -> cursor -> prune)
  via an httptest server, plus the no-store error branch
- a socket round trip exercising list_commands (RequestListCommands /
  handleListCommands) and SendTrackEvent routing
- commands/ls.go file-store path (previously 0% covered) via a temp-HOME
  fixture exercising both json and table output
- handlePubSubTrackPre/Post parse-error branches
The `track` command runs inside the shell hook on every command and is
spawned as a fresh process each time, so the in-memory config cache never
helps and reading config.toml/config.local.toml adds filesystem latency to
every command. Stop reading config on the fast path: if a daemon is up on
the default socket, forward the raw pre/post event and return. Config is
only read in the daemon-less fallback (which may point at a custom socket
or drive the direct HTTP sync).

The daemon now owns the storage-engine decision and exclude filtering. It
already reads config (cached, long-lived process), so the bolt-vs-txt
choice and exclude rules live there. When the bolt engine is disabled the
track handlers fall back to the txt file store (both satisfy CommandStore)
instead of erroring, so default (file engine) users keep working through
the daemon.
@AnnatarHe

Copy link
Copy Markdown
Contributor Author

@claude ultrareview

@claude

claude Bot commented Jun 2, 2026

Copy link
Copy Markdown
Contributor

Claude finished @AnnatarHe's task in 6m 16s —— View job


Ultra Code Review: feat: bbolt-backed command store

  • Gather context and understand request
  • Read model layer (store.go, store_bolt.go, store_file.go, tracking_build.go, list.go)
  • Read daemon changes (handlers.track.go, socket.go, client.go, base.go, handlers.sync.go, handlers.go)
  • Read CLI command changes (track.go, ls.go, gc.go)
  • Read cmd/daemon/main.go
  • Read all test files
  • Read types and path changes
  • Cross-reference prior review findings
  • Post comprehensive ultra-review

Previous Critical Issues — Status

All four criticals from the first pass are confirmed fixed:

Issue Fix
Nil panics on tx.Bucket() return store_bolt.go:80-83, 95-98, 148-151, 168-171 — all four sites now check b == nil
decodeKeyNano bounds panic store_bolt.go:67-71 — length guard added
Prune two-transaction race store_bolt.go:182-241 — now a single db.Update, posts collected inside the write tx
FindClosestCommand direction store.go:90-110preHasSyncedPost correctly matches post.Time >= pre.Time; TestPruneRealisticTiming exercises the case

Still Open from First Pass

1. Circuit breaker bypass on bolt track-post path — daemon/handlers.track.go:108 ⚠️ Unresolved Critical

handlePubSubSync gates on syncCircuitBreaker.IsOpen() before calling sendTrackArgsToServer. handlePubSubTrackPost does not:

// handlers.track.go:108 — no circuit breaker check
if err := sendTrackArgsToServer(ctx, args); err != nil {

When the API server is down and the circuit is open, every shell post event still fires an HTTP call. In a high-shell-traffic environment (CI boxes, pair-programming sessions) this causes a connection timeout per command. The fix is a three-line guard matching handlePubSubSync:15-21.

2. Silent empty ls in bolt mode with daemon down — commands/ls.go:72-73 ⚠️ Unresolved High

} else {
    commands, err = model.BuildListedCommands(ctx, model.NewFileStore())

When storage.engine = bolt and the daemon is unreachable, ls quietly queries the txt files (which are empty in bolt mode) and returns zero rows. Users see an empty table with no explanation. This should return fmt.Errorf("daemon is not running; cannot read bolt store — start the daemon or use storage.engine=file").

3. No write deadline on SendTrackEventdaemon/client.go:64 ⚠️ Unresolved High

conn, err := net.Dial("unix", socketPath)   // no timeout
// conn.SetDeadline never called

RequestListCommands and RequestCCInfo both call conn.SetDeadline(time.Now().Add(timeout)). SendSessionProject uses net.DialTimeout(10ms). SendTrackEvent does neither. If the daemon's socket accept-queue or internal goroutine is stalled, a track hook invocation in the shell hangs indefinitely — blocking the prompt. A conn.SetDeadline(time.Now().Add(50*time.Millisecond)) after dial is the minimal fix.

4. Two config reads per flush — daemon/handlers.track.go:61 + daemon/handlers.sync.go:55 — Medium

handlePubSubTrackPost reads config (line 61) to get exclude rules, then calls sendTrackArgsToServer which reads it again (line 55) for token/endpoint/encryption settings. That's two full TOML parses on the hot command path every time data is flushed. Pass the already-read cfg into sendTrackArgsToServer as a parameter.

5. In-flight pub/sub messages dropped on shutdown — cmd/daemon/main.go:197-198 — Medium

pubsub.Close()    // closes the GoChannel — any buffered-but-unacked messages are lost
processor.Stop()  // socket handler stops after

SocketTopicProcessor is still consuming messages from msg (the subscribe channel) in its goroutine. When pubsub.Close() is called first, unprocessed messages are discarded. Reversing the order (processor.Stop()pubsub.Close()) and adding a brief drain window would reduce silent data loss on clean shutdown.

6. handleListCommands uses context.Background()daemon/socket.go:257 — Low

commands, err := model.BuildListedCommands(context.Background(), commandStore)

If the client disconnects mid-request, the bolt store read continues. Minor inconsistency vs the rest of the codebase; thread the connection's context through.


New Findings (Not in Previous Review)

7. ls doesn't check model.DefaultSocketPathcommands/ls.go:66 — High (New)

if useBolt && daemon.IsSocketReady(ctx, config.SocketPath) {

config.SocketPath is "" when not explicitly set in config.toml. os.Stat("") fails, so IsSocketReady returns false, and ls silently falls back to the empty txt store — even though the daemon is happily running on /tmp/shelltime.sock.

The track fast-path (line 100 of commands/track.go) explicitly checks model.DefaultSocketPath first, so track works fine without socketPath in config. ls does not get that treatment. Typical setup: user enables storage.engine = bolt, restarts daemon, runs shelltime ls — empty table. The fix is to check model.DefaultSocketPath as a fallback when config.SocketPath is empty (mirroring the track pattern).

8. cleanCommandFiles in gc.go still has the old direction bug — commands/gc.go:115 — Medium (New)

The 771e371 fix correctly replaced FindClosestCommand direction in store_bolt.go and store_file.go with preHasSyncedPost. But gc.go's cleanCommandFiles was not updated:

// gc.go:115 — still the old, broken direction
closestNode := row.FindClosestCommand(postCommands, true)

FindClosestCommand(..., true) on a pre command looks for a candidate with candidate.Time <= pre.Time. Since post commands run after their pre, the post's time is always greater, so closestNode is always nil, and every pre command is kept forever. In the no-daemon, file-mode path, gc is the only pruner — and it never prunes pre commands. The active fileStore.Prune (used by the daemon fallback) is correct, but cleanCommandFiles is dead code for pre cleanup.

The fix: replace cleanCommandFiles with model.NewFileStore().Prune(ctx, lastCursor) and delete the now-redundant local implementation.

9. Identity no-op in sendTrackArgsToServerdaemon/handlers.sync.go:63 — Low (New)

CursorID: time.Unix(0, syncMsg.CursorID).UnixNano(), // Convert nano timestamp to time.Time

time.Unix(0, n).UnixNano() == n — this is a lossless round-trip with no effect. The comment claims a type conversion is happening; it isn't. Should just be CursorID: syncMsg.CursorID. Pre-existing code but now more visible since sendTrackArgsToServer is shared.

10. Bolt store open failure silently downgrades to file mode — cmd/daemon/main.go:88-95 — Low (New)

if err != nil {
    slog.Error("Failed to open bolt command store", slog.Any("err", err))
} else {
    daemon.InitCommandStore(store)

If the bolt DB fails to open (corrupted, unexpected stale lock), commandStore stays nil and the daemon silently persists subsequent events to txt files. The CLI still sends track events to the daemon (it sees the socket), but data goes to files rather than bolt — invisible to the user. Since the IsSocketReady check in the CLI doesn't distinguish "daemon running in bolt mode" from "daemon running in file fallback mode", users have no way to detect this. At minimum, a clear warning message (or an error response on status requests) would help diagnose this scenario.


Test Coverage Notes

New tests are well-structured. A few gaps:

  • TestPrune still uses same-time pre/post (store_bolt_test.go:113) — TestPruneRealisticTiming at line 142 covers the post-after-pre case, which is great. Both cases are now covered.
  • No test for gc.go's cleanCommandFiles with realistic timing — this would expose finding chore(main): release 0.0.8 #8 above.
  • No test for ls with daemon down in bolt mode — would expose finding chore(main): release 0.0.2 #2 and chore(main): release 0.0.7 #7.
  • fileStore.Prune not covered by TestFileStoreRoundTrip's Prune call with post.Time > pre.Time — the test uses post.Time = start.Add(time.Second) which IS the realistic timing, and it asserts pres is empty (line 52-53). ✅ This case is covered.

Summary

# Finding Severity Status
1 Circuit breaker bypass in track_post Critical Open
2 Silent empty ls (bolt + daemon down) High Open
3 No write deadline on SendTrackEvent High Open
7 ls ignores DefaultSocketPath High New
4 Double config read per flush Medium Open
5 In-flight message loss on shutdown Medium Open
8 cleanCommandFiles still broken pruning Medium New
6 context.Background() in handleListCommands Low Open
9 Identity no-op CursorID round-trip Low New
10 Silent bolt→file fallback at startup Low New

The four previous criticals are all fixed. Issue #1 (circuit breaker) is the remaining blocker. Issues #2, #3, #7 are UX correctness problems worth resolving before users hit them. Issue #8 is a latent correctness bug in the file-mode gc path.

Copy link
Copy Markdown
Contributor Author

Ultrareview

Full pass over the diff (storage abstraction, bolt/file stores, daemon track handlers, CLI routing). Build is green and the store/handler unit tests pass. Findings below, roughly in priority order.

1. Behavior change worth a conscious sign-off: file-engine persistence now flows through the daemon

After the latest refactor, track forwards raw track_pre/track_post events to the daemon whenever the socket is up — for both engines. When bolt is off, the daemon now persists via fileStore (trackStore()NewFileStore()), whereas previously the CLI wrote the txt files itself and the daemon only HTTP-synced. Consequences:

  • Daemon $HOME must equal the user's. Txt files + cursor are written under $HOME/.shelltime. This was already true for bolt, but file-engine users never depended on the daemon's HOME before.
  • fileStore.Prune now runs after every flush (full rewrite of pre/post txt), where compaction previously only happened during gc/cleanup. More IO, and a concurrent ls reading mid-rewrite can observe a truncated file (a pre-existing gc race, now more frequent).

Net: cleaner and correct, but it changes the default path for all existing users, not just bolt adopters. Flagging so it's deliberate rather than incidental.

2. Low-probability ordering hazard: track_pre / track_post can be reordered

Each socket connection is handled in its own goroutine and published independently; GoChannel gives no cross-publish ordering guarantee (delivery races on the subscriber's sending mutex). If a track_post is handled before its track_pre, BuildTrackingData advances latest past the unpaired post (model/tracking_build.go:73, before the preTree lookup at :78), so a following flush + Prune can drop the post before the pre arrives → command lost.

In practice pre/post are wall-clock seconds apart, so this is near-zero. The latent footgun is that the cursor advances for posts that weren't paired. Cheap hardening: only advance latest for posts that are actually paired (or excluded), not orphans.

3. Minor robustness: unguarded cursor decode

boltStore.GetLastCursor does binary.BigEndian.Uint64(v) with no length check (model/store_bolt.go:158). We always write 8 bytes, so it's defensive-only, but a short/corrupt value panics. A len(v) >= 8 guard (mirroring decodeKeyNano) would make it total.

4. Stale doc comments

daemon/socket.go:26-34 still says track_pre/track_post and list_commands are "used when the bolt storage engine is enabled" — no longer accurate now that the daemon owns both engines for track events. The handler-side comments are updated; these aren't.

5. Pre-existing test flakiness (likely CI red on reruns)

The concurrent subtest in TestTrackTestSuite (goroutines + time.Sleep + exact pre/post line-count asserts) fails ~1/6 locally. Not introduced by this PR, but it's the most likely cause of intermittent red CI here. Worth a follow-up to make it deterministic.


None of these block merge in my view — #1 is a design call for you, #2 is a rare latent edge, and #3/#4 are trivial. Happy to push fixes for any subset (the #2 hardening, #3 guard, #4 comments are all small and low-risk) — just say which. codecov/patch remains advisory as discussed.


Generated by Claude Code

@AnnatarHe AnnatarHe merged commit 07544ff into main Jun 10, 2026
3 checks passed
@AnnatarHe AnnatarHe deleted the claude/gifted-allen-ojXBf branch June 10, 2026 08:03
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