diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 109d6ce..3bdd451 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/CMakeLists.txt b/CMakeLists.txt index dfcaa77..cda1dd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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}) diff --git a/README.md b/README.md index c85edde..896f36a 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,8 @@ agentvfs-ctl.exe --sock \\.\pipe\agentvfs- 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 @@ -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 diff --git a/docs/roadmap.md b/docs/roadmap.md index 808d3b5..1b01775 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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. diff --git a/src/cas/platform/macos/main_macos.cpp b/src/cas/platform/macos/main_macos.cpp index a97e96e..0258491 100644 --- a/src/cas/platform/macos/main_macos.cpp +++ b/src/cas/platform/macos/main_macos.cpp @@ -9,6 +9,7 @@ #include "daemon.h" #include "platform.h" #include "platform/macos/fuse_t_preflight.h" +#include "workspace_cli.h" #include #include @@ -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; } } @@ -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; diff --git a/src/cas/workspace_cli.cpp b/src/cas/workspace_cli.cpp index eded225..3aab1e4 100644 --- a/src/cas/workspace_cli.cpp +++ b/src/cas/workspace_cli.cpp @@ -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) { @@ -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) { @@ -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)); @@ -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"); @@ -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; @@ -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) { @@ -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); diff --git a/tests/system/macos/test_workspace_cli.sh b/tests/system/macos/test_workspace_cli.sh new file mode 100644 index 0000000..93508c7 --- /dev/null +++ b/tests/system/macos/test_workspace_cli.sh @@ -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"