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
40 changes: 40 additions & 0 deletions src/cli/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@ pub enum DaemonSubcommand {
Status,
Enable,
Disable,
/// Print the current daemon session's stdout+stderr (bridge activity,
/// dashboard startup, etc). Truncated fresh on every start/restart.
Logs {
/// Keep printing new lines as the daemon writes them.
#[arg(short, long)]
follow: bool,
},
}

impl DaemonArgs {
Expand All @@ -25,6 +32,7 @@ impl DaemonArgs {
DaemonSubcommand::Status => cmd_status(),
DaemonSubcommand::Enable => cmd_enable(),
DaemonSubcommand::Disable => cmd_disable(),
DaemonSubcommand::Logs { follow } => cmd_logs(follow),
}
}
}
Expand Down Expand Up @@ -70,6 +78,38 @@ fn cmd_status() {
}
}

fn cmd_logs(follow: bool) {
use std::io::{Read, Write};
let path = crate::daemon::daemon_log_path();
let mut file = match std::fs::File::open(&path) {
Ok(f) => f,
Err(e) => {
eprintln!("error: {}: {e}", path.display());
std::process::exit(1);
}
};
let mut buf = String::new();
let _ = file.read_to_string(&mut buf);
print!("{buf}");
let _ = std::io::stdout().flush();
if !follow {
return;
}
// Simplest portable tail -f: re-read from the current position on the
// same handle, which reflects append-mode writes made by a different
// process to the same file. Not robust to the daemon restarting mid-tail
// (a fresh log truncates the same path) -- good enough for `-f` used
// interactively, same tradeoff `agentflare work`'s own log tailing makes.
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
let mut chunk = String::new();
if file.read_to_string(&mut chunk).is_ok() && !chunk.is_empty() {
print!("{chunk}");
let _ = std::io::stdout().flush();
Comment on lines +98 to +108

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- available code-intelligence and memory tools ---'
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
command -v agentflare || true
if command -v agentflare >/dev/null 2>&1; then
  agentflare memory context 2>/dev/null || true
fi
printf '%s\n' '--- tracked candidate files ---'
git ls-files 'src/cli/daemon.rs' 'src/ipc/process.rs'
printf '%s\n' '--- relevant source ---'
sed -n '1,150p' src/cli/daemon.rs
sed -n '1,90p' src/ipc/process.rs

Repository: getappz/agentflare

Length of output: 7033


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- daemon log path and startup call sites ---'
rg -n -C 5 'daemon_log_path|spawn_detached|File::create|cmd_logs' src
printf '%s\n' '--- standalone cursor/truncation verifier ---'
python3 - <<'PY'
class FileModel:
    def __init__(self, data=b"old log\n"):
        self.data = bytearray(data)
        self.position = 0

    def read_to_end(self):
        chunk = bytes(self.data[self.position:])
        self.position = len(self.data)
        return chunk

    def truncate_from_other_handle(self):
        self.data.clear()

    def append_from_other_handle(self, data):
        self.data.extend(data)

    def reset_if_shrunk(self):
        if self.position > len(self.data):
            self.position = 0

f = FileModel()
initial = f.read_to_end()
old_position = f.position
f.truncate_from_other_handle()
without_reset = f.read_to_end()
f.append_from_other_handle(b"new log\n")
after_growth = f.read_to_end()

g = FileModel()
g.read_to_end()
g.truncate_from_other_handle()
g.reset_if_shrunk()
with_reset = g.read_to_end()
g.append_from_other_handle(b"new log\n")
with_reset_after_growth = g.read_to_end()

print({
    "initial": initial.decode(),
    "position_after_initial_read": old_position,
    "without_reset_after_truncate": without_reset.decode(),
    "without_reset_after_growth": after_growth.decode(),
    "with_reset_after_truncate": with_reset.decode(),
    "with_reset_after_growth": with_reset_after_growth.decode(),
    "skips_new_prefix_without_reset": after_growth != b"new log\n",
    "captures_new_log_with_reset": with_reset_after_growth == b"new log\n",
})
PY

Repository: getappz/agentflare

Length of output: 6023


🏁 Script executed:

#!/bin/bash
set -u
printf '%s\n' '--- daemon start/restart path ---'
sed -n '112,158p' src/daemon.rs
printf '%s\n' '--- corrected standalone cursor/truncation verifier ---'
python3 - <<'PY'
class Follower:
    def __init__(self, initial):
        self.data = bytearray(initial)
        self.position = 0

    def initial_read(self):
        out = bytes(self.data[self.position:])
        self.position = len(self.data)
        return out

    def restart_truncate(self):
        self.data.clear()

    def append(self, data):
        self.data.extend(data)

    def current_read(self):
        out = bytes(self.data[self.position:])
        self.position = len(self.data)
        return out

    def reset_if_shrunk(self):
        if self.position > len(self.data):
            self.position = 0

old_log = b"old daemon output\n" * 3  # 54 bytes
new_log = b"new daemon output\n"            # shorter than old_log

current = Follower(old_log)
current.initial_read()
old_position = current.position
current.restart_truncate()
first_new_write = new_log[:8]
current.append(first_new_write)
without_reset_first = current.current_read()
current.append(new_log[8:])
without_reset_second = current.current_read()

fixed = Follower(old_log)
fixed.initial_read()
fixed.restart_truncate()
fixed.reset_if_shrunk()
with_reset_first = fixed.current_read()
fixed.append(first_new_write)
with_reset_second = fixed.current_read()
fixed.append(new_log[8:])
with_reset_third = fixed.current_read()

print({
    "old_log_length": len(old_log),
    "old_cursor": old_position,
    "new_log_length": len(new_log),
    "current_first_read_after_restart": without_reset_first,
    "current_second_read_after_restart": without_reset_second,
    "fixed_read_before_new_write": with_reset_first,
    "fixed_first_new_chunk": with_reset_second,
    "fixed_second_new_chunk": with_reset_third,
    "current_misses_initial_new_bytes": without_reset_first == b"",
    "current_later_read_is_empty_until_file_exceeds_old_cursor":
        without_reset_second == b"",
    "fixed_captures_new_bytes": with_reset_second + with_reset_third == new_log,
})
PY

Repository: getappz/agentflare

Length of output: 2415


🏁 Script executed:

#!/bin/bash
set -u
python3 - <<'PY'
class Follower:
    def __init__(self, initial):
        self.data = bytearray(initial)
        self.position = 0

    def read_to_end(self):
        if self.position >= len(self.data):
            return b""
        out = bytes(self.data[self.position:])
        self.position += len(out)
        return out

    def restart_truncate(self):
        self.data.clear()

    def append(self, data):
        self.data.extend(data)

    def reset_if_shrunk(self):
        if self.position > len(self.data):
            self.position = 0

old_log = b"old daemon output\n" * 3
new_log = b"new daemon output\n"

current = Follower(old_log)
initial = current.read_to_end()
old_cursor = current.position
current.restart_truncate()
current.append(new_log[:8])
current_first = current.read_to_end()
current.append(new_log[8:])
current_second = current.read_to_end()
current.append(b"x" * (old_cursor - len(current.data) + 1))
current_third = current.read_to_end()

fixed = Follower(old_log)
fixed.read_to_end()
fixed.restart_truncate()
fixed.reset_if_shrunk()
fixed_before_write = fixed.read_to_end()
fixed.append(new_log[:8])
fixed_first = fixed.read_to_end()
fixed.append(new_log[8:])
fixed_second = fixed.read_to_end()

print({
    "initial_length": len(initial),
    "old_cursor": old_cursor,
    "new_length_before_crossing_old_cursor": len(new_log),
    "current_reads_before_crossing_old_cursor": [
        current_first, current_second
    ],
    "current_read_after_crossing_old_cursor": current_third,
    "fixed_reads": [fixed_before_write, fixed_first, fixed_second],
    "current_misses_new_log_prefix": current_first + current_second == b"",
    "current_reads_only_after_old_cursor": current_third != b"",
    "fixed_captures_new_log": fixed_first + fixed_second == new_log,
})
PY

Repository: getappz/agentflare

Length of output: 513


Reset the cursor after log truncation.

A daemon restart truncates daemon.log through File::create. The open follower handle keeps its previous offset. If the new log is shorter than that offset, --follow misses new output until the file grows beyond the old offset.

Before each follow read, compare file.stream_position() with file.metadata()?.len(). If the file shrank, seek to offset zero before reading.

🤖 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/cli/daemon.rs` around lines 98 - 108, Update the follow loop around the
file read to detect truncation before calling read_to_string: compare
file.stream_position() with file.metadata()?.len(), and seek to position zero
when the current offset exceeds the file length. Preserve the existing polling,
chunk printing, and stdout flushing behavior, while propagating or handling I/O
errors consistently with the surrounding daemon code.

}
}
}

fn cmd_enable() {
match crate::daemon_autostart::install() {
Ok(()) => println!("autostart enabled"),
Expand Down
15 changes: 15 additions & 0 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ pub fn daemon_pid_path() -> PathBuf {
.unwrap_or_else(|| std::env::temp_dir().join("agentflare-daemon.pid"))
}

/// stdout+stderr of the current daemon session — truncated fresh on every
/// `start`/`restart` (see `spawn_detached`), same lifetime as the runtime
/// dir it lives in. Not a rotated history: `agentflare daemon logs` is for
/// seeing what THIS run of the daemon is doing, not an audit trail.
pub fn daemon_log_path() -> PathBuf {
dirs::runtime_dir()
.map(|d| d.join("agentflare").join("daemon.log"))
.unwrap_or_else(|| std::env::temp_dir().join("agentflare-daemon.log"))
Comment on lines +17 to +20

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 | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files 'src/daemon.rs' 'src/ipc/process.rs' 'Cargo.toml' 'src/*' | sed -n '1,120p'

printf '%s\n' '--- daemon.rs outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline src/daemon.rs
else
  echo 'ast-grep unavailable'
fi

printf '%s\n' '--- daemon.rs relevant lines ---'
nl -ba src/daemon.rs | sed -n '1,100p'

printf '%s\n' '--- process.rs relevant symbols and lines ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline src/ipc/process.rs
else
  echo 'ast-grep unavailable'
fi
nl -ba src/ipc/process.rs | sed -n '1,180p'

printf '%s\n' '--- log-path and file-creation references ---'
rg -n -C 4 'daemon_log_path|File::create|daemon\.log|agentflare-daemon\.log|cmd_logs|runtime_dir|temp_dir' src Cargo.toml

Repository: getappz/agentflare

Length of output: 36248


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
from pathlib import Path

for name in ("src/daemon.rs", "src/ipc/process.rs"):
    path = Path(name)
    print(f"--- {name}: {path.stat().st_size} bytes ---")
    text = path.read_text()
    for i, line in enumerate(text.splitlines(), 1):
        if any(token in line for token in (
            "daemon_log_path", "File::create", "OpenOptions", "cmd_logs",
            "Command", "runtime_dir", "temp_dir", "logs",
        )):
            start = max(1, i - 8)
            end = min(len(text.splitlines()), i + 12)
            lines = text.splitlines()
            print(f"[lines {start}-{end}]")
            for j in range(start, end + 1):
                print(f"{j:4}: {lines[j-1]}")
PY

printf '%s\n' '--- callers of daemon_log_path ---'
rg -n -C 6 'daemon_log_path\s*\(' .

Repository: getappz/agentflare

Length of output: 24592


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- all relevant source references ---'
rg -n -C 8 'daemon_log_path|File::create|OpenOptions|create_new|symlink|cmd_logs|runtime_dir|temp_dir' --glob '*.rs' .

printf '%s\n' '--- daemon and logs command definitions ---'
rg -n -C 12 'fn (daemon|logs|cmd_logs)|struct .*Args|enum .*Command|logs' src --glob '*.rs'

printf '%s\n' '--- dependency and platform context ---'
rg -n -C 3 '^\[dependencies\]|^dirs\s*=|^tempfile\s*=|^rustix\s*=|^nix\s*=|^target|cfg\(unix\)|cfg\(windows\)' Cargo.toml Cargo.lock src --glob '*.rs' --glob '*.toml'

Repository: getappz/agentflare

Length of output: 50375


Use a private fallback log location.

When dirs::runtime_dir() returns None, daemon_log_path() uses a predictable file in the shared temporary directory. File::create follows a pre-created symlink and truncates its target.

Use a per-user directory with restrictive permissions, or create the log file with symlink-safe semantics.

🤖 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/daemon.rs` around lines 17 - 20, Update daemon_log_path to avoid the
predictable shared temporary fallback when dirs::runtime_dir() returns None. Use
a per-user private directory with restrictive permissions for the fallback, or
ensure the log file creation path uses symlink-safe semantics while preserving
the existing runtime-directory location.

}

pub fn daemon_start_lock_path() -> PathBuf {
dirs::runtime_dir()
.map(|d| d.join("agentflare").join("daemon.start.lock"))
Expand Down Expand Up @@ -122,12 +132,17 @@ pub fn start_daemon() -> Result<u32, String> {
}

let binary = std::env::current_exe().map_err(|e| format!("current_exe: {e}"))?;
let log_path = daemon_log_path();
if let Some(parent) = log_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create log dir {parent:?}: {e}"))?;
}
// Must match the `ExecStart`/`ProgramArguments` invocation the installed
// systemd/launchd units use (see `daemon_autostart.rs`) — both spawn
// `serve --_foreground-daemon`, not just the bare flag.
let _pid = process::spawn_detached(
&binary.to_string_lossy(),
&["serve", "--_foreground-daemon"],
Some(&log_path),
)?;

for _ in 0..20 {
Expand Down
30 changes: 27 additions & 3 deletions src/ipc/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,35 @@ pub fn is_alive(pid: u32) -> bool {
}
}

pub fn spawn_detached(binary: &str, args: &[&str]) -> Result<u32, String> {
/// Spawns `binary` detached from the calling process. `log_path`, if given,
/// is truncated and used for both the child's stdout and stderr — `None`
/// discards them, same as before this parameter existed.
pub fn spawn_detached(
binary: &str,
args: &[&str],
log_path: Option<&std::path::Path>,
) -> Result<u32, String> {
// One shared `File` cloned for both streams: two independent
// `File::create` handles to the same path would each get their own
// write cursor at 0 and clobber each other instead of interleaving,
// the same reason a shell needs `2>&1` rather than two redirects.
let (out, err) = match log_path {
Some(p) => {
let f = std::fs::File::create(p)
.map_err(|e| format!("open log file {}: {e}", p.display()))?;
let f2 = f
.try_clone()
.map_err(|e| format!("open log file {}: {e}", p.display()))?;
(std::process::Stdio::from(f), std::process::Stdio::from(f2))
}
None => (std::process::Stdio::null(), std::process::Stdio::null()),
};
#[cfg(windows)]
{
let mut cmd = std::process::Command::new(binary);
cmd.args(args);
cmd.stdout(out);
cmd.stderr(err);
cmd.creation_flags(
windows_sys::Win32::System::Threading::CREATE_NEW_PROCESS_GROUP
| windows_sys::Win32::System::Threading::DETACHED_PROCESS,
Expand All @@ -44,8 +68,8 @@ pub fn spawn_detached(binary: &str, args: &[&str]) -> Result<u32, String> {
let child = std::process::Command::new(binary)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.stdout(out)
.stderr(err)
.spawn()
.map_err(|e| format!("spawn: {e}"))?;
Ok(child.id())
Expand Down
Loading