From 25bdf01437904a850da090ba6f2ad1040a90fb8a Mon Sep 17 00:00:00 2001 From: Snowy Date: Tue, 23 Jun 2026 02:06:50 +0300 Subject: [PATCH 1/2] Workspace adapter alpha evidence and docs --- README.md | 2 + docs/README.md | 6 +- docs/devbox/README.md | 1 + docs/devbox/workspace-adapters-alpha.md | 148 ++++++++ docs/evidence/alpha-readiness.md | 26 +- loom/crates/loom-cli/src/main.rs | 13 +- loom/crates/loom-cli/tests/cli.rs | 10 + scripts/alpha-workspace-adapters-smoke.ps1 | 404 +++++++++++++++++++++ tests/devbox-e2e/README.md | 6 + 9 files changed, 607 insertions(+), 9 deletions(-) create mode 100644 docs/devbox/workspace-adapters-alpha.md create mode 100644 scripts/alpha-workspace-adapters-smoke.ps1 diff --git a/README.md b/README.md index aba51f7..a58a356 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ This repository currently contains the product foundation and alpha planning art - [.plans](.plans/README.md) - MVP execution plan with static HTML pages for phases, architecture, and validation. - [docs/alpha-cli-distribution.md](docs/alpha-cli-distribution.md) - GitHub Release packaging, server-owned storage setup, and two-device alpha smoke testing. - [docs/devbox/loom-and-devbox.md](docs/devbox/loom-and-devbox.md) - the product/engine split and the vocabulary to use in new work. +- [docs/devbox/workspace-adapters-alpha.md](docs/devbox/workspace-adapters-alpha.md) - current sparse folder, agent workspace, materialized fallback, and filesystem adapter alpha state. - [docs/architecture/loom-storage-consistency.md](docs/architecture/loom-storage-consistency.md) - current storage consistency guarantees, non-guarantees, and evidence path. - [docs/evidence/alpha-readiness.md](docs/evidence/alpha-readiness.md) - concise alpha evidence and the canonical smoke commands. @@ -243,6 +244,7 @@ admin/debug smoke tests, but they are not the product path. Alpha helper scripts: +- `scripts/alpha-workspace-adapters-smoke.ps1` runs the workspace adapter alpha proof for sparse folders, agent virtual sessions, materialized sandbox fallback, and filesystem adapter truthfulness. - `scripts/alpha-two-device-smoke.sh` runs a local two-device proof with pairing, pending receiver refusal, live publish, latest pull, materialization, and redacted evidence logs. - `scripts/devbox-live-sync-alpha.sh` maps `.env` values into a live daemon command for local, hosted object-transfer, or trusted direct-S3 remotes. - `scripts/package-cli.sh ` builds macOS/Linux alpha tool archives with Loom, Devbox CLI, daemon, metadata server, docs, separate user/operator env templates, and helper scripts. diff --git a/docs/README.md b/docs/README.md index e2e4675..d282ed2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,8 +18,10 @@ a compatibility surface because developers use it today, but Git is not the foun trying to build. Start with [Loom And Devbox](devbox/loom-and-devbox.md) before reading older architecture slices. -For current alpha proof, read [Alpha Readiness Evidence](evidence/alpha-readiness.md) and run -`scripts/mvp-two-device-smoke`. +For current alpha proof, read [Alpha Readiness Evidence](evidence/alpha-readiness.md) and +[Workspace Adapter Alpha](devbox/workspace-adapters-alpha.md). Run `scripts/mvp-two-device-smoke` +for the product MVP path and `scripts/alpha-workspace-adapters-smoke.ps1` for sparse folder, +agent workspace, materialized fallback, and filesystem adapter evidence. For the intended full-scale repository and language split, read [Full-Scale Project Shape](architecture/full-scale-project-shape.md). diff --git a/docs/devbox/README.md b/docs/devbox/README.md index 9be2133..db4f56f 100644 --- a/docs/devbox/README.md +++ b/docs/devbox/README.md @@ -8,3 +8,4 @@ this folder for user-facing product, platform, hosted API, account, device, and Current CLI model docs: - [Sparse Folders](sparse-folders.md) +- [Workspace Adapter Alpha](workspace-adapters-alpha.md) diff --git a/docs/devbox/workspace-adapters-alpha.md b/docs/devbox/workspace-adapters-alpha.md new file mode 100644 index 0000000..d4de8af --- /dev/null +++ b/docs/devbox/workspace-adapters-alpha.md @@ -0,0 +1,148 @@ +# Workspace Adapter Alpha + +This page is the current-state map for the Devbox/Loom workspace adapter arc. Devbox is the product +for folder continuity across machines. Loom is the engine boundary for objects, file versions, folder +revisions, cache entries, pins, checkpoints, cursors, overlays, and sandboxes. + +The alpha adapter work proves explicit sparse folder flows, agent virtual sessions, materialized +sandbox fallback, and filesystem adapter boundaries. It does not claim native OS placeholder files +or hydrate-on-open behavior. + +## Evidence Command + +Run the adapter smoke locally: + +```text +powershell -ExecutionPolicy Bypass -File scripts/alpha-workspace-adapters-smoke.ps1 +``` + +The script builds local binaries if needed, starts a temporary in-memory `devbox-api`, uses only +temporary folders, and writes redacted logs under the printed evidence directory. + +It proves: + +- human sparse folder flow through `devbox login`, `devbox share`, `devbox clone --sparse`, + `devbox status`, `devbox hydrate`, `devbox warm`, `devbox keep`, and `devbox free-space` +- agent virtual workspace flow through `loom workspace open/read/write/exec/diff/checkpoint/discard` +- materialized sandbox fallback for real commands, including safe capture and unsafe host mutation + refusal +- filesystem adapter alpha behavior, including native fail-closed reporting and `local-dev` + metadata-only mount/status/unmount simulation + +## Sparse Folder Quickstart + +Use `devbox` for the human product flow: + +```text +devbox login --api --account --device-name "Desktop" +devbox share --no-background-sync + +DEVBOX_CONFIG_DIR=.devbox-laptop \ + devbox login --api --account --device-name "Laptop" +DEVBOX_CONFIG_DIR=.devbox-laptop \ + devbox clone --sparse --no-background-sync + +DEVBOX_CONFIG_DIR=.devbox-laptop devbox status +DEVBOX_CONFIG_DIR=.devbox-laptop devbox hydrate /README.md +DEVBOX_CONFIG_DIR=.devbox-laptop devbox warm --max-bytes 4096 +DEVBOX_CONFIG_DIR=.devbox-laptop devbox keep /README.md +DEVBOX_CONFIG_DIR=.devbox-laptop devbox free-space --max-bytes 0 +``` + +`hydrate` downloads and materializes a requested path now. `warm` chooses useful small source, +manifest, and config files. `keep` records offline retention intent; it does not download missing +bytes by itself. `free-space` only removes clean, unpinned local bytes when Devbox can prove a +hosted copy exists. + +Sparse folders are explicit CLI workflows today. Cloud-only files do not appear as placeholder files +in Explorer, Finder, or shells. + +## Agent Workspace Workflow + +Agents use Loom workspace sessions over a folder revision: + +```text +loom track +loom workspace open --session agent-1 +loom workspace list --session agent-1 +loom workspace read --session agent-1 README.md +loom workspace exec --session agent-1 -- cat README.md +loom workspace write --session agent-1 src/change.txt --text "agent edit" +loom workspace diff --session agent-1 +loom workspace checkpoint --session agent-1 -m "agent checkpoint" +``` + +The virtual adapter reads base revision metadata and overlay files without exposing the full source +folder as a mutable host worktree. Writes land in the session overlay until checkpoint. Checkpoint +coalesces overlay file versions into a folder revision and creates a human-readable checkpoint. A +session that should be abandoned uses: + +```text +loom workspace discard --session agent-1 +``` + +## Virtual Versus Materialized Execution + +`loom workspace exec` is a virtual command surface. It supports deterministic commands such as +`pwd`, `ls`, `cat`, `stat`, `rg`, and `write`. Unsupported commands return a clear failure and tell +the caller to use materialized sandbox fallback. + +Use materialized fallback when a real shell command or tool needs a filesystem: + +```text +loom workspace materialize-run --session agent-1 -- [ARGS...] +``` + +Materialized fallback creates an isolated sandbox from the session view, runs the command there, and +captures safe changes back into the overlay. It refuses deleted tracked files, secret-looking files, +generated/dependency paths, and mutations to the real shared folder outside the sandbox capture. + +This fallback is for commands that need real files; it is not permission to mutate the host folder +directly. + +## Filesystem Adapter Alpha + +The filesystem adapter boundary is exposed through: + +```text +loom fs status +loom fs mount --mount +loom fs mount --adapter local-dev --mount +loom fs status --adapter local-dev --mount +loom fs unmount --adapter local-dev --mount +``` + +Native adapters for Windows, macOS, and Linux are alpha stubs. They report host direction and fail +closed for mount. They do not record successful native mount metadata, do not create placeholder +files, and do not support hydrate-on-open. + +`--adapter local-dev` is a deterministic metadata simulation for tests and wiring. It records mount +state under `.loom/fs`, reports status, and supports idempotent unmount. It does not create the +mount path, project files into the OS, or hydrate bytes on open. + +## Safety Guarantees + +The current alpha keeps these guarantees: + +- Loom captures file versions frequently and coalesces folder revisions at stable boundaries such as + commands, sync, restore, sandbox merge, and checkpoint. +- Cache metadata records object byte availability separately from file versions and folder + revisions. +- Sparse cleanup keeps dirty files, pinned files, unsupported local entries, and files without + hosted proof. +- Agent overlay writes and materialized captures re-check secret/generated/dependency policy before + object bytes enter the cache. +- Materialized fallback refuses host shared-folder mutation outside the sandbox. +- Native filesystem adapters fail closed until real OS integrations exist. + +## Non-Goals + +These are not implemented in the current alpha: + +- native Windows Cloud Files, Projected File System, macOS File Provider, macFUSE, or Linux FUSE + drivers +- placeholder files in normal OS file browsers or shells +- hydrate-on-open, sparse reads, or kernel callback hydration +- remote protocol v2, chunk transfer, compression, or lazy byte-range transport +- broad automatic conflict resolution +- weakening secret, generated dependency, or unsupported filesystem policies diff --git a/docs/evidence/alpha-readiness.md b/docs/evidence/alpha-readiness.md index f429faa..22d2850 100644 --- a/docs/evidence/alpha-readiness.md +++ b/docs/evidence/alpha-readiness.md @@ -5,7 +5,7 @@ polish PR. ## Canonical Proof Path -Run: +For the product MVP path, run: ```text scripts/mvp-two-device-smoke @@ -21,6 +21,17 @@ The smoke builds local binaries when needed, starts a temporary `devbox-api` wit metadata, simulates two machines, and writes redacted logs under the printed evidence directory. It does not require live R2 or Postgres. +For the workspace adapter alpha path, run: + +```text +powershell -ExecutionPolicy Bypass -File scripts/alpha-workspace-adapters-smoke.ps1 +``` + +That smoke uses a temporary in-memory `devbox-api` plus local Loom folders to prove sparse folder +intents, agent virtual sessions, materialized sandbox fallback, and filesystem adapter alpha +truthfulness. It is local-dev evidence only; it does not claim native hydrate-on-open or OS +placeholder support. + ## What The Smoke Proves - Loom can track, checkpoint, sync, clone, sparse clone, hydrate, evict, pin, cache status, and @@ -36,6 +47,19 @@ It does not require live R2 or Postgres. object cache, and evidence logs. - Divergent cursor/conflict states refuse safely instead of auto-merging. +## Workspace Adapter Alpha Proofs + +- Devbox sparse clone starts metadata-only, then `status`, `hydrate`, `warm`, `keep`, and + `free-space` expose cache intent without treating cloud-only files as deletions. +- `free-space` succeeds only for clean, unpinned local bytes with hosted proof and refuses when that + proof is missing. +- Loom agent workspace sessions can virtual-read, virtual-exec, write overlays, diff, checkpoint, + and discard without materializing the whole folder. +- Materialized fallback runs a real command in an isolated sandbox, captures safe source changes, + and refuses host shared-folder mutation outside capture. +- Native filesystem adapters report unsupported mount status truthfully and record no success, while + `--adapter local-dev` records only metadata-only simulated mount/status/unmount state. + ## Focused Checks Useful targeted checks for this PR: diff --git a/loom/crates/loom-cli/src/main.rs b/loom/crates/loom-cli/src/main.rs index 298d48f..804d045 100644 --- a/loom/crates/loom-cli/src/main.rs +++ b/loom/crates/loom-cli/src/main.rs @@ -935,7 +935,7 @@ fn run_cache_status(folder: Option) -> Result<(), String> { } fn run_cache_free_space(args: &[String], command: &str) -> Result<(), String> { - let parsed = parse_cache_prune_args(args)?; + let parsed = parse_cache_prune_args(args, command)?; let store = open_store_from_optional_folder(parsed.folder)?; let versions = tracked_versions_for_scope(&store, Path::new("")).map_err(|error| error.to_string())?; @@ -2725,7 +2725,7 @@ fn parse_sync_daemon_args(args: &[String], command: &str) -> Result Result { +fn parse_cache_prune_args(args: &[String], command: &str) -> Result { let mut folder = None; let mut max_bytes = None; let mut index = 0; @@ -2742,16 +2742,17 @@ fn parse_cache_prune_args(args: &[String]) -> Result { } else if let Some(value) = arg.strip_prefix("--max-bytes=") { max_bytes = Some(parse_u64_flag("--max-bytes", value)?); } else if arg.starts_with('-') { - return Err(format!("cache prune unknown option '{arg}'")); + return Err(format!("{command} unknown option '{arg}'")); } else if folder.replace(PathBuf::from(arg)).is_some() { - return Err("cache prune accepts at most one folder".to_string()); + return Err(format!("{command} accepts at most one folder")); } index += 1; } let max_bytes = max_bytes.ok_or_else(|| { - "cache prune requires --max-bytes \nUsage: loom cache prune --max-bytes [FOLDER]" - .to_string() + format!( + "{command} requires --max-bytes \nUsage: loom {command} --max-bytes [FOLDER]" + ) })?; Ok(CachePruneArgs { folder, max_bytes }) diff --git a/loom/crates/loom-cli/tests/cli.rs b/loom/crates/loom-cli/tests/cli.rs index 815c405..833df10 100644 --- a/loom/crates/loom-cli/tests/cli.rs +++ b/loom/crates/loom-cli/tests/cli.rs @@ -221,6 +221,16 @@ fn command_help_prints_usage() { assert!(stdout.contains("Status: implemented for the local offline engine")); } +#[test] +fn cache_free_space_errors_use_free_space_wording() { + let output = run_loom(["cache", "free-space", "--bad"]); + + assert!(!output.status.success()); + let stderr = stderr(&output); + assert!(stderr.contains("cache free-space unknown option '--bad'")); + assert!(!stderr.contains("cache prune unknown option")); +} + #[test] fn workspace_help_lists_agent_session_commands() { let output = run_loom(["workspace", "--help"]); diff --git a/scripts/alpha-workspace-adapters-smoke.ps1 b/scripts/alpha-workspace-adapters-smoke.ps1 new file mode 100644 index 0000000..f552545 --- /dev/null +++ b/scripts/alpha-workspace-adapters-smoke.ps1 @@ -0,0 +1,404 @@ +param( + [switch]$Help +) + +$ErrorActionPreference = "Stop" + +if ($Help) { + @" +Usage: powershell -ExecutionPolicy Bypass -File scripts/alpha-workspace-adapters-smoke.ps1 + +Runs deterministic local alpha evidence for the workspace adapter arc: + - human sparse folder flow through devbox login/share/clone --sparse/status/warm/hydrate/keep/free-space + - agent virtual workspace flow through loom workspace open/read/write/exec/diff/checkpoint/discard + - materialized sandbox fallback flow with safe capture and unsafe host mutation refusal + - filesystem adapter alpha flow with native fail-closed status and local-dev metadata simulation + +Environment: + LOOM_BIN Optional path to a built loom binary. + DEVBOX_BIN Optional path to a built devbox binary. + DEVBOX_API_BIN Optional path to a built devbox-api binary. + DEVBOX_ADAPTER_SMOKE_DIR Optional working directory to reuse. + DEVBOX_CLEAN_SMOKE_DIR Set true to remove the generated temp directory after a pass. +"@ + exit 0 +} + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path + +if ($env:DEVBOX_ADAPTER_SMOKE_DIR) { + $WorkDir = $env:DEVBOX_ADAPTER_SMOKE_DIR + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + $Cleanup = $false +} else { + $WorkDir = Join-Path ([System.IO.Path]::GetTempPath()) ("devbox-adapter-smoke." + [System.Guid]::NewGuid().ToString("N").Substring(0, 8)) + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + $Cleanup = $false +} + +if ($env:DEVBOX_CLEAN_SMOKE_DIR -eq "true") { + $Cleanup = $true +} + +$EvidenceDir = Join-Path $WorkDir "evidence" +New-Item -ItemType Directory -Force -Path $EvidenceDir | Out-Null +$ApiProcess = $null + +function Fail($Message) { + Write-Error "[FAIL] $Message" + Write-Host "evidence=$EvidenceDir" + throw $Message +} + +function Redact-Text($Text) { + $Text = $Text -replace "devbox-local-session-[A-Za-z0-9]+", "devbox-local-session-" + $Text = $Text -replace "devbox://\S+", "devbox://" + $Text = $Text -replace "sk-[A-Za-z0-9_.-]+", "sk-" + $Text = $Text -replace "github_pat_[A-Za-z0-9_]+", "github_pat_" + $Text = $Text -replace "ghp_[A-Za-z0-9_]+", "ghp_" + $Text +} + +function Redact-File($RawPath, $OutputPath) { + if (Test-Path $RawPath) { + $text = Get-Content -Raw -Path $RawPath + Set-Content -Path $OutputPath -Value (Redact-Text $text) + Remove-Item -Force -Path $RawPath + } else { + Set-Content -Path $OutputPath -Value "" + } +} + +function Redact-FileCopy($RawPath, $OutputPath) { + if (Test-Path $RawPath) { + $text = Get-Content -Raw -Path $RawPath + Set-Content -Path $OutputPath -Value (Redact-Text $text) + } else { + Set-Content -Path $OutputPath -Value "" + } +} + +function Invoke-Logged { + param( + [string]$Name, + [string]$Exe, + [string[]]$CommandArgs, + [hashtable]$Env = @{}, + [switch]$ExpectFailure + ) + + $stdout = Join-Path $EvidenceDir "$Name.stdout.log" + $stderr = Join-Path $EvidenceDir "$Name.stderr.log" + $stdoutRaw = "$stdout.raw" + $stderrRaw = "$stderr.raw" + $oldEnv = @{} + + Write-Host ("[RUN] {0}{1}" -f $Name, $(if ($ExpectFailure) { " (expecting refusal)" } else { "" })) + + foreach ($key in $Env.Keys) { + $oldEnv[$key] = [Environment]::GetEnvironmentVariable($key, "Process") + [Environment]::SetEnvironmentVariable($key, [string]$Env[$key], "Process") + } + + try { + $oldErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + & $Exe @CommandArgs 1> $stdoutRaw 2> $stderrRaw + $code = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE } + } finally { + $ErrorActionPreference = $oldErrorActionPreference + foreach ($key in $Env.Keys) { + [Environment]::SetEnvironmentVariable($key, $oldEnv[$key], "Process") + } + } + + Redact-File $stdoutRaw $stdout + Redact-File $stderrRaw $stderr + + if ($ExpectFailure) { + if ($code -eq 0) { + Fail "$Name unexpectedly succeeded" + } + Write-Host "[PASS] $Name refused safely" + return + } + + if ($code -ne 0) { + Write-Host "[FAIL] $Name exited $code" + Get-Content -Tail 40 -Path $stderr | ForEach-Object { Write-Host $_ } + Fail "$Name failed" + } + + Write-Host "[PASS] $Name" +} + +function Expect-Contains($Path, $Needle) { + $text = Get-Content -Raw -Path $Path + if (-not $text.Contains($Needle)) { + Fail "$Path did not contain '$Needle'" + } +} + +function Expect-FileText($Path, $Expected) { + if (-not (Test-Path $Path)) { + Fail "missing file $Path" + } + $actual = (Get-Content -Raw -Path $Path).TrimEnd("`r", "`n") + if ($actual -ne $Expected) { + Fail "unexpected contents in $Path" + } +} + +function Expect-Absent($Path) { + if (Test-Path $Path) { + Fail "unexpected path exists: $Path" + } +} + +function Get-BinaryPath($EnvValue, $Package, $Binary) { + if ($EnvValue) { + return $EnvValue + } + + $exeName = if ($env:OS -eq "Windows_NT") { "$Binary.exe" } else { $Binary } + $candidate = Join-Path $RepoRoot "target\debug\$exeName" + if (-not (Test-Path $candidate)) { + Push-Location $RepoRoot + try { + & cargo build --quiet -p $Package + if ($LASTEXITCODE -ne 0) { + Fail "cargo build failed for $Package" + } + } finally { + Pop-Location + } + } + $candidate +} + +function New-TextFile($Path, $Content) { + $parent = Split-Path -Parent $Path + if ($parent) { + New-Item -ItemType Directory -Force -Path $parent | Out-Null + } + $utf8NoBom = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::WriteAllText($Path, $Content, $utf8NoBom) +} + +function Remove-SmokePath($Path) { + if (-not (Test-Path $Path)) { + return + } + $resolved = (Resolve-Path -LiteralPath $Path).Path + $root = (Resolve-Path -LiteralPath $WorkDir).Path + if (-not $resolved.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { + Fail "refusing to remove path outside smoke workdir: $resolved" + } + Remove-Item -Recurse -Force -LiteralPath $Path +} + +try { + $LoomBin = Get-BinaryPath $env:LOOM_BIN "loom-cli" "loom" + $DevboxBin = Get-BinaryPath $env:DEVBOX_BIN "devbox-cli" "devbox" + $DevboxApiBin = Get-BinaryPath $env:DEVBOX_API_BIN "devbox-api" "devbox-api" + + Write-Host "Devbox/Loom workspace adapter alpha smoke" + Write-Host "workdir=$WorkDir" + Write-Host "evidence=$EvidenceDir" + + $ApiRoot = Join-Path $WorkDir "api-root" + $ApiStdoutRaw = Join-Path $EvidenceDir "00-devbox-api.stdout.log.raw" + $ApiStderrRaw = Join-Path $EvidenceDir "00-devbox-api.stderr.log.raw" + $OldApiMetadataMode = [Environment]::GetEnvironmentVariable("DEVBOX_API_METADATA_MODE", "Process") + [Environment]::SetEnvironmentVariable("DEVBOX_API_METADATA_MODE", "memory", "Process") + try { + $startArgs = @{ + FilePath = $DevboxApiBin + ArgumentList = @("--root", $ApiRoot, "--bind", "127.0.0.1:0") + RedirectStandardOutput = $ApiStdoutRaw + RedirectStandardError = $ApiStderrRaw + PassThru = $true + } + if ($env:OS -eq "Windows_NT") { + $startArgs.WindowStyle = "Hidden" + } + $ApiProcess = Start-Process @startArgs + } finally { + [Environment]::SetEnvironmentVariable("DEVBOX_API_METADATA_MODE", $OldApiMetadataMode, "Process") + } + $ApiUrl = "" + for ($i = 0; $i -lt 100; $i++) { + if ($ApiProcess.HasExited) { + Redact-FileCopy $ApiStdoutRaw (Join-Path $EvidenceDir "00-devbox-api.stdout.log") + Redact-FileCopy $ApiStderrRaw (Join-Path $EvidenceDir "00-devbox-api.stderr.log") + Fail "devbox-api exited before it was ready" + } + if (Test-Path $ApiStdoutRaw) { + $apiLog = Get-Content -Raw -Path $ApiStdoutRaw + if ($null -eq $apiLog) { + $apiLog = "" + } + $match = [regex]::Match($apiLog, "http://127\.0\.0\.1:[0-9]+") + if ($match.Success) { + $ApiUrl = $match.Value + break + } + } + Start-Sleep -Milliseconds 50 + } + Redact-FileCopy $ApiStdoutRaw (Join-Path $EvidenceDir "00-devbox-api.stdout.log") + Redact-FileCopy $ApiStderrRaw (Join-Path $EvidenceDir "00-devbox-api.stderr.log") + if (-not $ApiUrl) { + Fail "could not parse devbox-api URL" + } + Write-Host "[PASS] 00-devbox-api-start" + + $SourceConfig = Join-Path $WorkDir "source-config" + $TargetConfig = Join-Path $WorkDir "target-config" + $ProductSource = Join-Path $WorkDir "human-source" + $ProductSparseTarget = Join-Path $WorkDir "human-sparse-target" + New-TextFile (Join-Path $ProductSource "README.md") "hello" + New-TextFile (Join-Path $ProductSource "Cargo.toml") "[package]`nname='a'" + New-TextFile (Join-Path $ProductSource "src\main.rs") "fn main() {}" + New-TextFile (Join-Path $ProductSource "config\app.toml") "debug=1" + New-TextFile (Join-Path $ProductSource "docs\guide.md") "guide" + New-TextFile (Join-Path $ProductSource "big.bin") ("x" * 128) + + Invoke-Logged -Name "01-human-source-login" -Exe $DevboxBin -CommandArgs @("login", "--api", $ApiUrl, "--account", "adapter-alpha", "--device-name", "Adapter desktop") -Env @{ DEVBOX_CONFIG_DIR = $SourceConfig } + Invoke-Logged -Name "02-human-share" -Exe $DevboxBin -CommandArgs @("share", $ProductSource, "--no-background-sync") -Env @{ DEVBOX_CONFIG_DIR = $SourceConfig } + Expect-Contains (Join-Path $EvidenceDir "02-human-share.stdout.log") "Shared folder: human-source" + Invoke-Logged -Name "03-human-target-login" -Exe $DevboxBin -CommandArgs @("login", "--api", $ApiUrl, "--account", "adapter-alpha", "--device-name", "Adapter laptop") -Env @{ DEVBOX_CONFIG_DIR = $TargetConfig } + Invoke-Logged -Name "04-human-sparse-clone" -Exe $DevboxBin -CommandArgs @("clone", "human-source", $ProductSparseTarget, "--sparse", "--no-background-sync") -Env @{ DEVBOX_CONFIG_DIR = $TargetConfig } + Expect-Contains (Join-Path $EvidenceDir "04-human-sparse-clone.stdout.log") "Files: available on demand" + Expect-Absent (Join-Path $ProductSparseTarget "README.md") + Invoke-Logged -Name "05-human-sparse-status" -Exe $DevboxBin -CommandArgs @("status", $ProductSparseTarget) -Env @{ DEVBOX_CONFIG_DIR = $TargetConfig } + Expect-Contains (Join-Path $EvidenceDir "05-human-sparse-status.stdout.log") "Cloud-only:" + Invoke-Logged -Name "06-human-hydrate-readme" -Exe $DevboxBin -CommandArgs @("hydrate", (Join-Path $ProductSparseTarget "README.md")) -Env @{ DEVBOX_CONFIG_DIR = $TargetConfig } + Expect-Contains (Join-Path $EvidenceDir "06-human-hydrate-readme.stdout.log") "Hydrated: README.md" + Expect-FileText (Join-Path $ProductSparseTarget "README.md") "hello" + Invoke-Logged -Name "07-human-warm-small-files" -Exe $DevboxBin -CommandArgs @("warm", $ProductSparseTarget, "--max-bytes", "40") -Env @{ DEVBOX_CONFIG_DIR = $TargetConfig } + Expect-Contains (Join-Path $EvidenceDir "07-human-warm-small-files.stdout.log") "Selected:" + Expect-FileText (Join-Path $ProductSparseTarget "src\main.rs") "fn main() {}" + Expect-FileText (Join-Path $ProductSparseTarget "config\app.toml") "debug=1" + Expect-Absent (Join-Path $ProductSparseTarget "big.bin") + Invoke-Logged -Name "08-human-keep-readme" -Exe $DevboxBin -CommandArgs @("keep", (Join-Path $ProductSparseTarget "README.md")) -Env @{ DEVBOX_CONFIG_DIR = $TargetConfig } + Expect-Contains (Join-Path $EvidenceDir "08-human-keep-readme.stdout.log") "Kept for offline: README.md" + New-TextFile (Join-Path $ProductSparseTarget "src\main.rs") "dirty local change" + Invoke-Logged -Name "09-human-free-space-success" -Exe $DevboxBin -CommandArgs @("free-space", $ProductSparseTarget, "--max-bytes", "0") -Env @{ DEVBOX_CONFIG_DIR = $TargetConfig } + Expect-Contains (Join-Path $EvidenceDir "09-human-free-space-success.stdout.log") "Safety: changed and kept files were left alone" + Expect-Contains (Join-Path $EvidenceDir "09-human-free-space-success.stdout.log") "Skipped:" + Expect-FileText (Join-Path $ProductSparseTarget "README.md") "hello" + Expect-FileText (Join-Path $ProductSparseTarget "src\main.rs") "dirty local change" + Expect-Absent (Join-Path $ProductSparseTarget "config\app.toml") + Invoke-Logged -Name "10-human-status-after-free-space" -Exe $DevboxBin -CommandArgs @("status", $ProductSparseTarget) -Env @{ DEVBOX_CONFIG_DIR = $TargetConfig } + Expect-Contains (Join-Path $EvidenceDir "10-human-status-after-free-space.stdout.log") "Kept offline:" + Expect-Contains (Join-Path $EvidenceDir "10-human-status-after-free-space.stdout.log") "Changed locally:" + + $RefusalSource = Join-Path $WorkDir "human-free-space-refusal" + New-TextFile (Join-Path $RefusalSource "README.md") "backed up proof" + Invoke-Logged -Name "11-human-refusal-share" -Exe $DevboxBin -CommandArgs @("share", $RefusalSource, "--no-background-sync") -Env @{ DEVBOX_CONFIG_DIR = $SourceConfig } + $ApiObjects = Join-Path $ApiRoot "objects" + if (-not (Test-Path $ApiObjects)) { + Fail "could not find hosted object directory for free-space refusal" + } + Remove-SmokePath $ApiObjects + Invoke-Logged -Name "12-human-free-space-refusal" -Exe $DevboxBin -CommandArgs @("free-space", $RefusalSource, "--max-bytes", "0") -Env @{ DEVBOX_CONFIG_DIR = $SourceConfig } -ExpectFailure + Expect-Contains (Join-Path $EvidenceDir "12-human-free-space-refusal.stderr.log") "not safely backed up" + Expect-FileText (Join-Path $RefusalSource "README.md") "backed up proof" + + $AgentFolder = Join-Path $WorkDir "agent-workspace" + New-TextFile (Join-Path $AgentFolder "README.md") "hello virtual" + Invoke-Logged -Name "13-agent-track" -Exe $LoomBin -CommandArgs @("track", $AgentFolder) + Invoke-Logged -Name "14-agent-open" -Exe $LoomBin -CommandArgs @("workspace", "open", $AgentFolder, "--session", "agent-smoke") + Expect-Contains (Join-Path $EvidenceDir "14-agent-open.stdout.log") "Adapter: agent virtual" + Invoke-Logged -Name "15-agent-read" -Exe $LoomBin -CommandArgs @("workspace", "read", $AgentFolder, "--session", "agent-smoke", "README.md") + Expect-Contains (Join-Path $EvidenceDir "15-agent-read.stdout.log") "hello virtual" + Invoke-Logged -Name "16-agent-virtual-exec" -Exe $LoomBin -CommandArgs @("workspace", "exec", $AgentFolder, "--session", "agent-smoke", "--", "cat", "README.md") + Expect-Contains (Join-Path $EvidenceDir "16-agent-virtual-exec.stdout.log") "Mode: virtual" + Expect-Contains (Join-Path $EvidenceDir "16-agent-virtual-exec.stdout.log") "hello virtual" + Invoke-Logged -Name "17-agent-write-overlay" -Exe $LoomBin -CommandArgs @("workspace", "write", $AgentFolder, "--session", "agent-smoke", "notes\todo.txt", "--text", "agent overlay") + Expect-Contains (Join-Path $EvidenceDir "17-agent-write-overlay.stdout.log") "Wrote overlay: notes/todo.txt" + Invoke-Logged -Name "18-agent-diff" -Exe $LoomBin -CommandArgs @("workspace", "diff", $AgentFolder, "--session", "agent-smoke") + Expect-Contains (Join-Path $EvidenceDir "18-agent-diff.stdout.log") "Changes: 1 created" + Invoke-Logged -Name "19-agent-checkpoint" -Exe $LoomBin -CommandArgs @("workspace", "checkpoint", $AgentFolder, "--session", "agent-smoke", "-m", "agent overlay checkpoint") + Expect-Contains (Join-Path $EvidenceDir "19-agent-checkpoint.stdout.log") "Boundary: sandbox-merge" + Expect-Contains (Join-Path $EvidenceDir "19-agent-checkpoint.stdout.log") "Overlay files: 1" + Invoke-Logged -Name "20-agent-open-discard" -Exe $LoomBin -CommandArgs @("workspace", "open", $AgentFolder, "--session", "agent-discard") + Invoke-Logged -Name "21-agent-discard-write" -Exe $LoomBin -CommandArgs @("workspace", "write", $AgentFolder, "--session", "agent-discard", "scratch.txt", "--text", "discard me") + Invoke-Logged -Name "22-agent-discard" -Exe $LoomBin -CommandArgs @("workspace", "discard", $AgentFolder, "--session", "agent-discard") + Expect-Contains (Join-Path $EvidenceDir "22-agent-discard.stdout.log") "State: discarded" + Expect-Contains (Join-Path $EvidenceDir "22-agent-discard.stdout.log") "Discarded overlay files: 1" + + $MaterializedFolder = Join-Path $WorkDir "materialized-workspace" + New-TextFile (Join-Path $MaterializedFolder "README.md") "before materialized" + Invoke-Logged -Name "23-materialized-track" -Exe $LoomBin -CommandArgs @("track", $MaterializedFolder) + Invoke-Logged -Name "24-materialized-open" -Exe $LoomBin -CommandArgs @("workspace", "open", $MaterializedFolder, "--session", "materialized-safe") + $safeCommand = @("cmd", "/C", "echo after materialized>README.md && mkdir src 2>NUL & echo new materialized file>src\new.txt") + Invoke-Logged -Name "25-materialized-safe-run" -Exe $LoomBin -CommandArgs (@("workspace", "materialize-run", $MaterializedFolder, "--session", "materialized-safe", "--") + $safeCommand) + Expect-Contains (Join-Path $EvidenceDir "25-materialized-safe-run.stdout.log") "Mode: materialized-sandbox" + Expect-Contains (Join-Path $EvidenceDir "25-materialized-safe-run.stdout.log") "Captured: 2 changed" + Invoke-Logged -Name "26-materialized-safe-diff" -Exe $LoomBin -CommandArgs @("workspace", "diff", $MaterializedFolder, "--session", "materialized-safe") + Expect-Contains (Join-Path $EvidenceDir "26-materialized-safe-diff.stdout.log") "Changes: 1 created, 1 modified" + Invoke-Logged -Name "27-materialized-open-unsafe" -Exe $LoomBin -CommandArgs @("workspace", "open", $MaterializedFolder, "--session", "materialized-unsafe") + $LeakPath = Join-Path $MaterializedFolder "host-leak.txt" + $EscapedLeakPath = $LeakPath -replace "'", "''" + $unsafeCommand = @("powershell", "-NoProfile", "-Command", "Set-Content -LiteralPath '$EscapedLeakPath' -Value 'host mutation'") + Invoke-Logged -Name "28-materialized-host-mutation-refusal" -Exe $LoomBin -CommandArgs (@("workspace", "materialize-run", $MaterializedFolder, "--session", "materialized-unsafe", "--") + $unsafeCommand) -ExpectFailure + Expect-Contains (Join-Path $EvidenceDir "28-materialized-host-mutation-refusal.stderr.log") "mutated the real shared folder outside capture" + Invoke-Logged -Name "29-materialized-unsafe-diff" -Exe $LoomBin -CommandArgs @("workspace", "diff", $MaterializedFolder, "--session", "materialized-unsafe") + Expect-Contains (Join-Path $EvidenceDir "29-materialized-unsafe-diff.stdout.log") "Changes: 0 created, 0 modified" + + $FsFolder = Join-Path $WorkDir "fs-alpha" + $NativeMount = Join-Path $WorkDir "native-view" + $LocalDevMount = Join-Path $WorkDir "local-dev-view" + New-TextFile (Join-Path $FsFolder "README.md") "hello fs" + Invoke-Logged -Name "30-fs-track" -Exe $LoomBin -CommandArgs @("track", $FsFolder) + Invoke-Logged -Name "31-fs-native-status" -Exe $LoomBin -CommandArgs @("fs", "status", $FsFolder) + Expect-Contains (Join-Path $EvidenceDir "31-fs-native-status.stdout.log") "Can mount: no" + Expect-Contains (Join-Path $EvidenceDir "31-fs-native-status.stdout.log") "Hydrate-on-open: no" + Invoke-Logged -Name "32-fs-native-mount-refusal" -Exe $LoomBin -CommandArgs @("fs", "mount", $FsFolder, "--mount", $NativeMount) -ExpectFailure + Expect-Contains (Join-Path $EvidenceDir "32-fs-native-mount-refusal.stderr.log") "adapter is unsupported for mount" + Invoke-Logged -Name "33-fs-native-status-after-refusal" -Exe $LoomBin -CommandArgs @("fs", "status", $FsFolder) + Expect-Contains (Join-Path $EvidenceDir "33-fs-native-status-after-refusal.stdout.log") "Mount records: none" + Invoke-Logged -Name "34-fs-local-dev-mount" -Exe $LoomBin -CommandArgs @("fs", "mount", $FsFolder, "--adapter", "local-dev", "--mount", $LocalDevMount) + Expect-Contains (Join-Path $EvidenceDir "34-fs-local-dev-mount.stdout.log") "Adapter: local-dev" + Expect-Contains (Join-Path $EvidenceDir "34-fs-local-dev-mount.stdout.log") "Native OS integration: no" + Expect-Contains (Join-Path $EvidenceDir "34-fs-local-dev-mount.stdout.log") "Hydrate-on-open: no" + Expect-Contains (Join-Path $EvidenceDir "34-fs-local-dev-mount.stdout.log") "projection=simulated-metadata-only" + Expect-Absent $LocalDevMount + Invoke-Logged -Name "35-fs-local-dev-status-mounted" -Exe $LoomBin -CommandArgs @("fs", "status", $FsFolder, "--adapter", "local-dev", "--mount", $LocalDevMount) + Expect-Contains (Join-Path $EvidenceDir "35-fs-local-dev-status-mounted.stdout.log") "state=mounted" + Invoke-Logged -Name "36-fs-local-dev-unmount" -Exe $LoomBin -CommandArgs @("fs", "unmount", $FsFolder, "--adapter", "local-dev", "--mount", $LocalDevMount) + Expect-Contains (Join-Path $EvidenceDir "36-fs-local-dev-unmount.stdout.log") "Unmount: recorded" + Invoke-Logged -Name "37-fs-local-dev-status-unmounted" -Exe $LoomBin -CommandArgs @("fs", "status", $FsFolder, "--adapter", "local-dev", "--mount", $LocalDevMount) + Expect-Contains (Join-Path $EvidenceDir "37-fs-local-dev-status-unmounted.stdout.log") "state=unmounted" + + @" +Devbox/Loom workspace adapter alpha smoke passed. + +Workdir: $WorkDir +API: $ApiUrl + +Proofs: +- Human sparse folders: Devbox login/share/sparse clone/status/hydrate/warm/keep/free-space success/refusal. +- Agent virtual workspaces: session open, virtual read/exec, overlay write, diff, checkpoint, and discard. +- Materialized fallback: real command changes captured into overlay; unsafe host shared-folder mutation refused. +- Filesystem adapter alpha: native adapters report unsupported/no hydrate-on-open and record no mount; local-dev records metadata-only mount/status/unmount and creates no projected folder. + +Native OS hydrate-on-open, placeholder files, kernel callback hydration, chunk transport, and production filesystem drivers are intentionally not claimed here. + +Evidence logs are in this directory. Session tokens and Devbox URLs are redacted. +"@ | Set-Content -Path (Join-Path $EvidenceDir "SUMMARY.txt") + + Write-Host "workspace adapter smoke passed" + Write-Host "evidence=$EvidenceDir" + Write-Host ("summary=" + (Join-Path $EvidenceDir "SUMMARY.txt")) +} finally { + if ($ApiProcess -and -not $ApiProcess.HasExited) { + Stop-Process -Id $ApiProcess.Id -Force -ErrorAction SilentlyContinue + $ApiProcess.WaitForExit() + } + if ($Cleanup -and (Test-Path $WorkDir)) { + Remove-SmokePath $WorkDir + } +} diff --git a/tests/devbox-e2e/README.md b/tests/devbox-e2e/README.md index cd3faed..372e611 100644 --- a/tests/devbox-e2e/README.md +++ b/tests/devbox-e2e/README.md @@ -4,3 +4,9 @@ End-to-end product tests and smoke harness fixtures belong here. Use this area for flows such as login, share, clone, pause/resume, hosted storage, and multi-machine simulation. + +Current top-level smoke commands: + +- `scripts/mvp-two-device-smoke` or `scripts/mvp-two-device-smoke.ps1` for the product MVP path. +- `scripts/alpha-workspace-adapters-smoke.ps1` for sparse folder intents, agent workspaces, + materialized fallback, and filesystem adapter alpha evidence. From da5ff8bb1ca5184d57a998001a2f3c0d3d900579 Mon Sep 17 00:00:00 2001 From: Snowy Date: Tue, 23 Jun 2026 02:20:47 +0300 Subject: [PATCH 2/2] Fix cache free-space refusal wording --- loom/crates/loom-cli/src/main.rs | 2 +- loom/crates/loom-cli/tests/cli.rs | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/loom/crates/loom-cli/src/main.rs b/loom/crates/loom-cli/src/main.rs index 804d045..b2ded24 100644 --- a/loom/crates/loom-cli/src/main.rs +++ b/loom/crates/loom-cli/src/main.rs @@ -943,7 +943,7 @@ fn run_cache_free_space(args: &[String], command: &str) -> Result<(), String> { let current_status = cache_status_for_scope(&store, Path::new(""), &no_remote_proof) .map_err(|error| error.to_string())?; let remote_available_objects = if current_status.hydrated_bytes() > parsed.max_bytes { - remote_available_objects_for_versions(&store, &versions, "cache prune")? + remote_available_objects_for_versions(&store, &versions, command)? } else { no_remote_proof }; diff --git a/loom/crates/loom-cli/tests/cli.rs b/loom/crates/loom-cli/tests/cli.rs index 833df10..03f0f18 100644 --- a/loom/crates/loom-cli/tests/cli.rs +++ b/loom/crates/loom-cli/tests/cli.rs @@ -1441,6 +1441,33 @@ fn cache_prune_refuses_without_remote_object_proof() { assert!(status_stdout.contains("cache hits/misses: not measured yet")); } +#[test] +fn cache_free_space_refusal_uses_free_space_wording_without_remote() { + let dir = tempfile::tempdir().expect("temp dir"); + let fixture = dir.path().join("fixture"); + std::fs::create_dir_all(&fixture).expect("fixture creates"); + std::fs::write(fixture.join("README.md"), "local only\n").expect("readme writes"); + + assert_success(&run_loom(["track", fixture.to_str().expect("UTF-8 path")])); + + let free_space = run_loom([ + "cache", + "free-space", + "--max-bytes", + "0", + fixture.to_str().expect("UTF-8 path"), + ]); + + assert!(!free_space.status.success()); + let stderr = stderr(&free_space); + assert!(stderr.contains("cache free-space refused because no Loom remote is configured")); + assert!(!stderr.contains("cache prune refused because no Loom remote is configured")); + assert_eq!( + std::fs::read_to_string(fixture.join("README.md")).expect("readme reads"), + "local only\n" + ); +} + #[test] fn cache_status_counts_materialized_duplicate_objects_once_per_present_file() { let dir = tempfile::tempdir().expect("temp dir");