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
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -198,4 +198,6 @@ jobs:
- name: Build
run: cmake --build build -j
- name: System tests
run: bash tests/system/test_macos_e2e.sh
run: |
bash tests/system/test_macos_e2e.sh
bash tests/system/macos/test_workspace_cli.sh
3 changes: 2 additions & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,8 @@ if(APPLE AND AGENTVFS_FUSE_T)
add_executable(agentvfs
src/cas/platform/macos/main_macos.cpp
src/cas/platform/macos/fuse_t_adapter.cpp
src/cas/platform/macos/fuse_t_preflight.cpp)
src/cas/platform/macos/fuse_t_preflight.cpp
src/cas/workspace_cli.cpp)
target_include_directories(agentvfs PRIVATE
src/cas include ${FUSE_T_INCLUDE_DIRS})
target_compile_options(agentvfs PRIVATE ${FUSE_T_CFLAGS_OTHER})
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ agentvfs-ctl.exe --sock \\.\pipe\agentvfs-<hash> checkpoint baseline
## Driving the daemon directly

```bash
agentvfs workspace start my-task --from /path/to/project
agentvfs workspace init my-task --from /path/to/project
agentvfs workspace start my-task
agentvfs workspace checkpoint my-task before-refactor
# ... agent makes changes ...
agentvfs workspace rollback my-task before-refactor
Expand Down Expand Up @@ -77,7 +78,7 @@ FUSE / WinFsp / fuse-t ──► WorkingTree (in-memory, COW) ──► Obje
| Content-Addressed Store | ✅ | ✅ | ✅ |
| Per-Agent Branches | ✅ | Coming soon | Coming soon |
| Pluggable Telemetry | ✅ | Coming soon | Coming soon |
| `agentvfs workspace` CLI | ✅ | Coming soon | Coming soon |
| `agentvfs workspace` CLI | ✅ | | Coming soon |

## Build from source

Expand Down
2 changes: 1 addition & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ Three parts, built on L0 (bounded memory / GC) and L1 (API + routing token). 2a
### 2c — Platform parity track *(parallel breadth)*
- **macOS/Windows branches.** Wire L1.2 token-based routing into the `fuse_t_adapter` and `winfsp_adapter` (cgroup v2 is unavailable off-Linux — the token *is* the routing mechanism there).
- **Telemetry off-Linux.** Today Linux-only (eBPF / fanotify / ptrace / `LD_PRELOAD`). Scope a feasible reduced backend — FUSE/WinFsp op-level capture, or FSEvents/ETW — and set expectations that off-Linux telemetry is weaker by nature.
- **`agentvfs workspace` CLI off-Linux.** Port `workspace_cli` state management + control plane (the Windows named-pipe channel already exists; macOS uses the AF_UNIX path).
- **`agentvfs workspace` parity off-Linux.** macOS now has `workspace_cli` state management + control plane; finish the remaining parity work for Windows and close the feature gaps that still differ from Linux.

**Parallelism note:** 2a/2b are the dependency-spine continuation; 2c is genuinely independent once the token lands, so a second contributor can drive parity while the first drives scale — but 2c *cannot start its branch work before L1.2*. That is the one hard cross-track edge.

Expand Down
12 changes: 11 additions & 1 deletion src/cas/platform/macos/main_macos.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#include "daemon.h"
#include "platform.h"
#include "platform/macos/fuse_t_preflight.h"
#include "workspace_cli.h"

#include <cerrno>
#include <cstdint>
Expand Down Expand Up @@ -41,8 +42,13 @@ bool parse(int argc, char** argv, Args& out) {
if (a == "--source" && need()) out.source = argv[++i];
else if (a == "--mountpoint" && need()) out.mountpoint = argv[++i];
else if (a == "--store" && need()) out.store = argv[++i];
else if (a == "--sock" && need()) out.control_sock = argv[++i];
else if ((a == "--sock" || a == "--control-sock") && need()) out.control_sock = argv[++i];
else if (a == "--volume-name" && need()) out.volume_name = argv[++i];
else if (a == "-o" && need()) { ++i; }
else if (a == "-f") { /* foreground is already the only mode */ }
else if (a.rfind("--telemetry=", 0) == 0) {
/* Workspace CLI can pass Linux telemetry flags; macOS v1 ignores them. */
}
else if (a == "-h" || a == "--help") { usage(); return false; }
else { usage(); return false; }
}
Expand All @@ -67,6 +73,10 @@ bool parse(int argc, char** argv, Args& out) {
} // namespace

int main(int argc, char** argv) {
if (argc >= 2 && std::string(argv[1]) == "workspace") {
return cas::workspace::run_workspace_cli(argc - 1, argv + 1, argv[0]);
}

Args ca;
if (!parse(argc, argv, ca)) return 1;

Expand Down
29 changes: 27 additions & 2 deletions src/cas/workspace_cli.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,18 @@ static bool pid_alive(long pid) {
}

static bool mountpoint_active(const std::string& path) {
#ifdef __APPLE__
std::string cmd = "mount | grep -F -- ";
cmd += "'";
for (char c : path) {
if (c == '\'') cmd += "'\\''";
else cmd.push_back(c);
}
cmd += "'";
cmd += " >/dev/null";
int rc = system(cmd.c_str());
return rc == 0;
#else
std::string cmd = "mountpoint -q ";
cmd += "'";
for (char c : path) {
Expand All @@ -643,6 +655,7 @@ static bool mountpoint_active(const std::string& path) {
cmd += "'";
int rc = system(cmd.c_str());
return rc == 0;
#endif
}

static bool path_owned_by_current_user_or_absent(const std::string& path, std::string& error) {
Expand Down Expand Up @@ -830,6 +843,7 @@ static pid_t spawn_daemon(const std::string& self_path,
const std::string& telemetry,
bool allow_root,
std::string& error) {
(void)telemetry;
pid_t pid = fork();
if (pid < 0) {
error = "fork: " + std::string(std::strerror(errno));
Expand All @@ -849,9 +863,11 @@ static pid_t spawn_daemon(const std::string& self_path,
"--mountpoint", paths.mount,
"--store", paths.store,
"--control-sock", paths.socket,
"--telemetry=" + telemetry,
"-f"
};
#ifndef __APPLE__
args.push_back("--telemetry=" + telemetry);
#endif
if (allow_root) {
args.push_back("-o");
args.push_back("allow_root");
Expand All @@ -870,7 +886,11 @@ static bool unmount_workspace(const std::string& mount_path) {
pid_t pid = fork();
if (pid < 0) return false;
if (pid == 0) {
#ifdef __APPLE__
execlp("umount", "umount", "-f", mount_path.c_str(), (char*)nullptr);
#else
execlp("fusermount3", "fusermount3", "-u", mount_path.c_str(), (char*)nullptr);
#endif
_exit(127);
}
int status = 0;
Expand Down Expand Up @@ -1073,7 +1093,6 @@ static int command_init(const ParsedCommon& opts) {
return 1;
}

std::string cmd = "cp -a --reflink=auto -- ";
auto shell_quote = [](const std::string& s) {
std::string out = "'";
for (char c : s) {
Expand All @@ -1083,6 +1102,12 @@ static int command_init(const ParsedCommon& opts) {
out += "'";
return out;
};
std::string cmd;
#ifdef __APPLE__
cmd = "cp -a ";
#else
cmd = "cp -a --reflink=auto -- ";
#endif
cmd += shell_quote(opts.from_dir + "/.");
cmd += " ";
cmd += shell_quote(paths.source);
Expand Down
139 changes: 139 additions & 0 deletions tests/system/macos/test_workspace_cli.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/bin/bash
set -euo pipefail

TEST_NAME="test_workspace_cli"

if [[ "$(uname)" != "Darwin" ]]; then
echo "$TEST_NAME: skipping (not macOS)"
exit 0
fi

if ! [[ -d /Library/Filesystems/fuse-t.fs ]]; then
brew install --cask macos-fuse-t/cask/fuse-t
fi

REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
BIN_DIR="${BIN_DIR:-$REPO_ROOT/build}"
BIN="$BIN_DIR/agentvfs"
WS_ROOT="$(mktemp -d /tmp/agentvfs-workspace-macos.XXXXXX)"
# macOS /tmp is a symlink to /private/tmp; resolve once so mount(8)
# output and the paths reported by the daemon stay comparable.
WS_ROOT="$(cd "$WS_ROOT" && pwd -P)"
WS_NAME="macos-workspace-cli"
WS_MNT="$WS_ROOT/$WS_NAME/mount"
SEED_DIR="$WS_ROOT/seed"

fail() {
echo "FAIL: $*"
exit 1
}

note() {
echo
echo "== $* =="
}

get_kv() {
local key="$1"
local text="${2:-}"
sed -n "s/^${key}=//p" <<<"$text"
}

expect_eq() {
local actual="$1"
local expected="$2"
local label="$3"
[[ "$actual" == "$expected" ]] || fail "$label: expected '$expected', got '$actual'"
}

expect_hex_hash() {
local value="$1"
local label="$2"
[[ "$value" =~ ^[0-9a-f]{64}$ ]] || fail "$label: expected 64-char hex hash, got '$value'"
}

expect_started_listing() {
local list_out="$1"
if ! grep -q "^$WS_NAME[[:space:]]\\+started[[:space:]]\\+$WS_MNT\$" <<<"$list_out"; then
fail "workspace list missing started session"
fi
}

expect_stopped_listing() {
local list_out="$1"
if ! grep -q "^$WS_NAME[[:space:]]\\+stopped[[:space:]]\\+$WS_MNT\$" <<<"$list_out"; then
fail "workspace list missing stopped session"
fi
}

expect_mount_present() {
if ! mount | grep -q " on $WS_MNT "; then
fail "workspace mount missing"
fi
}

expect_mount_absent() {
if mount | grep -q " on $WS_MNT "; then
fail "workspace mount still present after stop"
fi
}

cleanup() {
"$BIN" workspace stop "$WS_NAME" --root "$WS_ROOT" --no-checkpoint >/dev/null 2>&1 || true
umount -f "$WS_MNT" 2>/dev/null || diskutil unmount force "$WS_MNT" 2>/dev/null || true
rm -rf "$WS_ROOT"
}
trap cleanup EXIT

note "Initialize Workspace"
mkdir -p "$SEED_DIR"
echo "hello" > "$SEED_DIR/hello"
"$BIN" workspace init "$WS_NAME" --from "$SEED_DIR" --root "$WS_ROOT" >/dev/null

note "Start Workspace"
START_OUT="$("$BIN" workspace start "$WS_NAME" --root "$WS_ROOT")"
echo "$START_OUT"
WS_SOCK="$(get_kv socket "$START_OUT")"
WS_MNT_FROM_START="$(get_kv mount "$START_OUT")"
WS_STORE="$(get_kv store "$START_OUT")"
WS_STATUS="$(get_kv status "$START_OUT")"
WS_TELEMETRY="$(get_kv telemetry "$START_OUT")"

[[ -S "$WS_SOCK" ]] || fail "workspace socket missing: $WS_SOCK"
[[ -d "$WS_STORE" ]] || fail "workspace store missing: $WS_STORE"
expect_eq "$WS_MNT_FROM_START" "$WS_MNT" "workspace mount path mismatch"
expect_eq "$WS_STATUS" "started" "workspace status"
[[ "$WS_TELEMETRY" == "auto" || "$WS_TELEMETRY" == "none" ]] \
|| fail "unexpected workspace telemetry '$WS_TELEMETRY'"
expect_mount_present

note "Inspect Workspace"
STATUS_OUT="$("$BIN" workspace status "$WS_NAME" --root "$WS_ROOT")"
expect_eq "$(get_kv status "$STATUS_OUT")" "started" "workspace status command"
expect_eq "$(get_kv mount "$STATUS_OUT")" "$WS_MNT" "workspace status mount"

LIST_OUT="$("$BIN" workspace list --root "$WS_ROOT")"
expect_started_listing "$LIST_OUT"

note "Checkpoint And Rollback"
expect_eq "$(cat "$WS_MNT/hello")" "hello" "workspace bootstrap read"
echo "v1" > "$WS_MNT/data.txt"
FIRST_CP="$("$BIN" workspace checkpoint "$WS_NAME" baseline --root "$WS_ROOT")"
expect_hex_hash "$FIRST_CP" "workspace checkpoint"

echo "v2" > "$WS_MNT/data.txt"
ROLLBACK_OUT="$("$BIN" workspace rollback "$WS_NAME" baseline --root "$WS_ROOT")"
expect_hex_hash "$ROLLBACK_OUT" "workspace rollback response"
expect_eq "$(cat "$WS_MNT/data.txt")" "v1" "workspace rollback content"

note "Stop Workspace"
STOP_OUT="$("$BIN" workspace stop "$WS_NAME" --root "$WS_ROOT")"
echo "$STOP_OUT"
expect_eq "$(get_kv status "$STOP_OUT")" "stopped" "workspace stop response"
expect_mount_absent

LIST_AFTER_STOP="$("$BIN" workspace list --root "$WS_ROOT")"
expect_stopped_listing "$LIST_AFTER_STOP"

echo
echo "PASS $TEST_NAME"
Loading