Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/agentflare-backend/src/db.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const MIGRATION_LIST: &[M<'static>] = &[
M::up(include_str!("migrations/0007_ask_events.sql")),
M::up(include_str!("migrations/0008_bridge_repos.sql")),
M::up(include_str!("migrations/0009_vent_escalation.sql")),
M::up(include_str!("migrations/0010_project_dirs.sql")),
];
const MIGRATIONS: Migrations = Migrations::from_slice(MIGRATION_LIST);

Expand Down
1 change: 1 addition & 0 deletions crates/agentflare-backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub mod events;
pub mod item;
pub mod label;
pub mod project;
pub mod project_dir;
pub mod state;
pub mod vent;
pub mod webhook;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS project_dirs (
project_id TEXT PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE,
folder_path TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
112 changes: 112 additions & 0 deletions crates/agentflare-backend/src/project_dir.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
use crate::error::Result;
use rusqlite::{Connection, params};

/// One project's on-disk repo root — the reverse of `.agentflare/project.json`
/// (folder → project), indexed by project instead so a process with no
/// reliable cwd of its own (the daemon's background discovery loop) can
/// enumerate every project's folder it should operate against. Refreshed by
/// `resolve_project` wherever an agentflare CLI/MCP call runs inside a
/// linked repo. Unlike `bridge_repo`, not limited to GitHub-hosted repos —
/// every linked project gets a row here, regardless of its remote.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ProjectDir {
pub project_id: String,
pub folder_path: String,
pub updated_at: i64,
}

fn from_row(row: &rusqlite::Row) -> rusqlite::Result<ProjectDir> {
Ok(ProjectDir {
project_id: row.get(0)?,
folder_path: row.get(1)?,
updated_at: row.get(2)?,
})
}

pub fn upsert(conn: &Connection, project_id: &str, folder_path: &str, now: i64) -> Result<()> {
conn.execute(
"INSERT INTO project_dirs (project_id, folder_path, updated_at)
VALUES (?1, ?2, ?3)
ON CONFLICT(project_id) DO UPDATE SET
folder_path = excluded.folder_path,
updated_at = excluded.updated_at",
params![project_id, folder_path, now],
)?;
Ok(())
}

pub fn list(conn: &Connection) -> Result<Vec<ProjectDir>> {
let mut stmt = conn.prepare(
"SELECT project_id, folder_path, updated_at FROM project_dirs ORDER BY project_id",
)?;
let rows = stmt.query_map([], from_row)?;
Ok(rows.collect::<rusqlite::Result<Vec<_>>>()?)
}

#[cfg(test)]
mod tests {
use super::*;

fn seed_project(conn: &Connection, name: &str) -> String {
let workspace = crate::workspace::create(
conn,
crate::workspace::CreateWorkspace {
name: name.into(),
slug: name.into(),
owner_agent: None,
item_label: None,
},
)
.unwrap();
let project = crate::project::create(
conn,
crate::project::CreateProject {
workspace_id: workspace.id,
name: name.into(),
identifier: name.into(),
external_source: None,
external_id: None,
},
)
.unwrap();
project.id
}

#[test]
fn upsert_then_list_round_trips() {
let conn = crate::db::open_in_memory().unwrap();
let pid = seed_project(&conn, "proj");
upsert(&conn, &pid, "/home/avihs/projects/agentflare", 100).unwrap();

let rows = list(&conn).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].project_id, pid);
assert_eq!(rows[0].folder_path, "/home/avihs/projects/agentflare");
assert_eq!(rows[0].updated_at, 100);
}

#[test]
fn upsert_on_existing_project_updates_in_place() {
let conn = crate::db::open_in_memory().unwrap();
let pid = seed_project(&conn, "proj");
upsert(&conn, &pid, "/old/path", 100).unwrap();
upsert(&conn, &pid, "/new/path", 200).unwrap();

let rows = list(&conn).unwrap();
assert_eq!(rows.len(), 1, "same project must not create a second row");
assert_eq!(rows[0].folder_path, "/new/path");
assert_eq!(rows[0].updated_at, 200);
}

#[test]
fn list_returns_every_registered_project() {
let conn = crate::db::open_in_memory().unwrap();
let p1 = seed_project(&conn, "one");
let p2 = seed_project(&conn, "two");
upsert(&conn, &p1, "/repo/one", 1).unwrap();
upsert(&conn, &p2, "/repo/two", 2).unwrap();

let rows = list(&conn).unwrap();
assert_eq!(rows.len(), 2);
}
}
30 changes: 27 additions & 3 deletions src/cli/work.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ pub struct WorkArgs {
/// Channel recipient for a handoff artifact on outcome.
#[arg(long)]
pub notify: Option<String>,
/// The claimed item's own project directory, distinct from this
/// process's cwd — set by `WorkItemExecutor` from the job args the
/// supervisor's `dispatch_item` enqueues (item #63), so a daemon
/// dispatching an item from a different project than its own cwd still
/// claims/worktrees against the right repo. Not a CLI flag: a human
/// running `agentflare work` directly is already standing in the
/// right repo, same as before.
#[arg(skip)]
pub repo_root: Option<std::path::PathBuf>,
}

/// Cap on how much of the latest handoff asset's content gets inlined into
Expand Down Expand Up @@ -487,8 +496,17 @@ fn classify_and_cooldown(agent: &str, failure_message: &str) -> Option<u64> {
/// progress captured into that job's own log file — the exact same file the
/// dashboard already tails for subprocess-dispatched jobs — rather than only
/// working when there's a real subprocess's stdout to capture.
///
/// `args.repo_root`, when set (daemon dispatch — see `WorkItemExecutor`),
/// scopes project/worktree resolution to the claimed item's own project
/// directory instead of this process's cwd (item #63) — a human running
/// `agentflare work` directly leaves it unset and keeps the prior
/// cwd-resolved behavior.
pub(crate) fn execute_work(args: WorkArgs, log: &mut dyn std::io::Write) -> WorkOutcome {
let mcp = AgentflareMcp::default();
let mcp = match args.repo_root.clone() {
Some(root) => AgentflareMcp::for_project_dir(root),
None => AgentflareMcp::default(),
};
let timeout = Duration::from_secs(args.timeout);
let idle_timeout = Duration::from_secs(args.idle_timeout);

Expand Down Expand Up @@ -751,8 +769,12 @@ pub(crate) fn execute_work(args: WorkArgs, log: &mut dyn std::io::Write) -> Work
/// Runs an in-process work-item dispatch job for `agentflare_jobs::WorkerPool`
/// (see `dispatch_item` in `src/supervisor.rs`, which enqueues jobs this
/// executes) instead of the daemon spawning a fresh `agentflare work`
/// subprocess per item. `args` is `[item_id, agent]` — see `dispatch_item`
/// for how it's built.
/// subprocess per item. `args` is `[item_id, agent, folder_path]` — see
/// `dispatch_item` for how it's built. `folder_path` is optional on read
/// (via `args.get(2)`, not destructured like the first two) so a job
/// already queued from before item #63 — `[item_id, agent]` only — still
/// runs (against this process's cwd, the pre-#63 behavior) instead of
/// failing outright on daemon upgrade.
pub struct WorkItemExecutor;

impl agentflare_jobs::InProcessExecutor for WorkItemExecutor {
Expand All @@ -768,6 +790,7 @@ impl agentflare_jobs::InProcessExecutor for WorkItemExecutor {
)
.into());
};
let repo_root = args.get(2).map(std::path::PathBuf::from);
let work_args = WorkArgs {
target: item_id.clone(),
agent: Some(agent.clone()),
Expand All @@ -776,6 +799,7 @@ impl agentflare_jobs::InProcessExecutor for WorkItemExecutor {
max_turns: None,
max_cost_usd: None,
notify: None,
repo_root,
};
// `<agent>:<job-id>` — the job's own queue id is a natural instance
// discriminator, playing the role a subprocess's unique pid plays
Expand Down
40 changes: 40 additions & 0 deletions src/mcp_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,25 @@ impl AgentflareMcp {
}
}

/// Scopes this instance to a specific project's repo root instead of the
/// process's own cwd — used when dispatching or running work against a
/// project other than whichever repo this process happens to have been
/// started from. `backend_db`/`store` stay defaulted (those are the
/// single shared system-wide stores, not per-repo); only project-link
/// and worktree resolution — the two cwd-derived axes — are pinned to
/// `repo_root`. See `supervisor::dispatch_item` (which reads the
/// target's folder from the `project_dirs` registry) and
/// `cli::work::WorkItemExecutor`, which thread it through here.
pub(crate) fn for_project_dir(repo_root: std::path::PathBuf) -> Self {
Self {
backend_project_link_override: Some(
repo_root.join(Self::LINK_MARKER).join("project.json"),
),
worktree_repo_root_override: Some(repo_root),
..Default::default()
}
}

/// Pure walk-up so the non-git fallback path is unit-testable without
/// touching process-global state: neither this process's real cwd nor
/// `crate::paths::home()` (which itself reads the `AGENTFLARE_HOME_OVERRIDE`
Expand Down Expand Up @@ -874,6 +893,7 @@ impl AgentflareMcp {
match agentflare_backend::project::get(conn, &link.project_id) {
Ok(project) => {
self.register_bridge_repo(conn, &project.id);
self.register_project_dir(conn, &project.id);
return Ok(project);
}
Err(agentflare_backend::Error::NotFound(_)) => {} // stale link — re-resolve below
Expand Down Expand Up @@ -935,6 +955,7 @@ impl AgentflareMcp {
serde_json::to_vec_pretty(&link).unwrap_or_default(),
);
self.register_bridge_repo(conn, &project.id);
self.register_project_dir(conn, &project.id);
Ok(project)
}

Expand Down Expand Up @@ -964,6 +985,25 @@ impl AgentflareMcp {
);
}

/// Refreshes this repo's row in the general project-directory registry
/// (`project_dirs`) — the reverse of `project.json`'s folder→project
/// link, indexed by project instead so a process with no reliable cwd of
/// its own (the daemon's supervisor discovery loop) can enumerate every
/// project's folder it should operate against. Unlike
/// `register_bridge_repo`, not gated on a GitHub remote: every linked
/// project gets a row here. Best-effort — a registry write failure must
/// not break project resolution, which every MCP/CLI call depends on.
fn register_project_dir(&self, conn: &rusqlite::Connection, project_id: &str) {
let repo_root = Self::repo_root();
let folder_path = std::fs::canonicalize(&repo_root).unwrap_or(repo_root);
let _ = agentflare_backend::project_dir::upsert(
conn,
project_id,
&folder_path.to_string_lossy(),
crate::claims::now(),
);
}
Comment on lines +996 to +1005

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 | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

command -v ctx_search || true
command -v ctx_callgraph || true
command -v ctx_compose || true
command -v ctx_read || true
command -v ctx_shell || true
agentflare memory context 2>/dev/null || agentflare memory search 2>/dev/null || true
printf '%s\n' '--- candidate files ---'
git ls-files 'src/mcp_server.rs' 'src/cli/work.rs' '*project_dir*' '*bridge*' '*test*' | head -200
printf '%s\n' '--- relevant symbols ---'
rg -n -C 8 'register_project_dir|register_bridge_repo|for_project_dir|worktree_repo_root_override|resolve_project|repo_root|project_dirs' src/mcp_server.rs src/cli/work.rs

Repository: getappz/agentflare

Length of output: 35424


🏁 Script executed:

printf '%s\n' '--- mcp_server implementation ---'
sed -n '520,660p' src/mcp_server.rs
sed -n '840,1010p' src/mcp_server.rs
printf '%s\n' '--- backend registry upserts ---'
sed -n '1,240p' crates/agentflare-backend/src/project_dir.rs
sed -n '1,240p' crates/agentflare-backend/src/bridge_repo.rs
printf '%s\n' '--- dispatch path ---'
rg -n -C 12 'dispatch_item|project_dirs|folder_path|WorkItemExecutor' src crates/agentflare-jobs
printf '%s\n' '--- existing mcp tests and constructors ---'
rg -n -C 10 'for_project_dir|register_project_dir|register_bridge_repo|project_dir::|bridge_repo::|resolve_project' src/mcp_server/tests src/mcp_server.rs src/cli/work.rs

Repository: getappz/agentflare

Length of output: 50374


🏁 Script executed:

printf '%s\n' '--- supervisor dispatch implementation ---'
rg -n -A 90 -B 10 '^fn dispatch_item|dispatch_item\(' src/supervisor.rs
printf '%s\n' '--- RepoId remote resolution ---'
rg -n -A 100 -B 15 'struct RepoId|impl RepoId|resolve_from_remote' src crates
printf '%s\n' '--- project-dir consumers ---'
rg -n -A 35 -B 15 'project_dir::list|project_dirs::list|folder_path.*PathBuf|PathBuf.*folder_path' src crates
printf '%s\n' '--- focused tests ---'
rg -n -A 80 -B 15 'project_dir|for_project_dir|folder_path|dispatch_item' src/mcp_server/tests src/supervisor.rs src/cli/work.rs

Repository: getappz/agentflare

Length of output: 50375


🏁 Script executed:

printf '%s\n' '--- exact dispatch argument construction ---'
sed -n '235,275p' src/supervisor.rs
printf '%s\n' '--- project directory discovery ---'
sed -n '64,110p' src/supervisor.rs
printf '%s\n' '--- focused test names ---'
rg -n '^\s*fn [a-zA-Z0-9_]*' src/mcp_server.rs src/mcp_server/tests src/cli/work.rs src/supervisor.rs | rg 'project|repo|dispatch|worktree|cwd|root'
printf '%s\n' '--- read-only source invariant verifier ---'
python3 - <<'PY'
from pathlib import Path
import re

mcp = Path("src/mcp_server.rs").read_text()
backend = Path("crates/agentflare-backend/src/project_dir.rs").read_text()
supervisor = Path("src/supervisor.rs").read_text()

def body(name, start_after=0):
    pat = re.compile(rf'fn\s+{name}\s*\([^{{]*\)\s*(?:->[^{{]+)?\s*\{{')
    match = pat.search(mcp, start_after)
    if not match:
        raise SystemExit(f"missing {name}")
    depth = 0
    for i in range(match.end(), len(mcp)):
        if mcp[i] == "{":
            depth += 1
        elif mcp[i] == "}":
            if depth == 0:
                return mcp[match.start():i+1]
            depth -= 1
    raise SystemExit(f"unterminated {name}")

for_project = body("for_project_dir")
bridge = body("register_bridge_repo")
project_dir = body("register_project_dir")
valid_link_branch = mcp[mcp.index("Ok(project) => {"):mcp.index("Err(agentflare_backend::Error::NotFound", mcp.index("Ok(project) => {"))]

checks = {
    "for_project_dir sets worktree override": "worktree_repo_root_override: Some(repo_root)" in for_project,
    "bridge registration reads process root": "let repo_root = Self::repo_root();" in bridge,
    "project-dir registration reads process root": "let repo_root = Self::repo_root();" in project_dir,
    "valid link refreshes bridge registry": "self.register_bridge_repo(conn, &project.id);" in valid_link_branch,
    "valid link refreshes project-dir registry": "self.register_project_dir(conn, &project.id);" in valid_link_branch,
    "project-dir upsert updates by project id": "ON CONFLICT(project_id) DO UPDATE" in backend,
    "discovery forwards registered folder": "Some(folder_path)" in supervisor and "enqueue_work_job(queue, item, agent, Some(folder_path))" in supervisor,
}
for label, ok in checks.items():
    print(f"{'PASS' if ok else 'FAIL'}: {label}")
if all(checks.values()):
    print("RESULT: the scoped override is not used by either registry refresh, and the project-dir row is later used for dispatch.")
else:
    raise SystemExit(1)
PY

Repository: getappz/agentflare

Length of output: 9269


Use the scoped repository root for both registry refreshes.

for_project_dir sets the target root, but register_bridge_repo and register_project_dir still call Self::repo_root(). A daemon worker can overwrite the target project's registry entries with the daemon's repository. Later dispatches can create worktrees in the wrong repository.

Add a context-aware root helper. Use it in both methods. Add a regression test with different process and scoped roots.

🤖 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 996 - 1005, Add a context-aware
repository-root helper that returns the root established by for_project_dir,
falling back to the process root when no scoped root exists. Update both
register_bridge_repo and register_project_dir to use this helper instead of
Self::repo_root(), and add a regression test verifying distinct process and
scoped roots refresh the scoped project’s registry entries with the scoped root.


/// NotFound and InvalidInput (version conflict) are caller-fixable →
/// invalid_params; everything else is an internal error.
fn artifact_error(e: std::io::Error) -> ErrorData {
Expand Down
Loading
Loading