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
41 changes: 39 additions & 2 deletions docs/loom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ The command surface is under `loom workspace`:
loom workspace open ./folder --session agent-1
loom workspace read ./folder --session agent-1 README.md
loom workspace write ./folder --session agent-1 README.md --text "changed in overlay"
loom workspace exec ./folder --session agent-1 -- rg "changed" README.md
loom workspace materialize-run ./folder --session agent-1 -- cargo test -p some-crate
loom workspace diff ./folder --session agent-1
loom workspace checkpoint ./folder --session agent-1 -m "agent checkpoint"
loom workspace close ./folder --session agent-1
Expand All @@ -91,8 +93,43 @@ The agent virtual adapter returns explicit unsupported-operation errors for `loo
dehydrate` and `loom workspace pin`. Those capabilities belong to later adapters that can safely
coordinate per-session views with shared cache retention or materialized folder policy.

### Workspace Execution

`loom workspace exec` runs a small deterministic virtual command set against the session view
without materializing source files into the shared folder. It is meant for agent inspect/edit loops
and just-bash-style adapters that can route simple commands through Loom first:

- `pwd`
- `ls [PATH...]`
- `cat <PATH> [PATH...]`
- `stat <PATH> [PATH...]`
- `rg <LITERAL> [PATH...]`
- `write <PATH> <TEXT...>`

Virtual command output is captured as stdout bytes, stderr bytes, and an exit status. Unsupported
commands return exit status `127` with a message telling the caller to use the materialized sandbox
fallback. The built-in `rg` is a literal line search, not full ripgrep compatibility; richer shell
parsing, globbing, pipes, environment management, process trees, and PTY behavior are outside this
PR.

`loom workspace materialize-run` is the fallback for commands that need a real process. Loom creates
an isolated working directory under the operating system temp directory, hydrates the session view
there, runs the requested command with that directory as the working directory, scans the resulting
filesystem, and captures changed or created regular files back into the session overlay. Capture
uses the same secret and generated-folder policy as `loom workspace write`; a secret, symlink,
unsupported file, path conflict, deleted tracked file, or mutation of the real shared folder refuses
capture and keeps the sandbox for inspection so dirty state is not silently lost. Successful runs
remove the sandbox by default, or keep it with `--keep-sandbox`.

Parallel sessions get separate overlays and separate materialized sandbox directories. A diff or
checkpoint after materialized capture is the same session operation as a virtual write: `loom
workspace diff` shows overlay changes, and `loom workspace checkpoint` coalesces those file versions
into a `sandbox-merge` folder revision plus checkpoint.

This is not OS filesystem mounting. The agent virtual adapter is a programmatic/CLI view over Loom
metadata and object bytes. It does not intercept normal file opens, provide Finder/Explorer/FUSE
integration, or make a shell see virtual files. Native Windows, macOS, and Linux filesystem adapters
are later arc work. This PR also does not add just-bash execution, SDKs, compression, or full
sparse/chunk transfer.
are later arc work. This PR also does not add SDKs, compression, chunk transfer, full sparse clone
transport, or a complete shell implementation. The materialized fallback is not an OS-level security
sandbox: it moves the working directory out of the shared folder and detects shared-folder mutations
before capture, but it should not be treated as a containment boundary for hostile commands.
260 changes: 256 additions & 4 deletions loom/crates/loom-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ use loom_sync::{
RemoteCheckReport, DEFAULT_REMOTE_NAME, LOCAL_FILESYSTEM_REMOTE_KIND,
};
use loom_workspace::{
AgentWorkspaceAdapter, WorkspaceEntryMetadata, WorkspaceEntrySource, WorkspaceOverlayDiff,
WorkspaceSessionRequest, WorkspaceView,
AgentWorkspaceAdapter, WorkspaceCommandMode, WorkspaceCommandOutput, WorkspaceCommandRequest,
WorkspaceEntryMetadata, WorkspaceEntrySource, WorkspaceMaterializedRunOptions,
WorkspaceOverlayDiff, WorkspaceSessionRequest, WorkspaceView,
};
use loom_worktree::{
cache_policy_presets, cache_status_for_scope, diff_revision_to_capture,
Expand Down Expand Up @@ -161,10 +162,10 @@ const COMMANDS: &[CommandSpec] = &[
},
CommandSpec {
name: "workspace",
usage: "loom workspace open [FOLDER] [--session <ID>] [--revision <REVISION|CHECKPOINT>]\n loom workspace sessions [FOLDER]\n loom workspace list [FOLDER] --session <ID> [PATH]\n loom workspace read [FOLDER] --session <ID> <PATH>\n loom workspace write [FOLDER] --session <ID> <PATH> --text <TEXT>\n loom workspace hydrate [FOLDER] --session <ID> <PATH>\n loom workspace dehydrate [FOLDER] --session <ID> <PATH>\n loom workspace pin [FOLDER] --session <ID> <PATH>\n loom workspace diff [FOLDER] --session <ID>\n loom workspace checkpoint [FOLDER] --session <ID> -m <MESSAGE>\n loom workspace close [FOLDER] --session <ID>\n loom workspace discard [FOLDER] --session <ID>",
usage: "loom workspace open [FOLDER] [--session <ID>] [--revision <REVISION|CHECKPOINT>]\n loom workspace sessions [FOLDER]\n loom workspace list [FOLDER] --session <ID> [PATH]\n loom workspace read [FOLDER] --session <ID> <PATH>\n loom workspace write [FOLDER] --session <ID> <PATH> --text <TEXT>\n loom workspace exec [FOLDER] --session <ID> [--cwd <PATH>] -- <COMMAND> [ARGS...]\n loom workspace materialize-run [FOLDER] --session <ID> [--cwd <PATH>] [--keep-sandbox] -- <COMMAND> [ARGS...]\n loom workspace hydrate [FOLDER] --session <ID> <PATH>\n loom workspace dehydrate [FOLDER] --session <ID> <PATH>\n loom workspace pin [FOLDER] --session <ID> <PATH>\n loom workspace diff [FOLDER] --session <ID>\n loom workspace checkpoint [FOLDER] --session <ID> -m <MESSAGE>\n loom workspace close [FOLDER] --session <ID>\n loom workspace discard [FOLDER] --session <ID>",
summary: "Use virtual workspace sessions over Loom revisions",
implemented: true,
planned_behavior: "open agent virtual sessions, read lazily, write isolated overlays, diff, checkpoint, and discard without OS filesystem mounting",
planned_behavior: "open agent virtual sessions, run deterministic virtual commands, fall back to isolated materialized sandboxes, diff, checkpoint, and discard without OS filesystem mounting",
},
];

Expand Down Expand Up @@ -1073,6 +1074,10 @@ fn run_workspace(args: &[String]) -> Result<(), String> {
Some((subcommand, rest)) if subcommand == "list" => run_workspace_list(rest),
Some((subcommand, rest)) if subcommand == "read" => run_workspace_read(rest),
Some((subcommand, rest)) if subcommand == "write" => run_workspace_write(rest),
Some((subcommand, rest)) if subcommand == "exec" => run_workspace_exec(rest),
Some((subcommand, rest)) if subcommand == "materialize-run" => {
run_workspace_materialize_run(rest)
}
Some((subcommand, rest)) if subcommand == "hydrate" => run_workspace_hydrate(rest),
Some((subcommand, rest)) if subcommand == "dehydrate" => run_workspace_dehydrate(rest),
Some((subcommand, rest)) if subcommand == "pin" => run_workspace_pin(rest),
Expand Down Expand Up @@ -1244,6 +1249,79 @@ fn run_workspace_write(args: &[String]) -> Result<(), String> {
Ok(())
}

fn run_workspace_exec(args: &[String]) -> Result<(), String> {
let parsed = parse_workspace_exec_args("exec", args, false)?;
let store = open_store_from_optional_folder(parsed.folder)?;
let remote = optional_workspace_remote(&store)?;
let request = WorkspaceCommandRequest::new(parsed.command).with_cwd(parsed.cwd);
let output = if let Some(remote) = remote.as_deref() {
let adapter = AgentWorkspaceAdapter::with_remote(store, remote);
let mut session = adapter
.open_session(&parsed.session_id)
.map_err(|error| error.to_string())?;
session
.execute_virtual_command(request)
.map_err(|error| error.to_string())?
} else {
let adapter = AgentWorkspaceAdapter::new(store);
let mut session = adapter
.open_session(&parsed.session_id)
.map_err(|error| error.to_string())?;
session
.execute_virtual_command(request)
.map_err(|error| error.to_string())?
};

print_workspace_command_output(&parsed.session_id, &output)?;
if output.success() {
Ok(())
} else {
Err(format!(
"workspace exec exited with status {}",
output.exit_code()
))
}
}

fn run_workspace_materialize_run(args: &[String]) -> Result<(), String> {
let parsed = parse_workspace_exec_args("materialize-run", args, true)?;
let store = open_store_from_optional_folder(parsed.folder)?;
let remote = optional_workspace_remote(&store)?;
let request = WorkspaceCommandRequest::new(parsed.command).with_cwd(parsed.cwd);
let options = if parsed.keep_sandbox {
WorkspaceMaterializedRunOptions::keep_sandbox()
} else {
WorkspaceMaterializedRunOptions::cleanup()
};
let output = if let Some(remote) = remote.as_deref() {
let adapter = AgentWorkspaceAdapter::with_remote(store, remote);
let mut session = adapter
.open_session(&parsed.session_id)
.map_err(|error| error.to_string())?;
session
.run_materialized_command(request, options)
.map_err(|error| error.to_string())?
} else {
let adapter = AgentWorkspaceAdapter::new(store);
let mut session = adapter
.open_session(&parsed.session_id)
.map_err(|error| error.to_string())?;
session
.run_materialized_command(request, options)
.map_err(|error| error.to_string())?
};

print_workspace_command_output(&parsed.session_id, &output)?;
if output.success() {
Ok(())
} else {
Err(format!(
"workspace materialized command exited with status {}",
output.exit_code()
))
}
}

fn run_workspace_hydrate(args: &[String]) -> Result<(), String> {
let parsed = parse_workspace_path_args("hydrate", args, true)?;
let store = open_store_from_optional_folder(parsed.folder)?;
Expand Down Expand Up @@ -1603,6 +1681,92 @@ fn print_workspace_diff(diff: &WorkspaceOverlayDiff) {
print_paths("Deleted", diff.deleted());
}

fn print_workspace_command_output(
session_id: &WorkspaceSessionId,
output: &WorkspaceCommandOutput,
) -> Result<(), String> {
println!("Workspace session: {session_id}");
println!("Mode: {}", workspace_command_mode_label(output.mode()));
println!("Command: {}", shell_join(output.command()));
println!(
"Cwd: {}",
if output.cwd().as_os_str().is_empty() {
".".to_string()
} else {
path_to_store_string(output.cwd())
}
);
println!("Exit status: {}", output.exit_code());
if let Some(report) = output.materialized_report() {
println!("Sandbox: {}", report.sandbox_path().display());
println!(
"Materialized: {} files, {} directories",
report.materialized_files(),
report.materialized_directories()
);
println!(
"Captured: {} changed, {} unchanged, {} ignored",
report.captured_files(),
report.unchanged_files(),
report.ignored_entries()
);
println!(
"Sandbox cleanup: {}",
if report.cleaned_up() {
"removed"
} else {
"kept"
}
);
}
println!("Stdout bytes: {}", output.stdout().len());
if !output.stdout().is_empty() {
println!("--- stdout");
print_lossy_block(output.stdout())?;
}
println!("Stderr bytes: {}", output.stderr().len());
if !output.stderr().is_empty() {
println!("--- stderr");
print_lossy_block(output.stderr())?;
}
Ok(())
}

fn print_lossy_block(bytes: &[u8]) -> Result<(), String> {
let text = String::from_utf8_lossy(bytes);
print!("{text}");
if !text.ends_with('\n') {
println!();
}
std::io::stdout()
.flush()
.map_err(|error| format!("could not flush command output: {error}"))
}

fn workspace_command_mode_label(mode: WorkspaceCommandMode) -> &'static str {
match mode {
WorkspaceCommandMode::Virtual => "virtual",
WorkspaceCommandMode::MaterializedSandbox => "materialized-sandbox",
}
}

fn shell_join(command: &[String]) -> String {
command
.iter()
.map(|part| {
if part
.chars()
.all(|character| character.is_ascii_alphanumeric() || "-_./:=+".contains(character))
{
part.clone()
} else {
format!("'{}'", part.replace('\'', "'\\''"))
}
})
.collect::<Vec<_>>()
.join(" ")
}

fn file_kind_label(kind: &FileKind) -> &'static str {
match kind {
FileKind::File => "file",
Expand Down Expand Up @@ -2153,6 +2317,15 @@ struct WorkspaceWriteArgs {
text: String,
}

#[derive(Debug, Clone)]
struct WorkspaceExecArgs {
folder: Option<PathBuf>,
session_id: WorkspaceSessionId,
cwd: PathBuf,
command: Vec<String>,
keep_sandbox: bool,
}

#[derive(Debug, Clone)]
struct WorkspaceCheckpointArgs {
folder: Option<PathBuf>,
Expand Down Expand Up @@ -2602,6 +2775,85 @@ fn parse_workspace_write_args(args: &[String]) -> Result<WorkspaceWriteArgs, Str
})
}

fn parse_workspace_exec_args(
command_name: &str,
args: &[String],
allow_keep_sandbox: bool,
) -> Result<WorkspaceExecArgs, String> {
let mut session_id = None;
let mut cwd = PathBuf::new();
let mut keep_sandbox = false;
let mut positional = Vec::new();
let mut index = 0;
let mut command = None;

while index < args.len() {
let arg = &args[index];
if arg == "--" {
command = Some(args[index + 1..].to_vec());
break;
} else if arg == "--session" {
index += 1;
let value = args
.get(index)
.ok_or_else(|| "--session requires a value".to_string())?;
session_id =
Some(WorkspaceSessionId::new(value.clone()).map_err(|error| error.to_string())?);
} else if let Some(value) = arg.strip_prefix("--session=") {
session_id = Some(
WorkspaceSessionId::new(value.to_string()).map_err(|error| error.to_string())?,
);
} else if arg == "--cwd" {
index += 1;
cwd = PathBuf::from(
args.get(index)
.ok_or_else(|| "--cwd requires a value".to_string())?,
);
} else if let Some(value) = arg.strip_prefix("--cwd=") {
cwd = PathBuf::from(value);
} else if arg == "--keep-sandbox" && allow_keep_sandbox {
keep_sandbox = true;
} else if arg == "--keep-sandbox" {
return Err("workspace exec does not accept --keep-sandbox".to_string());
} else if arg.starts_with('-') {
return Err(format!("workspace {command_name} unknown option '{arg}'"));
} else {
positional.push(arg.clone());
}
index += 1;
}

let command = command.ok_or_else(|| {
format!(
"workspace {command_name} requires -- before the command\nUsage: loom workspace {command_name} [FOLDER] --session <ID> [--cwd <PATH>] -- <COMMAND> [ARGS...]"
)
})?;
if command.is_empty() {
return Err(format!(
"workspace {command_name} requires a command after --"
));
}
let folder = match positional.as_slice() {
[] => None,
[folder] => Some(PathBuf::from(folder)),
_ => {
return Err(format!(
"workspace {command_name} accepts at most one folder before --"
));
}
};
let session_id =
session_id.ok_or_else(|| format!("workspace {command_name} requires --session <ID>"))?;

Ok(WorkspaceExecArgs {
folder,
session_id,
cwd,
command,
keep_sandbox,
})
}

fn parse_workspace_checkpoint_args(args: &[String]) -> Result<WorkspaceCheckpointArgs, String> {
let mut session_id = None;
let mut message = None;
Expand Down
Loading
Loading