Skip to content

Guard agentflare db files from agent deletion + fix artifact store history tracking - #335

Merged
getappz merged 6 commits into
masterfrom
store-and-db-guard
Jul 26, 2026
Merged

Guard agentflare db files from agent deletion + fix artifact store history tracking#335
getappz merged 6 commits into
masterfrom
store-and-db-guard

Conversation

@getappz

@getappz getappz commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Summary

  • store: migrate artifact storage onto agentflare-store documents+blobs (ArtifactStore gains a with_store(Store) backend: content-addressed blobs, gzip, dedup, FTS5 search, version history via doc_history; dashboard serves artifacts under /artifacts/; falls back to flat-file storage on error)
  • hook_redirect: block agent shell commands (Bash/PowerShell, Claude Code and opencode) from deleting agentflare's own *.db/*.db-wal/*.db-shm files or the .agentflare dir itself — landed after opencode ran an rm mid-migration today and silently wiped store.db's metadata for 168 artifacts, recovered by hand from a pre-migration flat-file backup that happened to still exist
  • store: fix doc_upsert_with_opts's history-skip check, which never recorded history for blob-backed callers (compared an always-empty content string instead of also checking blob_hash) — surfaced by running the existing artifact-store test suite against current master
  • clippy nits (io_other_error, needless_borrow) surfaced by -D warnings

Test plan

  • cargo test --bin agentflare hook_redirect — 31/31 pass, including new regression tests for the delete-guard and a false-positive case (a git commit heredoc whose message describes the incident in prose must not itself get blocked)
  • cargo test -p agentflare-store — 46/46 pass
  • cargo test -p agentflare-artifacts — 32/32 pass
  • cargo clippy --workspace --all-features -- -D warnings — clean
  • cargo test --workspace — 796/799 pass; 3 pre-existing failures (state::tests::*, vent::capture::tests::append_routed_suppresses_second_identical_call_within_window) confirmed flaky/unrelated — pass individually in isolation, don't touch anything in this diff
  • Manually verified the delete guard end-to-end: piped the exact incident payload through the installed binary and confirmed it denies

Summary by CodeRabbit

  • New Features
    • Added an artifacts dashboard for browsing, viewing version history, comparing changes, and receiving live updates (SSE).
    • Artifacts can now use persistent shared storage with version history, content deduplication, filtering, and deletion.
    • Coaching now supports rule tiers and syncing rules to selected hosts, including a manual sync command.
  • Bug Fixes
    • Added safeguards blocking destructive deletions of Agentflare’s local database files.
    • Improved automatic fallback to local file storage when shared storage cannot be opened.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

ArtifactStore gains shared document/blob persistence with filesystem fallback and dashboard routes. Coaching rules gain tiers and host synchronization across CLI and generated rule targets. Shell interception blocks destructive deletion of local agentflare database data.

Changes

Shared artifact storage

Layer / File(s) Summary
Document/blob artifact backend
crates/agentflare-artifacts/Cargo.toml, crates/agentflare-artifacts/src/store.rs, crates/agentflare-store/src/documents.rs
Artifacts use document/blob storage with conflict checks, deduplication, metadata, history, and refresh broadcasting.
Store-backed reads and validation
crates/agentflare-artifacts/src/store.rs
Reads, versions, diffs, listing, deletion, blob reconstruction, and store-backed tests are added.
Shared-store runtime selection
src/artifacts.rs, src/cli/handoff.rs, src/mcp_server.rs
Artifact entry points prefer the shared store and fall back to filesystem storage.
Artifact dashboard routes
src/dashboard/artifacts.rs, src/dashboard/mod.rs, src/dashboard/server.rs
Dashboard routes render artifacts and versions, return JSON history, stream live updates, and mount under /artifacts.

Coaching rule tiers and synchronization

Layer / File(s) Summary
Rule tier and sync metadata
src/coaching/rule.rs
Rules persist tier and sync-host metadata with validation and backward-compatible parsing.
Tier-aware rule storage
src/coaching/store.rs
Rule application and removal carry sync data, and changed builtin bodies are recorded as superseded snapshots.
Coaching synchronization commands
src/cli/coaching.rs, src/coaching/cli.rs, src/coaching/mod.rs
CLI apply and sync actions synchronize rules to selected hosts and unsynchronize removed rules.
Host rule materialization
src/components.rs, src/init.rs, src/hook.rs
Host-specific coaching content is generated, refreshed, removed, and wired into OpenCode instructions with stale-rule tests.

Local data deletion guard

Layer / File(s) Summary
Destructive command classification
src/hook_redirect.rs
Shell classification blocks destructive deletion of agentflare database paths while testing allowed, denied, and separator-based commands.

Estimated code review effort: 5 (Critical) | ~90+ minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant ArtifactDashboard
  participant ArtifactStore
  participant SharedStore
  participant BlobStore
  Client->>ArtifactDashboard: request artifact route
  ArtifactDashboard->>ArtifactStore: get artifact or version
  ArtifactStore->>SharedStore: load document metadata
  ArtifactStore->>BlobStore: load referenced snapshot
  SharedStore-->>ArtifactStore: return document and history
  ArtifactStore-->>ArtifactDashboard: return artifact data
  ArtifactDashboard-->>Client: render HTML, JSON, or SSE response
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main change set: guarding agentflare DB files from deletion and fixing artifact store history tracking.
Description check ✅ Passed The description is mostly complete and covers summary and test plan, but the Notes for reviewers section is missing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch store-and-db-guard

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 6

🧹 Nitpick comments (6)
crates/agentflare-artifacts/src/store.rs (3)

146-147: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

ArtifactMeta.id is persisted empty on the store path.

Harmless today because doc_to_artifact/get_store read the id from doc.path, but any future consumer deserializing the metadata blob gets "". Setting it to the document path costs nothing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-artifacts/src/store.rs` around lines 146 - 147, Update the
ArtifactMeta construction in the store path to initialize id with the document’s
path instead of an empty string. Preserve the existing metadata serialization
and ensure the persisted ArtifactMeta.id matches the path used by
doc_to_artifact/get_store.

173-176: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Store path accepts any update_id, unlike the flat path.

publish_flat (Line 249-253) only reuses update_id when the artifact directory exists, otherwise it mints a new id. Here an unknown/typo'd update_id silently creates a document at that path. Consider mirroring the flat behavior for consistency:

♻️ Suggested tweak
-        let id = req.update_id.clone().unwrap_or_else(|| nanoid::nanoid!());
+        let id = match req.update_id.as_deref() {
+            Some(uid)
+                if store
+                    .doc_get_by_path(DOC_PROJECT, uid)
+                    .map_err(Self::store_conn_err)?
+                    .is_some() =>
+            {
+                uid.to_string()
+            }
+            _ => nanoid::nanoid!(),
+        };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-artifacts/src/store.rs` around lines 173 - 176, Update
publish_store to reuse req.update_id only when the corresponding artifact
directory already exists, matching publish_flat; otherwise generate a fresh
nanoid. Preserve the current path and response handling for valid existing IDs.

649-656: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a no-op republish test to lock in dedup behavior.

The new suite covers publish/get/versions/list/delete/diff, but not the dedup branch (Line 194) — the one place where an algorithm mismatch would silently inflate versions and history. A test that republishes identical content and asserts the version stays put would catch it.

Also applies to: 761-855

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-artifacts/src/store.rs` around lines 649 - 656, Add a test
near the existing publish/version tests that uses the doc_store helper to
publish identical content twice, then asserts the second publish is a no-op and
the artifact version remains unchanged. Ensure the test specifically exercises
the deduplication branch in the publish flow without altering the existing test
coverage.
src/dashboard/artifacts.rs (1)

20-32: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

open_store() runs at router-construction time and is duplicated.

Two notes: (1) the store handle is opened eagerly whenever router() is built (including in src/dashboard/server.rs's test that constructs the router), which touches the real ~/.agentflare store; (2) the fallback block duplicates src/artifacts.rs — see the consolidated comment.

Also applies to: 109-118

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dashboard/artifacts.rs` around lines 20 - 32, Update open_store and its
router-construction call path so artifact storage is not opened eagerly when the
dashboard router is built, including server tests. Reuse the existing
store-opening and fallback logic from src/artifacts.rs instead of duplicating
the ArtifactStore::with_store and ArtifactStore::new branches, while preserving
the ArtifactState base_url and shared store initialization behavior.
src/artifacts.rs (1)

5-16: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Extract the shared-store-with-flat-file-fallback selection into one helper. Three call sites repeat the same crate::store::open()ArtifactStore::with_store → stderr log → ~/.agentflare/artifacts fallback, each with its own log prefix; a divergence in any one of them changes where artifacts land.

  • src/artifacts.rs#L5-L16: replace the else branch with a call to a new shared helper (e.g. crate::artifacts::default_store()), keeping the dir-provided branch as-is.
  • src/cli/handoff.rs#L92-L103: replace the None arm with the shared helper.
  • src/dashboard/artifacts.rs#L20-L32: build store from the shared helper inside open_store.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/artifacts.rs` around lines 5 - 16, Extract the repeated
shared-store-with-flat-file-fallback logic into a single helper in the artifacts
module, such as default_store(), including the existing fallback path and stderr
logging. In src/artifacts.rs lines 5-16, replace only the dir-less branch with
this helper and preserve the provided-dir branch; in src/cli/handoff.rs lines
92-103, replace the None arm; and in src/dashboard/artifacts.rs lines 20-32, use
the helper when constructing store inside open_store.
src/hook_redirect.rs (1)

61-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting the statement-splitting and targeting checks into small helpers.

Static analysis flags this function as high complexity. The nested flat_map chain plus the sequential boolean flags (is_destructive_verb, targets_agentflare_dir, targets_db_or_whole_dir) could be split into a split_statements(command) helper and a couple of named predicate functions for readability, without changing behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hook_redirect.rs` around lines 61 - 105, Reduce complexity in
destructive_data_file_reason by extracting statement splitting into a
split_statements helper and moving the destructive-verb, .agentflare-target, and
database/whole-directory checks into named predicate helpers. Preserve the
current statement delimiters, normalization, matching behavior, and
blocked-message result.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/agentflare-store/src/documents.rs`:
- Around line 193-194: Update the unchanged calculation in the document upsert
flow to compare blob hashes only when opts.blob_hash is provided; treat an
omitted hash as unchanged for existing blob-backed documents while preserving
content comparison and explicit hash-change detection. Keep the track_history
condition unchanged.

In `@src/dashboard/artifacts.rs`:
- Around line 53-56: Update the artifact handlers around the synchronous store
calls and render operations, including the paths at the shown locations, to
execute SQLite/filesystem work via spawn_blocking, matching the existing index
handler’s pattern. Move each state.store access and associated synchronous
rendering into the blocking closure, await its result, and preserve the current
not-found response and handler responses.
- Around line 92-100: Update the SSE bridge task around the subscribe loop to
use timed receives (such as recv_timeout) instead of blocking indefinitely on
rx.recv(), periodically checking whether the unbounded channel’s tx has been
closed and exiting when it is. Also configure the Sse response with
keep_alive(...) so idle live connections remain active through intermediaries.
- Around line 49-83: Apply the existing valid_id guard at the start of
artifact_page, artifact_version_page, and versions_json, returning the same
invalid-ID response used by artifact_live before calling store.get,
store.get_version, or store.versions. Keep valid IDs on their current rendering
and JSON paths.

In `@src/hook_redirect.rs`:
- Around line 78-105: Update destructive_data_file_reason to split statements on
the lone '&' operator, removing the redundant && splitting while preserving
empty-statement handling. Normalize the first command token so path-qualified
binaries and the backslash-normalized /rm form are recognized by comparing its
basename to the destructive verbs, while retaining the existing agentflare path
and database/recursive deletion checks.

In `@src/mcp_server.rs`:
- Around line 419-426: Update the artifact store initialization in the shown
match block to use the instance’s self.store_override when opening the store
instead of always calling crate::store::open(). Preserve the existing fallback
to the flat-file ArtifactStore when opening the selected override fails,
ensuring private instances remain isolated.

---

Nitpick comments:
In `@crates/agentflare-artifacts/src/store.rs`:
- Around line 146-147: Update the ArtifactMeta construction in the store path to
initialize id with the document’s path instead of an empty string. Preserve the
existing metadata serialization and ensure the persisted ArtifactMeta.id matches
the path used by doc_to_artifact/get_store.
- Around line 173-176: Update publish_store to reuse req.update_id only when the
corresponding artifact directory already exists, matching publish_flat;
otherwise generate a fresh nanoid. Preserve the current path and response
handling for valid existing IDs.
- Around line 649-656: Add a test near the existing publish/version tests that
uses the doc_store helper to publish identical content twice, then asserts the
second publish is a no-op and the artifact version remains unchanged. Ensure the
test specifically exercises the deduplication branch in the publish flow without
altering the existing test coverage.

In `@src/artifacts.rs`:
- Around line 5-16: Extract the repeated shared-store-with-flat-file-fallback
logic into a single helper in the artifacts module, such as default_store(),
including the existing fallback path and stderr logging. In src/artifacts.rs
lines 5-16, replace only the dir-less branch with this helper and preserve the
provided-dir branch; in src/cli/handoff.rs lines 92-103, replace the None arm;
and in src/dashboard/artifacts.rs lines 20-32, use the helper when constructing
store inside open_store.

In `@src/dashboard/artifacts.rs`:
- Around line 20-32: Update open_store and its router-construction call path so
artifact storage is not opened eagerly when the dashboard router is built,
including server tests. Reuse the existing store-opening and fallback logic from
src/artifacts.rs instead of duplicating the ArtifactStore::with_store and
ArtifactStore::new branches, while preserving the ArtifactState base_url and
shared store initialization behavior.

In `@src/hook_redirect.rs`:
- Around line 61-105: Reduce complexity in destructive_data_file_reason by
extracting statement splitting into a split_statements helper and moving the
destructive-verb, .agentflare-target, and database/whole-directory checks into
named predicate helpers. Preserve the current statement delimiters,
normalization, matching behavior, and blocked-message result.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b8d7d350-58ab-4439-b757-0d15a9aff0e0

📥 Commits

Reviewing files that changed from the base of the PR and between d1b2a0d and 0ea8a1d.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (10)
  • crates/agentflare-artifacts/Cargo.toml
  • crates/agentflare-artifacts/src/store.rs
  • crates/agentflare-store/src/documents.rs
  • src/artifacts.rs
  • src/cli/handoff.rs
  • src/dashboard/artifacts.rs
  • src/dashboard/mod.rs
  • src/dashboard/server.rs
  • src/hook_redirect.rs
  • src/mcp_server.rs

Comment on lines +193 to +194
let unchanged = old_content == content && old_blob_hash == opts.blob_hash;
if opts.track_history && !unchanged {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Any caller that re-upserts an existing doc without blob_hash?
rg -nP -C6 'doc_upsert_with_opts\(' src crates --glob '!**/documents.rs'

Repository: getappz/agentflare

Length of output: 5961


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== crates/agentflare-store/src/documents.rs (around lines 150-250) ==\n'
sed -n '150,250p' crates/agentflare-store/src/documents.rs

printf '\n== Caller sites with doc_upsert_with_opts and blob_hash usage ==\n'
rg -n -C4 'doc_upsert_with_opts\(|blob_hash:' src crates --glob '!**/documents.rs'

Repository: getappz/agentflare

Length of output: 11557


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== crates/agentflare-store/src/documents.rs (full upsert logic slice) ==\n'
sed -n '180,240p' crates/agentflare-store/src/documents.rs

printf '\n== src/asset_store.rs ==\n'
sed -n '68,110p' src/asset_store.rs

printf '\n== src/mcp_server/asset.rs ==\n'
sed -n '92,125p' src/mcp_server/asset.rs

printf '\n== src/mcp_server/handoff.rs ==\n'
sed -n '126,155p' src/mcp_server/handoff.rs

printf '\n== crates/agentflare-artifacts/src/store.rs ==\n'
sed -n '208,235p' crates/agentflare-artifacts/src/store.rs

Repository: getappz/agentflare

Length of output: 9011


Treat omitted blob_hash as unchanged on blob-backed docs
Re-upserting an existing blob-backed doc without blob_hash makes Some(old) != None, so history is recorded on every call even though the blob column is left untouched. Compare the blob hash only when the caller supplies one.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/agentflare-store/src/documents.rs` around lines 193 - 194, Update the
unchanged calculation in the document upsert flow to compare blob hashes only
when opts.blob_hash is provided; treat an omitted hash as unchanged for existing
blob-backed documents while preserving content comparison and explicit
hash-change detection. Keep the track_history condition unchanged.

Comment thread src/dashboard/artifacts.rs Outdated
Comment on lines +49 to +83
async fn artifact_page(
State(state): State<ArtifactState>,
Path(id): Path<String>,
) -> Response {
let Ok(artifact) = state.store.get(&id) else {
return (StatusCode::NOT_FOUND, "artifact not found").into_response();
};
let html = agentflare_artifacts::render_artifact_page(&artifact, true, &state.base_url);
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}

async fn artifact_version_page(
State(state): State<ArtifactState>,
Path(VersionPath { id, version }): Path<VersionPath>,
) -> Response {
let Ok(artifact) = state.store.get_version(&id, version) else {
return (StatusCode::NOT_FOUND, "artifact version not found").into_response();
};
let html = agentflare_artifacts::render_artifact_page(&artifact, false, &state.base_url);
([(header::CONTENT_TYPE, "text/html; charset=utf-8")], html).into_response()
}

async fn versions_json(
State(state): State<ArtifactState>,
Path(id): Path<String>,
) -> Response {
match state.store.versions(&id) {
Ok(history) => (
[(header::CONTENT_TYPE, "application/json")],
serde_json::to_string_pretty(&history).unwrap_or_else(|_| "[]".into()),
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, "artifact not found").into_response(),
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Missing valid_id check on the page/version/versions routes.

Only artifact_live validates the id (Line 89). When open_store() falls back to the flat-file ArtifactStore (Line 25), get/get_version/versions resolve base_path.join(id), and axum percent-decodes path segments, so %2e%2e%2f… can escape the artifacts directory. Apply the same guard used by the artifact server:

🛡️ Proposed fix
 async fn artifact_page(
     State(state): State<ArtifactState>,
     Path(id): Path<String>,
 ) -> Response {
+    if !agentflare_artifacts::valid_id(&id) {
+        return (StatusCode::NOT_FOUND, "invalid id").into_response();
+    }
     let Ok(artifact) = state.store.get(&id) else {

…and the equivalent guard at the top of artifact_version_page and versions_json.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dashboard/artifacts.rs` around lines 49 - 83, Apply the existing valid_id
guard at the start of artifact_page, artifact_version_page, and versions_json,
returning the same invalid-ID response used by artifact_live before calling
store.get, store.get_version, or store.versions. Keep valid IDs on their current
rendering and JSON paths.

Comment on lines +53 to +56
let Ok(artifact) = state.store.get(&id) else {
return (StatusCode::NOT_FOUND, "artifact not found").into_response();
};
let html = agentflare_artifacts::render_artifact_page(&artifact, true, &state.base_url);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Blocking SQLite/filesystem reads on the async runtime thread.

index correctly wraps render_index in spawn_blocking, but these three handlers call the synchronous store directly from async context. Under concurrent requests this stalls the executor. Wrap them the same way index does.

Also applies to: 64-67, 75-75

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dashboard/artifacts.rs` around lines 53 - 56, Update the artifact
handlers around the synchronous store calls and render operations, including the
paths at the shown locations, to execute SQLite/filesystem work via
spawn_blocking, matching the existing index handler’s pattern. Move each
state.store access and associated synchronous rendering into the blocking
closure, await its result, and preserve the current not-found response and
handler responses.

Comment on lines +92 to +100
let rx = state.store.subscribe(&id);
let (tx, async_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
tokio::task::spawn_blocking(move || {
while let Ok(event) = rx.recv() {
if tx.send(event).is_err() {
break;
}
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The SSE bridge task parks a blocking thread until an event arrives, even after the client disconnects.

ArtifactStore::broadcast only wakes subscribers when a refresh actually happens, so rx.recv() blocks indefinitely for artifacts that are never republished; the tx.send error check can't run until then. Each abandoned /live connection therefore holds a thread from the (bounded) blocking pool permanently. Use recv_timeout in the loop so the task can notice a closed tx and exit:

🛡️ Proposed fix
     tokio::task::spawn_blocking(move || {
-        while let Ok(event) = rx.recv() {
-            if tx.send(event).is_err() {
-                break;
-            }
-        }
+        loop {
+            match rx.recv_timeout(std::time::Duration::from_secs(15)) {
+                Ok(event) => {
+                    if tx.send(event).is_err() {
+                        break;
+                    }
+                }
+                // Nothing published yet — bail out once the client is gone.
+                Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
+                    if tx.is_closed() {
+                        break;
+                    }
+                }
+                Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
+            }
+        }
     });

Also worth adding .keep_alive(...) to the Sse response so idle connections aren't dropped by intermediaries.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let rx = state.store.subscribe(&id);
let (tx, async_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
tokio::task::spawn_blocking(move || {
while let Ok(event) = rx.recv() {
if tx.send(event).is_err() {
break;
}
}
});
let rx = state.store.subscribe(&id);
let (tx, async_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
tokio::task::spawn_blocking(move || {
loop {
match rx.recv_timeout(std::time::Duration::from_secs(15)) {
Ok(event) => {
if tx.send(event).is_err() {
break;
}
}
// Nothing published yet — bail out once the client is gone.
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
if tx.is_closed() {
break;
}
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
}
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/dashboard/artifacts.rs` around lines 92 - 100, Update the SSE bridge task
around the subscribe loop to use timed receives (such as recv_timeout) instead
of blocking indefinitely on rx.recv(), periodically checking whether the
unbounded channel’s tx has been closed and exiting when it is. Also configure
the Sse response with keep_alive(...) so idle live connections remain active
through intermediaries.

Comment thread src/hook_redirect.rs
Comment on lines +78 to +105
fn destructive_data_file_reason(command: &str) -> Option<String> {
for statement in command.split([';', '\n']).flat_map(|s| s.split("&&")).flat_map(|s| s.split("||")).flat_map(|s| s.split('|')) {
let trimmed = statement.trim().to_lowercase().replace('\\', "/");
let Some(first_word) = trimmed.split_whitespace().next() else {
continue;
};
let is_destructive_verb = matches!(first_word, "rm" | "del" | "erase" | "remove-item" | "unlink" | "rmdir");
if !is_destructive_verb {
continue;
}
let targets_agentflare_dir =
trimmed.contains(".agentflare/") || trimmed.ends_with(".agentflare");
if !targets_agentflare_dir {
continue;
}
// Either a specific *.db*/-wal/-shm file, or a recursive/whole-dir
// delete of .agentflare itself (which would take the db files with it).
let targets_db_or_whole_dir = trimmed.contains(".db")
|| trimmed.ends_with(".agentflare")
|| trimmed.contains(" -r ")
|| trimmed.contains(" -rf")
|| trimmed.contains("-recurse");
if targets_db_or_whole_dir {
return Some("deleting agentflare's local data files (~/.agentflare/*.db, *.db-wal, *.db-shm, or the .agentflare directory itself) is blocked — they hold tracked items, artifacts, and secrets with no automatic backup. If a file genuinely needs to be removed, ask the user to run the command themselves.".to_string());
}
}
None
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Detection can be bypassed by sudo/path-qualified binaries and a lone &.

Two concrete gaps in the statement/verb matching let a genuinely destructive delete slip through undetected:

  1. matches!(first_word, "rm" | "del" | ...) requires an exact match, so sudo rm ~/.agentflare/store.db, /bin/rm ~/.agentflare/store.db, or ./rm ... never match (first word is "sudo" / "/bin/rm" / "./rm"). Ironically, the \/ normalization on line 80 (added for Windows paths) also turns bash's alias-bypass idiom \rm into /rm, which then fails the exact match too.
  2. The statement splitter (line 79) only breaks on ;, \n, &&, ||, | — it never splits on a lone &. A single & is a real bash/POSIX control operator that separates commands (it backgrounds the first one), so sleep 1 & rm ~/.agentflare/store.db is treated as one statement with first word "sleep", and the trailing rm is never inspected.

Both are easy, realistic ways an agent (or a copy-pasted one-liner) could trigger the exact incident this guard exists to prevent.

🔒 Proposed fix for both gaps
-    for statement in command.split([';', '\n']).flat_map(|s| s.split("&&")).flat_map(|s| s.split("||")).flat_map(|s| s.split('|')) {
+    for statement in command.split([';', '\n', '&']).flat_map(|s| s.split("||")).flat_map(|s| s.split('|')) {
         let trimmed = statement.trim().to_lowercase().replace('\\', "/");
         let Some(first_word) = trimmed.split_whitespace().next() else {
             continue;
         };
-        let is_destructive_verb = matches!(first_word, "rm" | "del" | "erase" | "remove-item" | "unlink" | "rmdir");
+        let verb = first_word.rsplit('/').next().unwrap_or(first_word);
+        let is_destructive_verb = matches!(verb, "rm" | "del" | "erase" | "remove-item" | "unlink" | "rmdir");

Note splitting the initial char set on & also subsumes && (each && becomes two adjacent & splits, yielding an empty statement that's already skipped), so the separate .split("&&") step can be dropped. sudo/env/command-prefixed invocations are a further residual gap worth a follow-up if worth the complexity.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn destructive_data_file_reason(command: &str) -> Option<String> {
for statement in command.split([';', '\n']).flat_map(|s| s.split("&&")).flat_map(|s| s.split("||")).flat_map(|s| s.split('|')) {
let trimmed = statement.trim().to_lowercase().replace('\\', "/");
let Some(first_word) = trimmed.split_whitespace().next() else {
continue;
};
let is_destructive_verb = matches!(first_word, "rm" | "del" | "erase" | "remove-item" | "unlink" | "rmdir");
if !is_destructive_verb {
continue;
}
let targets_agentflare_dir =
trimmed.contains(".agentflare/") || trimmed.ends_with(".agentflare");
if !targets_agentflare_dir {
continue;
}
// Either a specific *.db*/-wal/-shm file, or a recursive/whole-dir
// delete of .agentflare itself (which would take the db files with it).
let targets_db_or_whole_dir = trimmed.contains(".db")
|| trimmed.ends_with(".agentflare")
|| trimmed.contains(" -r ")
|| trimmed.contains(" -rf")
|| trimmed.contains("-recurse");
if targets_db_or_whole_dir {
return Some("deleting agentflare's local data files (~/.agentflare/*.db, *.db-wal, *.db-shm, or the .agentflare directory itself) is blocked — they hold tracked items, artifacts, and secrets with no automatic backup. If a file genuinely needs to be removed, ask the user to run the command themselves.".to_string());
}
}
None
}
fn destructive_data_file_reason(command: &str) -> Option<String> {
for statement in command.split([';', '\n', '&']).flat_map(|s| s.split("||")).flat_map(|s| s.split('|')) {
let trimmed = statement.trim().to_lowercase().replace('\\', "/");
let Some(first_word) = trimmed.split_whitespace().next() else {
continue;
};
let verb = first_word.rsplit('/').next().unwrap_or(first_word);
let is_destructive_verb = matches!(verb, "rm" | "del" | "erase" | "remove-item" | "unlink" | "rmdir");
if !is_destructive_verb {
continue;
}
let targets_agentflare_dir =
trimmed.contains(".agentflare/") || trimmed.ends_with(".agentflare");
if !targets_agentflare_dir {
continue;
}
// Either a specific *.db*/-wal/-shm file, or a recursive/whole-dir
// delete of .agentflare itself (which would take the db files with it).
let targets_db_or_whole_dir = trimmed.contains(".db")
|| trimmed.ends_with(".agentflare")
|| trimmed.contains(" -r ")
|| trimmed.contains(" -rf")
|| trimmed.contains("-recurse");
if targets_db_or_whole_dir {
return Some("deleting agentflare's local data files (~/.agentflare/*.db, *.db-wal, *.db-shm, or the .agentflare directory itself) is blocked — they hold tracked items, artifacts, and secrets with no automatic backup. If a file genuinely needs to be removed, ask the user to run the command themselves.".to_string());
}
}
None
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hook_redirect.rs` around lines 78 - 105, Update
destructive_data_file_reason to split statements on the lone '&' operator,
removing the redundant && splitting while preserving empty-statement handling.
Normalize the first command token so path-qualified binaries and the
backslash-normalized /rm form are recognized by comparing its basename to the
destructive verbs, while retaining the existing agentflare path and
database/recursive deletion checks.

Comment thread src/mcp_server.rs
Comment on lines +419 to +426
let store = match crate::store::open() {
Ok(s) => std::sync::Arc::new(agentflare_artifacts::ArtifactStore::with_store(s)),
Err(e) => {
eprintln!("[artifacts] fallback to flat-file store: {e}");
let dir = crate::paths::home().join(".agentflare").join("artifacts");
std::sync::Arc::new(agentflare_artifacts::ArtifactStore::new(dir))
}
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -nP -C4 'fn open\(|fn store_path' src/store.rs
rg -nP -C3 'store_override' src

Repository: getappz/agentflare

Length of output: 3857


🏁 Script executed:

sed -n '390,450p' src/mcp_server.rs
sed -n '670,700p' src/mcp_server.rs
sed -n '80,115p' src/mcp_server.rs
sed -n '1,60p' src/store.rs

Repository: getappz/agentflare

Length of output: 8317


🏁 Script executed:

rg -n -C3 'for_test|artifacts_dir_override|ensure_artifact_server|ArtifactStore::with_store|ArtifactStore::new' src/mcp_server.rs src/mcp_server

Repository: getappz/agentflare

Length of output: 13460


🏁 Script executed:

sed -n '520,590p' src/mcp_server.rs
sed -n '1,120p' src/mcp_server/flare_docs.rs
sed -n '120,220p' src/mcp_server.rs

Repository: getappz/agentflare

Length of output: 13430


🏁 Script executed:

rg -n -C4 'struct ArtifactStore|fn with_store|ArtifactStore::with_store|with_store\(' crates src

Repository: getappz/agentflare

Length of output: 12493


🏁 Script executed:

rg -n -C3 '\bfor_test\s*\(' src

Repository: getappz/agentflare

Length of output: 3136


Use self.store_override when opening the artifact-backed store. The None branch still calls crate::store::open(), so an instance with a private store_override writes artifacts into the default ~/.agentflare/store.db. Reuse the same override path here so private instances stay isolated.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mcp_server.rs` around lines 419 - 426, Update the artifact store
initialization in the shown match block to use the instance’s
self.store_override when opening the store instead of always calling
crate::store::open(). Preserve the existing fallback to the flat-file
ArtifactStore when opening the selected override fails, ensuring private
instances remain isolated.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/coaching/rule.rs (1)

63-72: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Known-host list duplicated with ALL_HOSTS in src/coaching/cli.rs.

KNOWN_SYNC_HOSTS here and ALL_HOSTS in src/coaching/cli.rs are two independent copies of the same 7 host strings. If a host is ever added/removed from one but not the other, --sync validation and coaching sync --agent will silently disagree on what's "known".

Consider exporting one list (e.g. pub(super) const KNOWN_SYNC_HOSTS) and having cli.rs reuse it instead of maintaining ALL_HOSTS separately.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/coaching/rule.rs` around lines 63 - 72, Centralize the host definitions
by exporting rule.rs’s KNOWN_SYNC_HOSTS and updating cli.rs to reuse it instead
of maintaining the duplicate ALL_HOSTS list. Ensure both --sync validation and
coaching sync --agent derive their known-host behavior from this single shared
constant.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components.rs`:
- Around line 255-274: Implement full reconciliation across
src/components.rs:255-274 in sync_now by replacing the exact is_stale_rule
heuristic with provenance-aware drift detection for previously generated
joined-host files and synced Override-tier rules; update
src/components.rs:276-298 in unsync_host to regenerate joined-host files rather
than deleting nonexistent per-rule files; update src/init.rs:434-520 in
wire_opencode_instructions so retain() removes every rules_dir-scoped entry
absent from expected_filenames, not only the hardcoded legacy entry; and update
src/coaching/cli.rs:52-86 in cli_apply to load the prior sync list before
store::apply_rule, then invoke unsync_host for hosts removed by the new --sync
selection.

---

Nitpick comments:
In `@src/coaching/rule.rs`:
- Around line 63-72: Centralize the host definitions by exporting rule.rs’s
KNOWN_SYNC_HOSTS and updating cli.rs to reuse it instead of maintaining the
duplicate ALL_HOSTS list. Ensure both --sync validation and coaching sync
--agent derive their known-host behavior from this single shared constant.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 7d896c90-582a-4401-a420-89ff3629a9e7

📥 Commits

Reviewing files that changed from the base of the PR and between 0ea8a1d and ed00524.

📒 Files selected for processing (8)
  • src/cli/coaching.rs
  • src/coaching/cli.rs
  • src/coaching/mod.rs
  • src/coaching/rule.rs
  • src/coaching/store.rs
  • src/components.rs
  • src/hook.rs
  • src/init.rs

Comment thread src/components.rs
Comment on lines +255 to +274
pub(crate) fn sync_now(host: &str) -> Result<String, String> {
let mut written = 0usize;
let mut refreshed = 0usize;
for (path, content) in rule_targets(host) {
if !path.exists() {
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
fs::write(&path, format!("{content}\n")).map_err(|e| e.to_string())?;
written += 1;
} else if crate::init::is_stale_rule(&path, &content) {
fs::write(&path, format!("{content}\n")).map_err(|e| e.to_string())?;
refreshed += 1;
}
}
if host == "opencode" {
crate::init::wire_opencode_instructions();
}
Ok(format!("{written} written, {refreshed} refreshed"))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Coaching-to-host sync/unsync only implements "create a new file"; update and removal are all broken for the same underlying reason.

None of these sites implement real reconciliation for already-materialized host content — each only handles the "file doesn't exist yet" case, so updates and removals for joined-host files and already-synced rules are silently dropped.

  • src/components.rs#L255-L274: sync_now's is_stale_rule-based refresh only fires on an exact match to a pre-registered "old wording" (compiled rule_text::superseded() or a Builtin-tier snapshot_previous_body); it never detects real drift for joined-host files (fixed names unrelated to any rule id) or for Override-tier body edits to already-synced rules. Replace the exact-match heuristic with a way to recognize "we generated this before and it's now different" regardless of whether that exact combination was ever registered.
  • src/components.rs#L276-L298: unsync_host looks for a standalone {rule_id}.md that is never created for joined hosts (cursor/codex/windsurf/vscode-copilot/cline), so it's a guaranteed no-op there — route joined-host teardown through a regenerate-the-joined-file path instead of a per-rule file delete.
  • src/init.rs#L434-L520: wire_opencode_instructions only ever adds missing instructions entries (plus one hardcoded legacy-engram removal); generalize the retain() cleanup to drop any rules_dir-scoped entry not in the current expected_filenames set.
  • src/coaching/cli.rs#L52-L86: cli_apply never diffs the rule's previous sync list against the new one, so hosts dropped via --sync are never unsynced at all — fetch the old sync list before calling store::apply_rule and call the (fixed) unsync path for hosts that fell out.
📍 Affects 3 files
  • src/components.rs#L255-L274 (this comment)
  • src/components.rs#L276-L298
  • src/init.rs#L434-L520
  • src/coaching/cli.rs#L52-L86
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components.rs` around lines 255 - 274, Implement full reconciliation
across src/components.rs:255-274 in sync_now by replacing the exact
is_stale_rule heuristic with provenance-aware drift detection for previously
generated joined-host files and synced Override-tier rules; update
src/components.rs:276-298 in unsync_host to regenerate joined-host files rather
than deleting nonexistent per-rule files; update src/init.rs:434-520 in
wire_opencode_instructions so retain() removes every rules_dir-scoped entry
absent from expected_filenames, not only the hardcoded legacy entry; and update
src/coaching/cli.rs:52-86 in cli_apply to load the prior sync list before
store::apply_rule, then invoke unsync_host for hosts removed by the new --sync
selection.

getappz added a commit that referenced this pull request Jul 25, 2026
Fixes the fmt and clippy failures on PR #335, introduced by the coaching
tier/sync commits added after the PR's original CI pass:
- cargo fmt --all across the files rustfmt flagged (hook_redirect.rs,
  coaching/{cli,mod,rule,store}.rs, cli/coaching.rs, components.rs,
  dashboard/artifacts.rs, init.rs, mcp_server.rs, agentflare-artifacts/store.rs)
- coaching/cli.rs: surface CoachingRule's tier and applied_at fields in
  `agentflare coaching list` output (was clippy dead_code; also useful to a
  user checking whether a rule is builtin vs override, and when it was applied)
- coaching/store.rs: drop needless borrow in create_dir_all(rules_dir())
- init.rs: use Vec::contains instead of iter().any() for a plain equality check
getappz added 6 commits July 26, 2026 03:48
ArtifactStore gains with_store(Store) constructor backed by
documents + blobs (content-addressed, gzip, deduped). Metadata
serialized to documents.metadata JSON. Version history via
doc_history. FTS5 search replaces flat-file scan (fixes #180).

Dashboard serves artifacts under /artifacts/ via axum routes
(index, artifact page, version page, versions JSON, SSE live).
No more random-port URL — uses dashboard port.

Integration: ensure_artifact_server, CLI handoff, standalone
serve all default to store backend, fall back to flat-file.

32 tests pass (26 original + 6 new store-backed).
… files

opencode ran an rm mid-migration and silently wiped store.db's metadata,
recovered by hand from a pre-migration flat-file backup that happened to
still exist. Extend the shared PreToolUse classifier to deny destructive
shell commands targeting agentflare's own db files or the data dir itself,
for Bash and PowerShell tool calls.
DocUpsertOpts gained a track_history field upstream since this branch's
artifact-store migration was written; doc_upsert_with_opts's history-skip
check compared old_content != content, but blob-backed callers (artifacts)
always pass an empty content string and store the real payload via
blob_hash, so the check was always a no-op false and history rows never
got recorded. Compare blob_hash too. Also fixes two clippy findings
(io_other_error, needless_borrow) surfaced by -D warnings.
…n, staleness, CLI

RuleTier (Builtin/Override), sync fields on CoachingRule, parse/write.
apply_rule/remove_rule carry tier+sync, sync_targets_for_host query.
rule_targets merges coaching rules per host (per-file or joined).
Snapshot previous body on builtin overwrite; is_stale_rule checks it.
sync_now/unsync_host for immediate materialization.
CLI: --tier/--sync flags, sync subcommand.
Tested: 112 pass across coaching/components/init/hook.
wire_opencode_instructions's doc comment promised removing entries for
rules no longer synced, but only the hardcoded legacy engram.md path
was ever pruned -- unsync_host deleting a coaching rule's file left a
dangling reference to it in opencode.jsonc forever. Retain only array
entries under our rules_dir whose filename is still expected.
Fixes the fmt and clippy failures on PR #335, introduced by the coaching
tier/sync commits added after the PR's original CI pass:
- cargo fmt --all across the files rustfmt flagged (hook_redirect.rs,
  coaching/{cli,mod,rule,store}.rs, cli/coaching.rs, components.rs,
  dashboard/artifacts.rs, init.rs, mcp_server.rs, agentflare-artifacts/store.rs)
- coaching/cli.rs: surface CoachingRule's tier and applied_at fields in
  `agentflare coaching list` output (was clippy dead_code; also useful to a
  user checking whether a rule is builtin vs override, and when it was applied)
- coaching/store.rs: drop needless borrow in create_dir_all(rules_dir())
- init.rs: use Vec::contains instead of iter().any() for a plain equality check
@getappz
getappz force-pushed the store-and-db-guard branch from f779a84 to b8bb87c Compare July 26, 2026 00:49
@getappz
getappz merged commit db719fe into master Jul 26, 2026
38 of 48 checks passed
@getappz
getappz deleted the store-and-db-guard branch July 26, 2026 04:59
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