From beec4c8dff1b5ab924b4322cefc0bdc2b1ec9bcb Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 6 Apr 2026 06:12:16 -0400 Subject: [PATCH] refactor(cli): make run-adjacent commands server-only --- docs/api-reference/fabro-api.yaml | 292 ++++++++++-- lib/crates/fabro-checkpoint/src/metadata.rs | 1 + lib/crates/fabro-cli/src/args.rs | 34 +- .../fabro-cli/src/commands/artifact/cp.rs | 80 ++-- .../fabro-cli/src/commands/artifact/list.rs | 45 +- .../fabro-cli/src/commands/artifact/mod.rs | 57 ++- lib/crates/fabro-cli/src/commands/pr/close.rs | 17 +- .../fabro-cli/src/commands/pr/create.rs | 24 +- lib/crates/fabro-cli/src/commands/pr/list.rs | 39 +- lib/crates/fabro-cli/src/commands/pr/merge.rs | 18 +- lib/crates/fabro-cli/src/commands/pr/mod.rs | 27 +- lib/crates/fabro-cli/src/commands/pr/view.rs | 18 +- .../fabro-cli/src/commands/run/attach.rs | 8 +- lib/crates/fabro-cli/src/commands/run/cp.rs | 111 +++-- lib/crates/fabro-cli/src/commands/run/diff.rs | 67 +-- lib/crates/fabro-cli/src/commands/run/fork.rs | 10 +- lib/crates/fabro-cli/src/commands/run/mod.rs | 4 +- .../fabro-cli/src/commands/run/output.rs | 14 - .../fabro-cli/src/commands/run/preview.rs | 84 ++-- .../fabro-cli/src/commands/run/resume.rs | 25 +- .../fabro-cli/src/commands/run/rewind.rs | 15 +- lib/crates/fabro-cli/src/commands/run/ssh.rs | 45 +- .../fabro-cli/src/commands/run/start.rs | 8 - .../fabro-cli/src/commands/store/dump.rs | 3 + lib/crates/fabro-cli/src/manifest_builder.rs | 5 +- lib/crates/fabro-cli/src/server_client.rs | 136 ++++++ lib/crates/fabro-cli/src/shared/mod.rs | 1 + lib/crates/fabro-cli/src/shared/repo.rs | 37 ++ lib/crates/fabro-cli/src/shared/utilities.rs | 14 - .../fabro-cli/tests/it/cmd/artifact_cp.rs | 20 +- .../fabro-cli/tests/it/cmd/artifact_list.rs | 18 +- lib/crates/fabro-cli/tests/it/cmd/diff.rs | 18 +- lib/crates/fabro-cli/tests/it/cmd/fork.rs | 18 +- lib/crates/fabro-cli/tests/it/cmd/pr_close.rs | 14 +- .../fabro-cli/tests/it/cmd/pr_create.rs | 16 +- lib/crates/fabro-cli/tests/it/cmd/pr_list.rs | 16 +- lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs | 16 +- lib/crates/fabro-cli/tests/it/cmd/pr_view.rs | 14 +- lib/crates/fabro-cli/tests/it/cmd/resume.rs | 18 +- lib/crates/fabro-cli/tests/it/cmd/rewind.rs | 18 +- .../fabro-cli/tests/it/cmd/sandbox_cp.rs | 18 +- .../fabro-cli/tests/it/cmd/sandbox_preview.rs | 22 +- .../fabro-cli/tests/it/cmd/sandbox_ssh.rs | 20 +- .../fabro-cli/tests/it/cmd/system_prune.rs | 2 +- .../fabro-cli/tests/it/scenario/artifacts.rs | 7 - lib/crates/fabro-github/src/lib.rs | 59 +++ lib/crates/fabro-server/Cargo.toml | 2 +- lib/crates/fabro-server/src/demo/mod.rs | 77 ++- lib/crates/fabro-server/src/run_manifest.rs | 4 + lib/crates/fabro-server/src/server.rs | 439 +++++++++++++++++- lib/crates/fabro-store/src/run_state.rs | 1 + lib/crates/fabro-store/src/slate/mod.rs | 1 + lib/crates/fabro-types/src/run.rs | 2 + lib/crates/fabro-types/src/run_event/run.rs | 2 + lib/crates/fabro-workflow/src/event.rs | 4 + .../fabro-workflow/src/operations/create.rs | 57 ++- .../src/operations/rebuild_meta.rs | 2 + .../fabro-workflow/src/operations/start.rs | 1 + .../src/pipeline/execute/tests.rs | 1 + .../fabro-workflow/src/pipeline/initialize.rs | 1 + .../fabro-workflow/src/pipeline/persist.rs | 2 + .../src/pipeline/pull_request.rs | 6 + .../fabro-workflow/src/pipeline/retro.rs | 2 + lib/crates/fabro-workflow/src/run_lookup.rs | 2 + lib/crates/fabro-workflow/src/test_support.rs | 1 + 65 files changed, 1473 insertions(+), 687 deletions(-) create mode 100644 lib/crates/fabro-cli/src/shared/repo.rs diff --git a/docs/api-reference/fabro-api.yaml b/docs/api-reference/fabro-api.yaml index 81f78de36..6cd1a7d4a 100644 --- a/docs/api-reference/fabro-api.yaml +++ b/docs/api-reference/fabro-api.yaml @@ -744,6 +744,28 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/artifacts: + get: + operationId: listRunArtifacts + tags: [Run Internals] + summary: List Run Artifacts + description: Lists captured artifact files for a run. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: Artifact files captured for the run + content: + application/json: + schema: + $ref: "#/components/schemas/RunArtifactListResponse" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/stages/{stageId}/artifacts: get: operationId: listStageArtifacts @@ -829,31 +851,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/files: - get: - operationId: retrieveRunFiles - tags: [Run Outputs] - summary: Retrieve Run Files - description: Returns a paginated list of file-level diffs produced by the run, optionally filtered to a specific checkpoint. - parameters: - - $ref: "#/components/parameters/RunId" - - $ref: "#/components/parameters/CheckpointFilter" - - $ref: "#/components/parameters/PageLimit" - - $ref: "#/components/parameters/PageOffset" - responses: - "200": - description: Paginated list of file diffs - content: - application/json: - schema: - $ref: "#/components/schemas/PaginatedRunFileList" - "404": - description: Run not found - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - /api/v1/runs/{id}/usage: get: operationId: retrieveRunUsage @@ -957,7 +954,7 @@ paths: operationId: generatePreviewUrl tags: [Human-in-the-Loop] summary: Preview URL - description: Generates a time-limited preview URL for a port exposed by the run's sandbox environment. + description: Generates a preview URL for a port exposed by the run's sandbox environment. parameters: - $ref: "#/components/parameters/RunId" requestBody: @@ -986,6 +983,147 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" + /api/v1/runs/{id}/ssh: + post: + operationId: createRunSshAccess + tags: [Human-in-the-Loop] + summary: SSH Access + description: Creates a time-limited SSH command for the run's sandbox environment. + parameters: + - $ref: "#/components/parameters/RunId" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/SshAccessRequest" + responses: + "201": + description: SSH command created + content: + application/json: + schema: + $ref: "#/components/schemas/SshAccessResponse" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox or provider does not support SSH + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/runs/{id}/sandbox/files: + get: + operationId: listSandboxFiles + tags: [Human-in-the-Loop] + summary: List Sandbox Files + description: Lists directory entries from the run's sandbox environment. + parameters: + - $ref: "#/components/parameters/RunId" + - in: query + name: path + required: true + schema: + type: string + - in: query + name: depth + required: false + schema: + type: integer + minimum: 1 + responses: + "200": + description: Directory entries + content: + application/json: + schema: + $ref: "#/components/schemas/SandboxFileListResponse" + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + + /api/v1/runs/{id}/sandbox/file: + get: + operationId: getSandboxFile + tags: [Human-in-the-Loop] + summary: Download Sandbox File + description: Downloads a file from the run's sandbox environment. + parameters: + - $ref: "#/components/parameters/RunId" + - in: query + name: path + required: true + schema: + type: string + responses: + "200": + description: File contents + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Run or file not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + put: + operationId: putSandboxFile + tags: [Human-in-the-Loop] + summary: Upload Sandbox File + description: Uploads a file into the run's sandbox environment. + parameters: + - $ref: "#/components/parameters/RunId" + - in: query + name: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "204": + description: File written + "404": + description: Run not found + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + "409": + description: Run has no active sandbox + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + # ── Workflows ───────────────────────────────────────────────────────── /api/v1/workflows: @@ -3067,6 +3205,45 @@ components: items: $ref: "#/components/schemas/ArtifactEntry" + RunArtifactEntry: + description: A captured artifact file for a run. + type: object + required: + - stage_id + - node_slug + - retry + - relative_path + - size + properties: + stage_id: + type: string + description: Stage ID in `node@visit` form. + node_slug: + type: string + description: Node slug that produced the artifact. + retry: + type: integer + format: int32 + description: Retry attempt number. + relative_path: + type: string + description: Artifact path relative to the stage artifact capture directory. + size: + type: integer + format: int64 + description: Artifact size in bytes. + + RunArtifactListResponse: + description: List of captured artifact files for a run. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/RunArtifactEntry" + InternalRunStatus: description: Internal event-sourced run status. type: string @@ -4101,6 +4278,10 @@ components: minimum: 1 maximum: 86400 example: 3600 + signed: + type: boolean + description: When true, return a signed URL that does not require a preview token header. + default: false PreviewUrlResponse: description: Response containing the generated preview URL. @@ -4110,8 +4291,65 @@ components: properties: url: type: string - description: Time-limited preview URL. + description: Preview URL. example: "https://preview.example.com/sb-a1b2c3d4/3000" + token: + type: string + description: Preview token header value for unsigned preview URLs. + example: "preview-token-123" + + SshAccessRequest: + description: Request body for creating SSH access for a sandbox-backed run. + type: object + required: + - ttl_minutes + properties: + ttl_minutes: + type: number + description: Time-to-live for the SSH command in minutes. + minimum: 1 + maximum: 1440 + example: 60 + + SshAccessResponse: + description: Response containing an SSH command for the sandbox. + type: object + required: + - command + properties: + command: + type: string + description: SSH command to connect to the sandbox. + example: ssh daytona@preview.example.com -p 2222 + + SandboxFileEntry: + description: A directory entry in a run sandbox. + type: object + required: + - name + - is_dir + properties: + name: + type: string + description: Basename of the entry. + is_dir: + type: boolean + description: Whether the entry is a directory. + size: + type: integer + format: int64 + description: File size in bytes when known. + + SandboxFileListResponse: + description: Non-paginated list of sandbox directory entries. + type: object + required: + - data + properties: + data: + type: array + items: + $ref: "#/components/schemas/SandboxFileEntry" # ── Workflow Schemas ───────────────────────────────────────────────── diff --git a/lib/crates/fabro-checkpoint/src/metadata.rs b/lib/crates/fabro-checkpoint/src/metadata.rs index c4abfa939..857392214 100644 --- a/lib/crates/fabro-checkpoint/src/metadata.rs +++ b/lib/crates/fabro-checkpoint/src/metadata.rs @@ -211,6 +211,7 @@ mod tests { workflow_slug: None, working_directory: PathBuf::from("/tmp"), host_repo_path: None, + repo_origin_url: None, base_branch: None, labels: HashMap::new(), } diff --git a/lib/crates/fabro-cli/src/args.rs b/lib/crates/fabro-cli/src/args.rs index 1ba8e9fc7..002bbb4ee 100644 --- a/lib/crates/fabro-cli/src/args.rs +++ b/lib/crates/fabro-cli/src/args.rs @@ -375,7 +375,7 @@ pub(crate) struct ParseArgs { #[derive(Args)] pub(crate) struct ArtifactListArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID (or prefix) pub(crate) run_id: String, @@ -392,7 +392,7 @@ pub(crate) struct ArtifactListArgs { #[derive(Args)] pub(crate) struct ArtifactCpArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Source: RUN_ID (all artifacts) or RUN_ID:path (specific artifact) pub(crate) source: String, @@ -417,7 +417,7 @@ pub(crate) struct ArtifactCpArgs { #[derive(Args)] pub(crate) struct CpArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Source: : or local path pub(crate) src: String, @@ -431,7 +431,7 @@ pub(crate) struct CpArgs { #[derive(Args)] pub(crate) struct PreviewArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID or prefix pub(crate) run: String, @@ -451,7 +451,7 @@ pub(crate) struct PreviewArgs { #[derive(Args)] pub(crate) struct SshArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID or prefix pub(crate) run: String, @@ -466,19 +466,13 @@ pub(crate) struct SshArgs { #[derive(Args)] pub(crate) struct DiffArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID or prefix pub(crate) run: String, /// Show diff for a specific node #[arg(long)] pub(crate) node: Option, - /// Show diffstat instead of full patch (live diffs only) - #[arg(long)] - pub(crate) stat: bool, - /// Show only files-changed/insertions/deletions summary (live diffs only) - #[arg(long)] - pub(crate) shortstat: bool, } #[derive(Args)] @@ -523,7 +517,7 @@ pub(crate) struct SecretSetArgs { #[derive(Debug, Args)] pub(crate) struct ResumeArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID or unambiguous prefix pub(crate) run: String, @@ -536,7 +530,7 @@ pub(crate) struct ResumeArgs { #[derive(Debug, Args)] pub(crate) struct RewindArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID (or unambiguous prefix) pub(crate) run_id: String, @@ -556,7 +550,7 @@ pub(crate) struct RewindArgs { #[derive(Debug, Args)] pub(crate) struct ForkArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID (or unambiguous prefix) pub(crate) run_id: String, @@ -683,7 +677,7 @@ pub(crate) struct SkillInstallArgs { #[derive(Args)] pub(crate) struct PrCreateArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID or prefix pub(crate) run_id: String, @@ -695,7 +689,7 @@ pub(crate) struct PrCreateArgs { #[derive(Args)] pub(crate) struct PrListArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Show all PRs (including closed/merged), not just open #[arg(long)] @@ -705,7 +699,7 @@ pub(crate) struct PrListArgs { #[derive(Args)] pub(crate) struct PrViewArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID or prefix pub(crate) run_id: String, @@ -714,7 +708,7 @@ pub(crate) struct PrViewArgs { #[derive(Args)] pub(crate) struct PrMergeArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID or prefix pub(crate) run_id: String, @@ -726,7 +720,7 @@ pub(crate) struct PrMergeArgs { #[derive(Args)] pub(crate) struct PrCloseArgs { #[command(flatten)] - pub(crate) storage_dir: StorageDirArgs, + pub(crate) server: ServerTargetArgs, /// Run ID or prefix pub(crate) run_id: String, diff --git a/lib/crates/fabro-cli/src/commands/artifact/cp.rs b/lib/crates/fabro-cli/src/commands/artifact/cp.rs index 33bf75308..4cb33537e 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/cp.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/cp.rs @@ -1,25 +1,20 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_store::RuntimeState; -use fabro_workflow::artifacts::{ArtifactEntry, scan_artifacts}; use crate::args::{ArtifactCpArgs, GlobalArgs}; -use crate::server_runs::ServerRunLookup; +use crate::server_client::ServerStoreClient; use crate::shared::{print_json_pretty, split_run_path}; -use crate::user_config::load_settings_with_storage_dir; pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let (run_id, asset_path) = parse_source(&args.source); - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; - let run = lookup.resolve(run_id)?; - let runtime_state = RuntimeState::new(&run.path); - let entries = scan_artifacts( - &runtime_state.artifacts_dir(), + let (run_id_selector, asset_path) = parse_source(&args.source); + let (run_id, client, entries) = super::resolve_artifacts( + &args.server, + run_id_selector, args.node.as_deref(), args.retry, - )?; + ) + .await?; if entries.is_empty() { bail!("No artifacts found for this run"); @@ -39,7 +34,7 @@ pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> R if matching.len() > 1 { let candidates: Vec<_> = matching .iter() - .map(|entry| format!("{}:retry_{}", entry.node_slug, entry.retry)) + .map(|entry| format_candidate(entry)) .collect(); bail!( "Path '{path}' matches multiple artifacts: {}. Use --node and/or --retry to disambiguate.", @@ -53,16 +48,7 @@ pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> R .file_name() .unwrap_or_else(|| std::ffi::OsStr::new(&entry.relative_path)), ); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; + write_artifact_file(&client, &run_id, entry, &dest_file).await?; if globals.json { print_json_pretty(&serde_json::json!({ "copied": [{ @@ -83,23 +69,15 @@ pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> R .join(format!("retry_{}", entry.retry)) .join(&entry.relative_path); let dest_file = args.dest.join(relative_dest); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; + write_artifact_file(&client, &run_id, entry, &dest_file).await?; copied.push(serde_json::json!({ "relative_path": entry.relative_path, "destination": dest_file.display().to_string(), })); } } else { - let mut by_filename: Vec<(String, &ArtifactEntry)> = Vec::with_capacity(entries.len()); + let mut by_filename: Vec<(String, &super::ArtifactEntry)> = + Vec::with_capacity(entries.len()); for entry in &entries { let filename = Path::new(&entry.relative_path) .file_name() @@ -119,16 +97,7 @@ pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> R for (filename, entry) in &by_filename { let dest_file = args.dest.join(filename); - if let Some(parent) = dest_file.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::copy(&entry.absolute_path, &dest_file).with_context(|| { - format!( - "Failed to copy {} to {}", - entry.absolute_path.display(), - dest_file.display() - ) - })?; + write_artifact_file(&client, &run_id, entry, &dest_file).await?; copied.push(serde_json::json!({ "relative_path": entry.relative_path, "destination": dest_file.display().to_string(), @@ -148,6 +117,23 @@ pub(super) async fn cp_command(args: &ArtifactCpArgs, globals: &GlobalArgs) -> R Ok(()) } +async fn write_artifact_file( + client: &ServerStoreClient, + run_id: &fabro_types::RunId, + entry: &super::ArtifactEntry, + dest_file: &Path, +) -> Result<()> { + if let Some(parent) = dest_file.parent() { + std::fs::create_dir_all(parent)?; + } + let bytes = client + .download_stage_artifact(run_id, &entry.stage_id, &entry.relative_path) + .await?; + std::fs::write(dest_file, bytes) + .with_context(|| format!("Failed to write {}", dest_file.display()))?; + Ok(()) +} + fn parse_source(source: &str) -> (&str, Option<&str>) { match split_run_path(source) { Some((run_id, path)) => (run_id, Some(path)), @@ -155,7 +141,7 @@ fn parse_source(source: &str) -> (&str, Option<&str>) { } } -fn format_candidate(entry: &ArtifactEntry) -> String { +fn format_candidate(entry: &super::ArtifactEntry) -> String { format!("{}:retry_{}", entry.node_slug, entry.retry) } @@ -193,11 +179,11 @@ mod tests { #[test] fn format_candidate_includes_retry() { - let entry = ArtifactEntry { + let entry = super::super::ArtifactEntry { node_slug: "retry_assets".to_string(), retry: 2, + stage_id: fabro_types::StageId::new("retry_assets", 2), relative_path: "assets/retry/report.txt".to_string(), - absolute_path: PathBuf::from("/tmp/report.txt"), size: 6, }; diff --git a/lib/crates/fabro-cli/src/commands/artifact/list.rs b/lib/crates/fabro-cli/src/commands/artifact/list.rs index 71482be02..157bff5e2 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/list.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/list.rs @@ -1,22 +1,11 @@ use anyhow::Result; -use fabro_store::RuntimeState; -use fabro_workflow::artifacts::scan_artifacts; use crate::args::{ArtifactListArgs, GlobalArgs}; -use crate::server_runs::ServerRunLookup; -use crate::shared::format_size; -use crate::user_config::load_settings_with_storage_dir; pub(super) async fn list_command(args: &ArtifactListArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; - let run = lookup.resolve(&args.run_id)?; - let runtime_state = RuntimeState::new(&run.path); - let entries = scan_artifacts( - &runtime_state.artifacts_dir(), - args.node.as_deref(), - args.retry, - )?; + let (_run_id, _client, entries) = + super::resolve_artifacts(&args.server, &args.run_id, args.node.as_deref(), args.retry) + .await?; if globals.json { println!("{}", serde_json::to_string_pretty(&entries)?); @@ -34,34 +23,22 @@ pub(super) async fn list_command(args: &ArtifactListArgs, globals: &GlobalArgs) .max() .unwrap_or(4) .max(4); - let retry_width = 5; - let size_width = entries + let retry_width = entries .iter() - .map(|entry| format_size(entry.size).len()) + .map(|entry| entry.retry.to_string().len()) .max() - .unwrap_or(4) - .max(4); + .unwrap_or(5) + .max(5); - println!( - "{:retry_width$} {:>size_width$} PATH", - "NODE", "RETRY", "SIZE" - ); - let total_size: u64 = entries.iter().map(|entry| entry.size).sum(); + println!("{:retry_width$} PATH", "NODE", "RETRY"); for entry in &entries { println!( - "{:retry_width$} {:>size_width$} {}", - entry.node_slug, - entry.retry, - format_size(entry.size), - entry.relative_path + "{:retry_width$} {}", + entry.node_slug, entry.retry, entry.relative_path ); } println!(); - println!( - "{} artifact(s), {} total", - entries.len(), - format_size(total_size) - ); + println!("{} artifact(s)", entries.len()); Ok(()) } diff --git a/lib/crates/fabro-cli/src/commands/artifact/mod.rs b/lib/crates/fabro-cli/src/commands/artifact/mod.rs index 144fe0ea0..093fdfda3 100644 --- a/lib/crates/fabro-cli/src/commands/artifact/mod.rs +++ b/lib/crates/fabro-cli/src/commands/artifact/mod.rs @@ -1,9 +1,62 @@ mod cp; mod list; -use anyhow::Result; +use anyhow::{Context, Result}; +use fabro_types::{RunId, StageId}; -use crate::args::{ArtifactCommand, ArtifactNamespace, GlobalArgs}; +use crate::args::{ArtifactCommand, ArtifactNamespace, GlobalArgs, ServerTargetArgs}; +use crate::server_client::ServerStoreClient; +use crate::server_runs::ServerSummaryLookup; + +#[derive(Clone, Debug, serde::Serialize)] +pub(super) struct ArtifactEntry { + #[serde(skip_serializing)] + pub(super) stage_id: StageId, + pub(super) node_slug: String, + pub(super) retry: u32, + pub(super) relative_path: String, + pub(super) size: u64, +} + +pub(super) async fn resolve_artifacts( + server: &ServerTargetArgs, + run_selector: &str, + node: Option<&str>, + retry: Option, +) -> Result<(RunId, ServerStoreClient, Vec)> { + let lookup = ServerSummaryLookup::connect(server).await?; + let run = lookup.resolve(run_selector)?; + let run_id = run.run_id(); + let mut entries = Vec::new(); + for entry in lookup.client().list_run_artifacts(&run_id).await? { + if node.is_some_and(|value| entry.node_slug != value) { + continue; + } + let entry_retry = u32::try_from(entry.retry) + .context("server returned invalid negative artifact retry")?; + if retry.is_some_and(|value| entry_retry != value) { + continue; + } + let size = + u64::try_from(entry.size).context("server returned invalid negative artifact size")?; + entries.push(ArtifactEntry { + stage_id: entry.stage_id.parse()?, + node_slug: entry.node_slug, + retry: entry_retry, + relative_path: entry.relative_path, + size, + }); + } + + entries.sort_by(|a, b| { + a.stage_id + .cmp(&b.stage_id) + .then_with(|| a.relative_path.cmp(&b.relative_path)) + }); + + let client = lookup.client().clone_for_reuse(); + Ok((run_id, client, entries)) +} pub(crate) async fn dispatch(ns: ArtifactNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { diff --git a/lib/crates/fabro-cli/src/commands/pr/close.rs b/lib/crates/fabro-cli/src/commands/pr/close.rs index cca17cc84..f24897245 100644 --- a/lib/crates/fabro-cli/src/commands/pr/close.rs +++ b/lib/crates/fabro-cli/src/commands/pr/close.rs @@ -1,30 +1,15 @@ -use std::path::Path; - use anyhow::{Context, Result}; -use fabro_workflow::run_lookup::runs_base; use tracing::info; use crate::args::{GlobalArgs, PrCloseArgs}; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; pub(super) async fn close_command( args: PrCloseArgs, github_app: Option, globals: &GlobalArgs, ) -> Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let base = runs_base(&cli_settings.storage_dir()); - close_from(&base, args, github_app, globals).await -} - -async fn close_from( - base: &Path, - args: PrCloseArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id).await?; let creds = github_app.context( "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", diff --git a/lib/crates/fabro-cli/src/commands/pr/create.rs b/lib/crates/fabro-cli/src/commands/pr/create.rs index da95cc72a..a80c5f87c 100644 --- a/lib/crates/fabro-cli/src/commands/pr/create.rs +++ b/lib/crates/fabro-cli/src/commands/pr/create.rs @@ -1,36 +1,22 @@ -use std::path::Path; - use anyhow::{Context, Result, bail}; use fabro_model::Catalog; use fabro_sandbox::daytona::detect_repo_info; use fabro_workflow::outcome::StageStatus; use fabro_workflow::pull_request::maybe_open_pull_request; -use fabro_workflow::run_lookup::runs_base; use tracing::info; use crate::args::{GlobalArgs, PrCreateArgs}; use crate::commands::store::rebuild::rebuild_run_store; -use crate::server_runs::ServerRunLookup; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; +use crate::shared::repo::ensure_matching_repo_origin; pub(super) async fn create_command( args: PrCreateArgs, github_app: Option, globals: &GlobalArgs, ) -> Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let base = runs_base(&cli_settings.storage_dir()); - create_from(&base, args, github_app, globals).await -} - -async fn create_from( - base: &Path, - args: PrCreateArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let lookup = ServerRunLookup::connect_from_runs_base(base).await?; + let lookup = ServerSummaryLookup::connect(&args.server).await?; let run = lookup.resolve(&args.run_id)?; let run_id = run.run_id(); let events = lookup.client().list_run_events(&run_id, None, None).await?; @@ -38,6 +24,10 @@ async fn create_from( let state = run_store.state().await?; let record = state.run.context("Failed to load run record from store")?; + ensure_matching_repo_origin( + record.repo_origin_url.as_deref(), + "create a pull request for", + )?; let start = state .start diff --git a/lib/crates/fabro-cli/src/commands/pr/list.rs b/lib/crates/fabro-cli/src/commands/pr/list.rs index 19b89a84d..1494d4a54 100644 --- a/lib/crates/fabro-cli/src/commands/pr/list.rs +++ b/lib/crates/fabro-cli/src/commands/pr/list.rs @@ -1,17 +1,11 @@ -use std::path::Path; - use anyhow::{Context, Result}; -use fabro_types::PullRequestRecord; -use fabro_workflow::run_lookup::{runs_base, scan_runs_with_summaries}; use futures::future::join_all; use serde::Serialize; use tracing::info; use crate::args::{GlobalArgs, PrListArgs}; -use crate::server_client; -use crate::server_runs::ServerRunLookup; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; #[derive(Serialize)] struct PrRow { @@ -26,38 +20,15 @@ pub(super) async fn list_command( args: PrListArgs, github_app: Option, globals: &GlobalArgs, -) -> Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let base = runs_base(&cli_settings.storage_dir()); - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; - list_from( - lookup.client(), - lookup.summaries(), - &base, - args, - github_app, - globals, - ) - .await -} - -async fn list_from( - client: &server_client::ServerStoreClient, - summaries: &[fabro_store::RunSummary], - base: &Path, - args: PrListArgs, - github_app: Option, - globals: &GlobalArgs, ) -> Result<()> { let creds = github_app.context( "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", )?; + let lookup = ServerSummaryLookup::connect(&args.server).await?; - let runs = scan_runs_with_summaries(summaries, base).context("Failed to scan runs")?; - - let mut entries: Vec<(String, PullRequestRecord)> = Vec::new(); - for run in &runs { - if let Ok(state) = client.get_run_state(&run.run_id()).await { + let mut entries = Vec::new(); + for run in lookup.runs() { + if let Ok(state) = lookup.client().get_run_state(&run.run_id()).await { if let Some(record) = state.pull_request { entries.push((run.run_id().to_string(), record)); } diff --git a/lib/crates/fabro-cli/src/commands/pr/merge.rs b/lib/crates/fabro-cli/src/commands/pr/merge.rs index 8d0e5f513..7117daf3a 100644 --- a/lib/crates/fabro-cli/src/commands/pr/merge.rs +++ b/lib/crates/fabro-cli/src/commands/pr/merge.rs @@ -1,31 +1,15 @@ -use std::path::Path; - use anyhow::{Context, Result}; use tracing::info; -use fabro_workflow::run_lookup::runs_base; - use crate::args::{GlobalArgs, PrMergeArgs}; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; pub(super) async fn merge_command( args: PrMergeArgs, github_app: Option, globals: &GlobalArgs, ) -> Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let base = runs_base(&cli_settings.storage_dir()); - merge_from(&base, args, github_app, globals).await -} - -async fn merge_from( - base: &Path, - args: PrMergeArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id).await?; let creds = github_app.context( "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", diff --git a/lib/crates/fabro-cli/src/commands/pr/mod.rs b/lib/crates/fabro-cli/src/commands/pr/mod.rs index 58a5fb8d0..577b14bfc 100644 --- a/lib/crates/fabro-cli/src/commands/pr/mod.rs +++ b/lib/crates/fabro-cli/src/commands/pr/mod.rs @@ -4,41 +4,39 @@ mod list; mod merge; mod view; -use std::path::{Path, PathBuf}; - use anyhow::{Context, Result}; use fabro_types::PullRequestRecord; -use crate::args::{GlobalArgs, PrCommand, PrNamespace}; -use crate::server_runs::ServerRunLookup; +use crate::args::{GlobalArgs, PrCommand, PrNamespace, ServerTargetArgs}; +use crate::server_runs::ServerSummaryLookup; use crate::shared::github::build_github_app_credentials; -use crate::user_config::load_settings_with_storage_dir; +use crate::user_config::load_settings; pub(crate) async fn dispatch(ns: PrNamespace, globals: &GlobalArgs) -> Result<()> { match ns.command { PrCommand::Create(args) => { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; + let cli_settings = load_settings()?; let github_app = build_github_app_credentials(cli_settings.app_id())?; Box::pin(create::create_command(args, github_app, globals)).await } PrCommand::List(args) => { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; + let cli_settings = load_settings()?; let github_app = build_github_app_credentials(cli_settings.app_id())?; list::list_command(args, github_app, globals).await } PrCommand::View(args) => { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; + let cli_settings = load_settings()?; let github_app = build_github_app_credentials(cli_settings.app_id())?; view::view_command(args, github_app, globals).await } PrCommand::Merge(args) => { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; + let cli_settings = load_settings()?; let github_app = build_github_app_credentials(cli_settings.app_id())?; merge::merge_command(args, github_app, globals).await } PrCommand::Close(args) => { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; + let cli_settings = load_settings()?; let github_app = build_github_app_credentials(cli_settings.app_id())?; close::close_command(args, github_app, globals).await } @@ -46,16 +44,15 @@ pub(crate) async fn dispatch(ns: PrNamespace, globals: &GlobalArgs) -> Result<() } pub(crate) async fn load_pr_record( - base: &Path, + server: &ServerTargetArgs, run_id: &str, -) -> Result<(PullRequestRecord, PathBuf)> { - let lookup = ServerRunLookup::connect_from_runs_base(base).await?; +) -> Result<(PullRequestRecord, fabro_types::RunId)> { + let lookup = ServerSummaryLookup::connect(server).await?; let run = lookup.resolve(run_id)?; let run_id = run.run_id(); - let run_dir = run.path; let state = lookup.client().get_run_state(&run_id).await?; let record = state.pull_request.with_context(|| { format!("No pull request found in store. Create one first with: fabro pr create {run_id}") })?; - Ok((record, run_dir)) + Ok((record, run_id)) } diff --git a/lib/crates/fabro-cli/src/commands/pr/view.rs b/lib/crates/fabro-cli/src/commands/pr/view.rs index e2909913e..11a719dfa 100644 --- a/lib/crates/fabro-cli/src/commands/pr/view.rs +++ b/lib/crates/fabro-cli/src/commands/pr/view.rs @@ -1,31 +1,15 @@ -use std::path::Path; - use anyhow::{Context, Result}; use tracing::info; -use fabro_workflow::run_lookup::runs_base; - use crate::args::{GlobalArgs, PrViewArgs}; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; pub(super) async fn view_command( args: PrViewArgs, github_app: Option, globals: &GlobalArgs, ) -> Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let base = runs_base(&cli_settings.storage_dir()); - view_from(&base, args, github_app, globals).await -} - -async fn view_from( - base: &Path, - args: PrViewArgs, - github_app: Option, - globals: &GlobalArgs, -) -> Result<()> { - let (record, _run_dir) = super::load_pr_record(base, &args.run_id).await?; + let (record, _run_id) = super::load_pr_record(&args.server, &args.run_id).await?; let creds = github_app.context( "GitHub App credentials required — set GITHUB_APP_PRIVATE_KEY and configure app_id", diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index e9ac16d4f..c44d7aa31 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -1,5 +1,8 @@ use std::io::{IsTerminal, Write}; -use std::path::{Path, PathBuf}; +#[cfg(test)] +use std::path::Path; +#[cfg(test)] +use std::path::PathBuf; use std::process::ExitCode; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -32,6 +35,7 @@ const ATTACH_FINAL_STATUS_GRACE: Duration = Duration::from_secs(2); /// Attach to a running (or finished) workflow run, rendering progress live. /// /// Returns exit code 0 for success/partial_success, 1 otherwise. +#[cfg(test)] pub(crate) async fn attach_run( run_dir: &Path, storage_dir: Option<&Path>, @@ -376,12 +380,14 @@ fn restore_empty_run_properties(value: &mut serde_json::Value) { } } +#[cfg(test)] fn infer_storage_dir(run_dir: &Path) -> Option { let runs_dir = run_dir.parent()?; let storage_dir = runs_dir.parent()?; (runs_dir.file_name()? == "runs").then(|| storage_dir.to_path_buf()) } +#[cfg(test)] fn infer_run_id(run_dir: &Path) -> Option { std::fs::read_to_string(run_dir.join("id.txt")) .ok() diff --git a/lib/crates/fabro-cli/src/commands/run/cp.rs b/lib/crates/fabro-cli/src/commands/run/cp.rs index d4bb7cfde..a63d95b73 100644 --- a/lib/crates/fabro-cli/src/commands/run/cp.rs +++ b/lib/crates/fabro-cli/src/commands/run/cp.rs @@ -1,16 +1,15 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result, bail}; -use fabro_agent::sandbox::Sandbox; -use fabro_sandbox::reconnect::reconnect; use tokio::fs; use tracing::{debug, info}; -use crate::args::{CpArgs, GlobalArgs}; -use crate::server_runs::ServerRunLookup; +use crate::args::{CpArgs, GlobalArgs, ServerTargetArgs}; +use crate::server_client::ServerStoreClient; +use crate::server_runs::ServerSummaryLookup; use crate::shared::{print_json_pretty, split_run_path}; -use crate::user_config::load_settings_with_storage_dir; +#[derive(Debug)] enum CopyDirection { Download { run_prefix: String, @@ -26,7 +25,6 @@ enum CopyDirection { pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> { let direction = parse_direction(&args.src, &args.dst)?; - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; match direction { CopyDirection::Download { @@ -34,16 +32,13 @@ pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> remote_path, local_path, } => { - let sandbox = load_sandbox(&cli_settings.storage_dir(), &run_prefix).await?; + let (client, run_id) = resolve_client_and_run_id(&args.server, &run_prefix).await?; let file_count = if args.recursive { - Some(download_recursive(&*sandbox, &remote_path, &local_path).await?) + Some(download_recursive(&client, &run_id, &remote_path, &local_path).await?) } else { debug!(path = %remote_path, "Downloading file from sandbox"); - sandbox - .download_file_to_local(&remote_path, &local_path) - .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + write_sandbox_file(&client, &run_id, &remote_path, &local_path).await?; None }; @@ -67,16 +62,13 @@ pub(crate) async fn cp_command(args: CpArgs, globals: &GlobalArgs) -> Result<()> run_prefix, remote_path, } => { - let sandbox = load_sandbox(&cli_settings.storage_dir(), &run_prefix).await?; + let (client, run_id) = resolve_client_and_run_id(&args.server, &run_prefix).await?; let file_count = if args.recursive { - Some(upload_recursive(&*sandbox, &local_path, &remote_path).await?) + Some(upload_recursive(&client, &run_id, &local_path, &remote_path).await?) } else { debug!(path = %remote_path, "Uploading file to sandbox"); - sandbox - .upload_file_from_local(&local_path, &remote_path) - .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + upload_sandbox_file(&client, &run_id, &local_path, &remote_path).await?; None }; @@ -121,29 +113,54 @@ fn parse_direction(src: &str, dst: &str) -> Result { } } -async fn load_sandbox(storage_dir: &Path, run_prefix: &str) -> Result> { - let lookup = ServerRunLookup::connect(storage_dir).await?; +async fn resolve_client_and_run_id( + server: &ServerTargetArgs, + run_prefix: &str, +) -> Result<(ServerStoreClient, fabro_types::RunId)> { + let lookup = ServerSummaryLookup::connect(server).await?; let run = lookup.resolve(run_prefix)?; - let record = lookup - .client() - .get_run_state(&run.run_id()) - .await? - .sandbox - .context("Failed to load sandbox record from store")?; + Ok((lookup.client().clone_for_reuse(), run.run_id())) +} - info!(run_id = %run_prefix, provider = %record.provider, "Connecting to sandbox"); - reconnect(&record).await +async fn write_sandbox_file( + client: &ServerStoreClient, + run_id: &fabro_types::RunId, + remote_path: &str, + local_path: &Path, +) -> Result<()> { + if let Some(parent) = local_path.parent() { + fs::create_dir_all(parent) + .await + .with_context(|| format!("Failed to create directory {}", parent.display()))?; + } + let bytes = client.get_sandbox_file(run_id, remote_path).await?; + fs::write(local_path, bytes) + .await + .with_context(|| format!("Failed to write {}", local_path.display()))?; + Ok(()) +} + +async fn upload_sandbox_file( + client: &ServerStoreClient, + run_id: &fabro_types::RunId, + local_path: &Path, + remote_path: &str, +) -> Result<()> { + let bytes = fs::read(local_path) + .await + .with_context(|| format!("Failed to read {}", local_path.display()))?; + client.put_sandbox_file(run_id, remote_path, bytes).await } async fn download_recursive( - sandbox: &dyn Sandbox, + client: &ServerStoreClient, + run_id: &fabro_types::RunId, remote_path: &str, local_path: &Path, ) -> Result { - let entries = sandbox - .list_directory(remote_path, Some(100)) - .await - .map_err(|err| anyhow::anyhow!("Failed to list directory {remote_path}: {err}"))?; + let entries = client + .list_sandbox_files(run_id, remote_path, Some(100)) + .await?; let mut file_count = 0usize; for entry in &entries { @@ -152,16 +169,8 @@ async fn download_recursive( } let remote_file = format!("{remote_path}/{}", entry.name); let local_file = local_path.join(&entry.name); - if let Some(parent) = local_file.parent() { - fs::create_dir_all(parent) - .await - .with_context(|| format!("Failed to create directory {}", parent.display()))?; - } debug!(path = %remote_file, "Downloading file from sandbox"); - sandbox - .download_file_to_local(&remote_file, &local_file) - .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + write_sandbox_file(client, run_id, &remote_file, &local_file).await?; file_count += 1; } debug!(count = file_count, "Recursive download complete"); @@ -169,7 +178,8 @@ async fn download_recursive( } async fn upload_recursive( - sandbox: &dyn Sandbox, + client: &ServerStoreClient, + run_id: &fabro_types::RunId, local_path: &Path, remote_path: &str, ) -> Result { @@ -190,10 +200,7 @@ async fn upload_recursive( stack.push((entry_path, remote_file)); } else { debug!(path = %remote_file, "Uploading file to sandbox"); - sandbox - .upload_file_from_local(&entry_path, &remote_file) - .await - .map_err(|err| anyhow::anyhow!("{err}"))?; + upload_sandbox_file(client, run_id, &entry_path, &remote_file).await?; file_count += 1; } } @@ -241,9 +248,11 @@ mod tests { } #[test] - fn split_run_path_ignores_local_paths() { - assert_eq!(split_run_path("/tmp/file"), None); - assert_eq!(split_run_path("./file"), None); - assert_eq!(split_run_path("../file"), None); + fn parse_direction_rejects_sandbox_to_sandbox_copy() { + let err = parse_direction("abc123:/in.txt", "def456:/out.txt").unwrap_err(); + assert!( + err.to_string() + .contains("Cannot copy between two sandboxes") + ); } } diff --git a/lib/crates/fabro-cli/src/commands/run/diff.rs b/lib/crates/fabro-cli/src/commands/run/diff.rs index 35deafc6d..6deb27834 100644 --- a/lib/crates/fabro-cli/src/commands/run/diff.rs +++ b/lib/crates/fabro-cli/src/commands/run/diff.rs @@ -1,39 +1,28 @@ use std::io::{self, IsTerminal, Write}; -use std::path::Path; use anyhow::{Context, Result, bail}; -use fabro_sandbox::reconnect::reconnect; -use fabro_workflow::sandbox_git::GIT_REMOTE; use tracing::{debug, info}; use crate::args::{DiffArgs, GlobalArgs}; use crate::server_client::RunProjection; -use crate::server_runs::ServerRunLookup; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> { info!(run_id = %args.run, "Showing diff"); - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; + let lookup = ServerSummaryLookup::connect(&args.server).await?; let run = lookup.resolve(&args.run)?; let run_id = run.run_id(); let state = lookup.client().get_run_state(&run_id).await?; - let patch = resolve_diff(&run.path, &state, &args).await?; + let patch = resolve_diff(&state, &args)?; if globals.json { - let mut value = serde_json::json!({ + let value = serde_json::json!({ "run_id": run_id, "node": args.node, + "diff": patch, }); - if args.shortstat { - value["shortstat"] = patch.trim_end().into(); - } else if args.stat { - value["stat"] = patch.trim_end().into(); - } else { - value["diff"] = patch.into(); - } print_json_pretty(&value)?; return Ok(()); } @@ -50,7 +39,7 @@ pub(crate) async fn run(args: DiffArgs, globals: &GlobalArgs) -> Result<()> { Ok(()) } -async fn resolve_diff(_run_dir: &Path, state: &RunProjection, args: &DiffArgs) -> Result { +fn resolve_diff(state: &RunProjection, args: &DiffArgs) -> Result { if let Some(ref node_id) = args.node { if let Some(visit) = state.list_node_visits(node_id).into_iter().max() { if let Some(node) = state.node(&fabro_store::StageId::new(node_id, visit)) { @@ -79,51 +68,15 @@ async fn resolve_diff(_run_dir: &Path, state: &RunProjection, args: &DiffArgs) - return Ok(patch); } - let run_concluded = state.conclusion.is_some(); - if run_concluded { + if state.conclusion.is_some() { bail!( "Run completed but no final.patch exists — the run may not have produced any changes" ); } - debug!("No final.patch found; attempting live diff from sandbox"); - let record = state - .sandbox - .clone() - .context("Failed to load sandbox record from store")?; - - info!(provider = %record.provider, "Reconnecting to sandbox for live diff"); - let sandbox = reconnect(&record).await?; - - let cmd = build_live_diff_cmd(base_sha, args.stat, args.shortstat); - debug!(cmd, "Running git diff in sandbox"); - - let result = sandbox - .exec_command(&cmd, 30_000, None, None, None) - .await - .map_err(|e| anyhow::anyhow!("Failed to run git diff in sandbox: {e}"))?; - - if result.exit_code != 0 { - let stderr = result.stderr.trim(); - bail!("git diff failed (exit {}):\n{stderr}", result.exit_code); - } - - Ok(result.stdout) -} - -fn build_live_diff_cmd(base_sha: &str, stat: bool, shortstat: bool) -> String { - let mut flags = String::new(); - if stat { - flags.push_str(" --stat"); - } - if shortstat { - flags.push_str(" --shortstat"); - } - let quoted_sha = shlex::try_quote(base_sha).map_or_else( - |_| format!("'{}'", base_sha.replace('\'', "'\\''")), - |q| q.to_string(), - ); - format!("{GIT_REMOTE} add -N . && {GIT_REMOTE} diff{flags} {quoted_sha}") + bail!( + "Run is missing stored diff output since base commit {base_sha}; live sandbox diff is no longer supported" + ) } fn colorize_diff_line(line: &str) -> String { diff --git a/lib/crates/fabro-cli/src/commands/run/fork.rs b/lib/crates/fabro-cli/src/commands/run/fork.rs index ca09da575..4be007b5e 100644 --- a/lib/crates/fabro-cli/src/commands/run/fork.rs +++ b/lib/crates/fabro-cli/src/commands/run/fork.rs @@ -7,16 +7,18 @@ use git2::Repository; use crate::args::{ForkArgs, GlobalArgs}; use crate::commands::store::rebuild::rebuild_run_store; -use crate::server_runs::ServerRunLookup; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; +use crate::shared::repo::ensure_matching_repo_origin; pub(crate) async fn run(args: &ForkArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; + let lookup = ServerSummaryLookup::connect(&args.server).await?; let run = lookup.resolve(&args.run_id)?; let run_id = run.run_id(); + let state = lookup.client().get_run_state(&run_id).await?; + let record = state.run.context("Failed to load run record from store")?; + ensure_matching_repo_origin(record.repo_origin_url.as_deref(), "fork")?; let store = Store::new(repo); let events = lookup.client().list_run_events(&run_id, None, None).await?; let run_store = rebuild_run_store(&run_id, &events).await?; diff --git a/lib/crates/fabro-cli/src/commands/run/mod.rs b/lib/crates/fabro-cli/src/commands/run/mod.rs index c65f5d7b7..c8f8d725e 100644 --- a/lib/crates/fabro-cli/src/commands/run/mod.rs +++ b/lib/crates/fabro-cli/src/commands/run/mod.rs @@ -90,9 +90,7 @@ pub(crate) async fn dispatch(cmd: RunCommands, globals: &GlobalArgs) -> Result<( let styles: &'static Styles = Box::leak(Box::new(Styles::detect_stderr())); #[cfg(feature = "sleep_inhibitor")] let _sleep_guard = { - let cli_settings = crate::user_config::load_settings_with_storage_dir( - args.storage_dir.as_deref(), - )?; + let cli_settings = crate::user_config::load_settings()?; crate::sleep_inhibitor::guard(cli_settings.prevent_idle_sleep_enabled()) }; resume::resume_command(args, styles, globals).await diff --git a/lib/crates/fabro-cli/src/commands/run/output.rs b/lib/crates/fabro-cli/src/commands/run/output.rs index 623be7cf3..50f24b632 100644 --- a/lib/crates/fabro-cli/src/commands/run/output.rs +++ b/lib/crates/fabro-cli/src/commands/run/output.rs @@ -122,20 +122,6 @@ pub(crate) fn api_check_report_to_local(report: &types::PreflightCheckReport) -> } } -pub(crate) async fn print_run_summary( - storage_dir: &Path, - run_dir: &Path, - run_id: impl std::fmt::Display, - styles: &Styles, -) -> Result<()> { - let run_id = run_id - .to_string() - .parse() - .map_err(|err| anyhow::anyhow!("invalid run ID: {err}"))?; - let client = server_client::connect_server(storage_dir).await?; - print_run_summary_with_client(&client, &run_id, Some(run_dir), styles).await -} - pub(crate) async fn print_run_summary_with_client( client: &server_client::ServerStoreClient, run_id: &fabro_types::RunId, diff --git a/lib/crates/fabro-cli/src/commands/run/preview.rs b/lib/crates/fabro-cli/src/commands/run/preview.rs index bc4b7b23e..d6460eb07 100644 --- a/lib/crates/fabro-cli/src/commands/run/preview.rs +++ b/lib/crates/fabro-cli/src/commands/run/preview.rs @@ -1,66 +1,48 @@ use anyhow::{Context, Result}; -use fabro_sandbox::daytona::DaytonaSandbox; use tracing::info; use crate::args::{GlobalArgs, PreviewArgs}; -use crate::server_runs::ServerRunLookup; -use crate::shared::{print_json_pretty, validate_daytona_provider}; -use crate::user_config::load_settings_with_storage_dir; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::print_json_pretty; pub(crate) async fn run(args: PreviewArgs, globals: &GlobalArgs) -> Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; + let lookup = ServerSummaryLookup::connect(&args.server).await?; let run = lookup.resolve(&args.run)?; - let record = lookup + let run_id = run.run_id(); + let expires_in_secs = + u64::try_from(args.ttl).map_err(|_| anyhow::anyhow!("--ttl must be positive"))?; + let response = lookup .client() - .get_run_state(&run.run_id()) - .await? - .sandbox - .context("Failed to load sandbox record from store")?; + .generate_preview_url( + &run_id, + args.port, + expires_in_secs, + args.signed || args.open, + ) + .await?; - validate_daytona_provider(&record, "Preview URLs")?; + info!(run_id = %args.run, port = args.port, "Generating preview URL"); - let name = record - .identifier - .as_deref() - .context("Daytona sandbox record missing identifier (sandbox name)")?; - - info!(run_id = %args.run, provider = %record.provider, port = args.port, "Generating preview URL"); - - let daytona = DaytonaSandbox::reconnect(name) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - if args.signed || args.open { - let signed = daytona - .get_signed_preview_url(args.port, Some(args.ttl)) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - if globals.json { - print_json_pretty(&serde_json::json!({ "url": signed.url }))?; - } else { - print!("{}", format_signed_output(&signed.url)); - } - - if args.open && !globals.json { - std::process::Command::new("open") - .arg(&signed.url) - .spawn() - .context("Failed to open browser")?; + if globals.json { + match response.token { + Some(token) => { + print_json_pretty(&serde_json::json!({ "url": response.url, "token": token }))?; + } + None => { + print_json_pretty(&serde_json::json!({ "url": response.url }))?; + } } + } else if let Some(token) = response.token.as_deref() { + print!("{}", format_standard_output(&response.url, token)); } else { - let preview = daytona - .get_preview_link(args.port) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - if globals.json { - print_json_pretty(&serde_json::json!({ - "url": preview.url, - "token": preview.token, - }))?; - } else { - print!("{}", format_standard_output(&preview.url, &preview.token)); - } + print!("{}", format_signed_output(&response.url)); + } + + if args.open && !globals.json { + std::process::Command::new("open") + .arg(&response.url) + .spawn() + .context("Failed to open browser")?; } Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/run/resume.rs b/lib/crates/fabro-cli/src/commands/run/resume.rs index d7ef30351..da2f8b4e9 100644 --- a/lib/crates/fabro-cli/src/commands/run/resume.rs +++ b/lib/crates/fabro-cli/src/commands/run/resume.rs @@ -1,9 +1,8 @@ use fabro_util::terminal::Styles; use crate::args::{GlobalArgs, ResumeArgs}; -use crate::server_runs::ServerRunLookup; +use crate::server_runs::ServerSummaryLookup; use crate::shared::print_json_pretty; -use crate::user_config::load_settings_with_storage_dir; /// Resume an interrupted workflow run. /// @@ -15,13 +14,11 @@ pub(crate) async fn resume_command( styles: &'static Styles, globals: &GlobalArgs, ) -> anyhow::Result<()> { - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; + let lookup = ServerSummaryLookup::connect(&args.server).await?; let run = lookup.resolve(&args.run)?; let run_id = run.run_id(); - let run_dir = run.path; - super::start::start_run(&run_id, &cli_settings.storage_dir(), true).await?; + super::start::start_run_with_client(lookup.client(), &run_id, true).await?; if args.detach { if globals.json { @@ -30,23 +27,17 @@ pub(crate) async fn resume_command( println!("{run_id}"); } } else { - let exit_code = super::attach::attach_run( - &run_dir, - Some(cli_settings.storage_dir().as_path()), - Some(&run_id), + let exit_code = super::attach::attach_run_with_client( + lookup.client(), + &run_id, true, styles, globals.json, ) .await?; if !globals.json { - super::output::print_run_summary( - cli_settings.storage_dir().as_path(), - &run_dir, - run_id, - styles, - ) - .await?; + super::output::print_run_summary_with_client(lookup.client(), &run_id, None, styles) + .await?; } if exit_code != std::process::ExitCode::SUCCESS { std::process::exit(1); diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index 558c34d2a..ecd9f7e05 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -16,9 +16,9 @@ use serde::Serialize; use crate::args::{GlobalArgs, RewindArgs}; use crate::commands::store::rebuild::rebuild_run_store; use crate::server_client::ServerStoreClient; -use crate::server_runs::ServerRunLookup; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::repo::ensure_matching_repo_origin; use crate::shared::{color_if, print_json_pretty}; -use crate::user_config::load_settings_with_storage_dir; #[derive(Serialize)] pub(crate) struct TimelineEntryJson { @@ -30,10 +30,12 @@ pub(crate) struct TimelineEntryJson { pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs) -> Result<()> { let repo = Repository::discover(".").context("not in a git repository")?; - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; + let lookup = ServerSummaryLookup::connect(&args.server).await?; let run = lookup.resolve(&args.run_id)?; let run_id = run.run_id(); + let state = lookup.client().get_run_state(&run_id).await?; + let record = state.run.context("Failed to load run record from store")?; + ensure_matching_repo_origin(record.repo_origin_url.as_deref(), "rewind")?; let store = Store::new(repo); let events = lookup.client().list_run_events(&run_id, None, None).await?; let run_store = rebuild_run_store(&run_id, &events).await?; @@ -60,7 +62,7 @@ pub(crate) async fn run(args: &RewindArgs, styles: &Styles, globals: &GlobalArgs }, )?; let entry = timeline.resolve(&target)?; - reset_rewound_run_state(lookup.client(), &store, &run_id, &run.path, entry).await?; + reset_rewound_run_state(lookup.client(), &store, &run_id, entry).await?; let run_id_string = run_id.to_string(); @@ -96,7 +98,6 @@ async fn reset_rewound_run_state( client: &ServerStoreClient, git_store: &Store, run_id: &fabro_types::RunId, - run_dir: &std::path::Path, entry: &TimelineEntry, ) -> Result<()> { let state = client.get_run_state(run_id).await.map_err(|err| { @@ -110,8 +111,6 @@ async fn reset_rewound_run_state( .context("rewound metadata branch is missing checkpoint.json")?; let previous_status = state.status.map(|status| status.status.to_string()); - let _ = std::fs::remove_file(run_dir.join("detached_failure.json")); - client .append_run_event( run_id, diff --git a/lib/crates/fabro-cli/src/commands/run/ssh.rs b/lib/crates/fabro-cli/src/commands/run/ssh.rs index ae4547aed..f531cc7b8 100644 --- a/lib/crates/fabro-cli/src/commands/run/ssh.rs +++ b/lib/crates/fabro-cli/src/commands/run/ssh.rs @@ -1,54 +1,33 @@ -use anyhow::{Context, Result, bail}; -use fabro_sandbox::daytona::DaytonaSandbox; +use anyhow::{Result, bail}; use tracing::info; use crate::args::{GlobalArgs, SshArgs}; -use crate::server_runs::ServerRunLookup; -use crate::shared::{print_json_pretty, validate_daytona_provider}; -use crate::user_config::load_settings_with_storage_dir; +use crate::server_runs::ServerSummaryLookup; +use crate::shared::print_json_pretty; pub(crate) async fn run(args: SshArgs, globals: &GlobalArgs) -> Result<()> { if globals.json && !args.print { globals.require_no_json()?; } - let cli_settings = load_settings_with_storage_dir(args.storage_dir.as_deref())?; - let lookup = ServerRunLookup::connect(&cli_settings.storage_dir()).await?; + let lookup = ServerSummaryLookup::connect(&args.server).await?; let run = lookup.resolve(&args.run)?; let run_id = run.run_id(); - let record = lookup + let ssh = lookup .client() - .get_run_state(&run_id) - .await? - .sandbox - .context("Failed to load sandbox record from store")?; - - validate_daytona_provider(&record, "SSH access")?; - - let name = record - .identifier - .as_deref() - .context("Daytona sandbox record missing identifier (sandbox name)")?; + .create_run_ssh_access(&run_id, args.ttl) + .await?; info!(run_id = %args.run, ttl_minutes = args.ttl, "Creating SSH access"); - let daytona = DaytonaSandbox::reconnect(name) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let ssh_cmd = daytona - .create_ssh_access(Some(args.ttl)) - .await - .map_err(|e| anyhow::anyhow!("{e}"))?; - if args.print { if globals.json { - print_json_pretty(&serde_json::json!({ "command": ssh_cmd }))?; + print_json_pretty(&serde_json::json!({ "command": ssh.command }))?; } else { - print!("{}", format_output(&ssh_cmd)); + print!("{}", format_output(&ssh.command)); } } else { - exec_ssh(&ssh_cmd)?; + exec_ssh(&ssh.command)?; } Ok(()) @@ -64,7 +43,7 @@ fn exec_ssh(ssh_cmd: &str) -> Result<()> { let parts: Vec<&str> = ssh_cmd.split_whitespace().collect(); if parts.is_empty() { - bail!("Empty SSH command returned from Daytona"); + bail!("Empty SSH command returned from server"); } let err = std::process::Command::new(parts[0]) .args(&parts[1..]) @@ -74,5 +53,5 @@ fn exec_ssh(ssh_cmd: &str) -> Result<()> { #[cfg(not(unix))] fn exec_ssh(_ssh_cmd: &str) -> Result<()> { - bail!("Direct SSH connection is only supported on Unix systems; use --print instead"); + bail!("Direct SSH connection is only supported on Unix systems; use --print instead") } diff --git a/lib/crates/fabro-cli/src/commands/run/start.rs b/lib/crates/fabro-cli/src/commands/run/start.rs index 6931c0d14..1b8e7c46b 100644 --- a/lib/crates/fabro-cli/src/commands/run/start.rs +++ b/lib/crates/fabro-cli/src/commands/run/start.rs @@ -1,16 +1,8 @@ -use std::path::Path; - use anyhow::Result; use fabro_types::RunId; use crate::server_client; -/// Queue a run for server-owned execution. -pub(crate) async fn start_run(run_id: &RunId, storage_dir: &Path, resume: bool) -> Result<()> { - let client = server_client::connect_server(storage_dir).await?; - start_run_with_client(&client, run_id, resume).await -} - pub(crate) async fn start_run_with_client( client: &server_client::ServerStoreClient, run_id: &RunId, diff --git a/lib/crates/fabro-cli/src/commands/store/dump.rs b/lib/crates/fabro-cli/src/commands/store/dump.rs index 9ae07d6bc..07e99aa53 100644 --- a/lib/crates/fabro-cli/src/commands/store/dump.rs +++ b/lib/crates/fabro-cli/src/commands/store/dump.rs @@ -176,6 +176,7 @@ mod tests { workflow_slug: Some("night-sky".to_string()), working_directory: PathBuf::from("/tmp/night-sky"), host_repo_path: Some("github.com/fabro-sh/fabro".to_string()), + repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()), base_branch: Some("main".to_string()), labels: HashMap::from([("team".to_string(), "infra".to_string())]), } @@ -306,6 +307,7 @@ mod tests { run_dir: "/tmp/night-sky-run".to_string(), working_directory: run_record.working_directory.display().to_string(), host_repo_path: run_record.host_repo_path.clone(), + repo_origin_url: run_record.repo_origin_url.clone(), base_branch: run_record.base_branch.clone(), workflow_slug: run_record.workflow_slug.clone(), db_prefix: None, @@ -657,6 +659,7 @@ mod tests { run_dir: "/tmp/night-sky-run".to_string(), working_directory: run_record.working_directory.display().to_string(), host_repo_path: run_record.host_repo_path.clone(), + repo_origin_url: run_record.repo_origin_url.clone(), base_branch: run_record.base_branch.clone(), workflow_slug: run_record.workflow_slug.clone(), db_prefix: None, diff --git a/lib/crates/fabro-cli/src/manifest_builder.rs b/lib/crates/fabro-cli/src/manifest_builder.rs index 05a5df1d9..c34b0ec31 100644 --- a/lib/crates/fabro-cli/src/manifest_builder.rs +++ b/lib/crates/fabro-cli/src/manifest_builder.rs @@ -467,10 +467,7 @@ fn build_manifest_git(cwd: &Path) -> Option { } fn sanitize_origin_url(origin_url: &str) -> String { - if let Some(at_pos) = origin_url.find('@') { - return format!("https://***@{}", &origin_url[at_pos + 1..]); - } - origin_url.to_string() + fabro_github::normalize_repo_origin_url(origin_url) } fn normalize_absolute_path(base_dir: &Path, reference: &str) -> Option { diff --git a/lib/crates/fabro-cli/src/server_client.rs b/lib/crates/fabro-cli/src/server_client.rs index 40c802c8d..625be569b 100644 --- a/lib/crates/fabro-cli/src/server_client.rs +++ b/lib/crates/fabro-cli/src/server_client.rs @@ -19,6 +19,7 @@ use crate::args::{ServerConnectionArgs, ServerTargetArgs}; use crate::commands::server::start; use crate::user_config; +#[derive(Clone)] pub(crate) struct ServerStoreClient { client: fabro_api::Client, } @@ -210,6 +211,10 @@ async fn wait_for_server_ready(http_client: &reqwest::Client) -> Result<()> { } impl ServerStoreClient { + pub(crate) fn clone_for_reuse(&self) -> Self { + self.clone() + } + pub(crate) async fn create_run_from_manifest( &self, manifest: types::RunManifest, @@ -389,6 +394,137 @@ impl ServerStoreClient { .map_err(map_api_error)?; Ok(()) } + + pub(crate) async fn list_run_artifacts( + &self, + run_id: &RunId, + ) -> Result> { + let response = self + .client + .list_run_artifacts() + .id(run_id.to_string()) + .send() + .await + .map_err(map_api_error)?; + Ok(response.into_inner().data) + } + + pub(crate) async fn download_stage_artifact( + &self, + run_id: &RunId, + stage_id: &StageId, + filename: &str, + ) -> Result> { + let response = self + .client + .get_stage_artifact() + .id(run_id.to_string()) + .stage_id(stage_id.to_string()) + .filename(filename) + .send() + .await + .map_err(map_api_error)?; + let mut stream = response.into_inner(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("{err}"))?; + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + + pub(crate) async fn generate_preview_url( + &self, + run_id: &RunId, + port: u16, + expires_in_secs: u64, + signed: bool, + ) -> Result { + let expires_in_secs = NonZeroU64::new(expires_in_secs) + .ok_or_else(|| anyhow!("preview expiry must be greater than zero"))?; + let response = self + .client + .generate_preview_url() + .id(run_id.to_string()) + .body(types::PreviewUrlRequest { + expires_in_secs, + port: i64::from(port), + signed, + }) + .send() + .await + .map_err(map_api_error)?; + Ok(response.into_inner()) + } + + pub(crate) async fn create_run_ssh_access( + &self, + run_id: &RunId, + ttl_minutes: f64, + ) -> Result { + let response = self + .client + .create_run_ssh_access() + .id(run_id.to_string()) + .body(types::SshAccessRequest { ttl_minutes }) + .send() + .await + .map_err(map_api_error)?; + Ok(response.into_inner()) + } + + pub(crate) async fn list_sandbox_files( + &self, + run_id: &RunId, + path: &str, + depth: Option, + ) -> Result> { + let mut request = self + .client + .list_sandbox_files() + .id(run_id.to_string()) + .path(path); + if let Some(depth) = depth.and_then(non_zero_u64_from_u32) { + request = request.depth(depth); + } + let response = request.send().await.map_err(map_api_error)?; + Ok(response.into_inner().data) + } + + pub(crate) async fn get_sandbox_file(&self, run_id: &RunId, path: &str) -> Result> { + let response = self + .client + .get_sandbox_file() + .id(run_id.to_string()) + .path(path) + .send() + .await + .map_err(map_api_error)?; + let mut stream = response.into_inner(); + let mut bytes = Vec::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|err| anyhow!("{err}"))?; + bytes.extend_from_slice(&chunk); + } + Ok(bytes) + } + + pub(crate) async fn put_sandbox_file( + &self, + run_id: &RunId, + path: &str, + bytes: Vec, + ) -> Result<()> { + self.client + .put_sandbox_file() + .id(run_id.to_string()) + .path(path) + .body(bytes) + .send() + .await + .map_err(map_api_error)?; + Ok(()) + } } fn map_api_error(err: progenitor_client::Error) -> anyhow::Error diff --git a/lib/crates/fabro-cli/src/shared/mod.rs b/lib/crates/fabro-cli/src/shared/mod.rs index 4aca2e627..89c85fde6 100644 --- a/lib/crates/fabro-cli/src/shared/mod.rs +++ b/lib/crates/fabro-cli/src/shared/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod github; pub(crate) mod openai_jwt; pub(crate) mod provider_auth; +pub(crate) mod repo; mod utilities; pub(crate) use utilities::*; diff --git a/lib/crates/fabro-cli/src/shared/repo.rs b/lib/crates/fabro-cli/src/shared/repo.rs new file mode 100644 index 000000000..44dd0ad99 --- /dev/null +++ b/lib/crates/fabro-cli/src/shared/repo.rs @@ -0,0 +1,37 @@ +use anyhow::{Result, bail}; +use fabro_sandbox::daytona::detect_repo_info; + +pub(crate) fn ensure_matching_repo_origin( + expected_origin_url: Option<&str>, + action: &str, +) -> Result<()> { + let Some(expected_origin_url) = expected_origin_url else { + return Ok(()); + }; + + let cwd = std::env::current_dir()?; + let (origin_url, _) = detect_repo_info(&cwd).map_err(|_| { + anyhow::anyhow!( + "Current directory is not a git repository with an origin remote; refusing to {action} run from repository '{expected_origin_url}'" + ) + })?; + let current_origin_url = fabro_github::normalize_repo_origin_url(&origin_url); + + if current_origin_url != expected_origin_url { + bail!( + "Current repository origin '{current_origin_url}' does not match run repository '{expected_origin_url}'; refusing to {action} this run from the wrong checkout" + ); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::ensure_matching_repo_origin; + + #[test] + fn missing_expected_origin_skips_guard() { + ensure_matching_repo_origin(None, "fork").unwrap(); + } +} diff --git a/lib/crates/fabro-cli/src/shared/utilities.rs b/lib/crates/fabro-cli/src/shared/utilities.rs index 029f95b5f..b37ac5040 100644 --- a/lib/crates/fabro-cli/src/shared/utilities.rs +++ b/lib/crates/fabro-cli/src/shared/utilities.rs @@ -2,7 +2,6 @@ use std::path::Path; use std::time::Duration; use std::{io::Write, path::PathBuf}; -use anyhow::{Result, bail}; use cli_table::Color; use fabro_util::terminal::Styles; use fabro_validate::{Diagnostic, Severity}; @@ -103,19 +102,6 @@ pub(crate) fn split_run_path(s: &str) -> Option<(&str, &str)> { s.split_once(':') } -pub(crate) fn validate_daytona_provider( - record: &fabro_sandbox::SandboxRecord, - feature: &str, -) -> Result<()> { - if record.provider != "daytona" { - bail!( - "{feature} is only supported for Daytona sandboxes (this run uses '{}')", - record.provider - ); - } - Ok(()) -} - pub(crate) fn format_duration_ms(ms: u64) -> String { let duration = Duration::from_millis(ms); let secs = duration.as_secs(); diff --git a/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs index a163f798e..40536d2b3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/artifact_cp.rs @@ -20,16 +20,16 @@ fn help() { [DEST] Destination directory (defaults to current directory) [default: .] Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --node Filter to artifacts from a specific node - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --retry Filter to artifacts from a specific retry attempt - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --tree Preserve {node_slug}/retry_{N}/ directory structure - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --node Filter to artifacts from a specific node + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --retry Filter to artifacts from a specific retry attempt + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --tree Preserve {node_slug}/retry_{N}/ directory structure + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs b/lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs index 4c50e5b53..ddee6fbb2 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/artifact_list.rs @@ -19,15 +19,15 @@ fn help() { Run ID (or prefix) Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --node Filter to artifacts from a specific node - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --retry Filter to artifacts from a specific retry attempt - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --node Filter to artifacts from a specific node + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --retry Filter to artifacts from a specific retry attempt + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/diff.rs b/lib/crates/fabro-cli/tests/it/cmd/diff.rs index 2775a33b0..d17697267 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/diff.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/diff.rs @@ -19,16 +19,14 @@ fn help() { Run ID or prefix Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --node Show diff for a specific node - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --stat Show diffstat instead of full patch (live diffs only) - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --shortstat Show only files-changed/insertions/deletions summary (live diffs only) - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --node Show diff for a specific node + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/fork.rs b/lib/crates/fabro-cli/tests/it/cmd/fork.rs index 3a8f44c54..c11080b59 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/fork.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/fork.rs @@ -25,15 +25,15 @@ fn help() { [TARGET] Target checkpoint: node name, node@visit, or @ordinal (omit to fork from latest) Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --list Show the checkpoint timeline instead of forking - --no-push Skip pushing new branches to the remote - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --list Show the checkpoint timeline instead of forking + --no-push Skip pushing new branches to the remote + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_close.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_close.rs index a0a5ff24c..5850ef931 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_close.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_close.rs @@ -17,13 +17,13 @@ fn help() { Run ID or prefix Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs index 8fec4e0e3..bc5030db3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_create.rs @@ -19,14 +19,14 @@ fn help() { Run ID or prefix Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --model LLM model for generating PR description - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --model LLM model for generating PR description + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_list.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_list.rs index fa031a6ca..784976ce4 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_list.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_list.rs @@ -14,14 +14,14 @@ fn help() { Usage: fabro pr list [OPTIONS] Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --all Show all PRs (including closed/merged), not just open - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --all Show all PRs (including closed/merged), not just open + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs index 7f5a5ffc5..2d6344249 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_merge.rs @@ -17,14 +17,14 @@ fn help() { Run ID or prefix Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --method Merge method: merge, squash, or rebase [default: squash] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --method Merge method: merge, squash, or rebase [default: squash] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs index e8bee7e48..7ef07e083 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/pr_view.rs @@ -23,13 +23,13 @@ fn help() { Run ID or prefix Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/resume.rs b/lib/crates/fabro-cli/tests/it/cmd/resume.rs index 01a5d7969..d643eaab3 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/resume.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/resume.rs @@ -21,14 +21,14 @@ fn help() { Run ID or unambiguous prefix Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - -d, --detach Run in the background and print the run ID - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + -d, --detach Run in the background and print the run ID + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -46,7 +46,7 @@ fn resume_requires_run_arg() { error: the following required arguments were not provided: - Usage: fabro resume --storage-dir --no-upgrade-check + Usage: fabro resume --no-upgrade-check For more information, try '--help'. "); diff --git a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs index c0fce603c..67a51207c 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/rewind.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/rewind.rs @@ -25,15 +25,15 @@ fn help() { [TARGET] Target checkpoint: node name, node@visit, or @ordinal (omit with --list) Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --list Show the checkpoint timeline instead of rewinding - --no-push Skip force-pushing rewound refs to the remote - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --list Show the checkpoint timeline instead of rewinding + --no-push Skip force-pushing rewound refs to the remote + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs index af4446ae5..197caa4eb 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_cp.rs @@ -20,14 +20,14 @@ fn help() { Destination: : or local path Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - -r, --recursive Recurse into directories - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + -r, --recursive Recurse into directories + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -45,7 +45,7 @@ fn sandbox_cp_run_without_sandbox_json_errors_cleanly() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Failed to load sandbox record from store + error: Run has no active sandbox. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs index c56e9548e..6b86ca10f 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_preview.rs @@ -20,16 +20,16 @@ fn help() { Port number Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --signed Generate a signed URL (embeds auth token, no headers needed) - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --ttl Signed URL expiry in seconds (default 3600, requires --signed) [default: 3600] - --open Open URL in browser (implies --signed) - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --signed Generate a signed URL (embeds auth token, no headers needed) + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --ttl Signed URL expiry in seconds (default 3600, requires --signed) [default: 3600] + --open Open URL in browser (implies --signed) + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -46,6 +46,6 @@ fn sandbox_preview_rejects_non_daytona_run() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: Preview URLs is only supported for Daytona sandboxes (this run uses 'local') + error: Sandbox provider does not support this capability. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs b/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs index bf8890bc4..cbf3a10b6 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/sandbox_ssh.rs @@ -19,15 +19,15 @@ fn help() { Run ID or prefix Options: - --json Output as JSON [env: FABRO_JSON=] - --storage-dir Local storage directory (default: ~/.fabro) [env: FABRO_STORAGE_DIR=[STORAGE_DIR]] - --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] - --ttl SSH access expiry in minutes (default 60) [default: 60] - --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] - --print Print the SSH command instead of connecting - --quiet Suppress non-essential output [env: FABRO_QUIET=] - --verbose Enable verbose output [env: FABRO_VERBOSE=] - -h, --help Print help + --json Output as JSON [env: FABRO_JSON=] + --server Fabro server target: http(s) URL or absolute Unix socket path [env: FABRO_SERVER=] + --debug Enable DEBUG-level logging (default is INFO) [env: FABRO_DEBUG=] + --ttl SSH access expiry in minutes (default 60) [default: 60] + --no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true] + --print Print the SSH command instead of connecting + --quiet Suppress non-essential output [env: FABRO_QUIET=] + --verbose Enable verbose output [env: FABRO_VERBOSE=] + -h, --help Print help ----- stderr ----- "); } @@ -44,7 +44,7 @@ fn sandbox_ssh_rejects_non_daytona_run() { exit_code: 1 ----- stdout ----- ----- stderr ----- - error: SSH access is only supported for Daytona sandboxes (this run uses 'local') + error: Sandbox provider does not support this capability. "); } diff --git a/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs b/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs index 0534eb3d4..70f93538a 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/system_prune.rs @@ -57,7 +57,7 @@ fn system_prune_dry_run_lists_matching_runs_without_deleting() { success: true exit_code: 0 ----- stdout ----- - would delete: 20260405-[ULID] (Simple) + would delete: 20260406-[ULID] (Simple) ----- stderr ----- 1 run(s) would be deleted ([SIZE] freed). Pass --yes to confirm. diff --git a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs b/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs index adecd4c4d..a4d5276d9 100644 --- a/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs +++ b/lib/crates/fabro-cli/tests/it/scenario/artifacts.rs @@ -30,42 +30,36 @@ fn artifact_commands_share_populated_run_fixture() { "node_slug": "create_assets", "retry": 1, "relative_path": "assets/node_a/summary.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/files/create_assets/retry_1/assets/node_a/summary.txt", "size": 5 }, { "node_slug": "create_assets", "retry": 1, "relative_path": "assets/shared/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/files/create_assets/retry_1/assets/shared/report.txt", "size": 3 }, { "node_slug": "create_colliding", "retry": 1, "relative_path": "assets/other/summary.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/files/create_colliding/retry_1/assets/other/summary.txt", "size": 4 }, { "node_slug": "create_colliding", "retry": 1, "relative_path": "assets/retry/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/files/create_colliding/retry_1/assets/retry/report.txt", "size": 6 }, { "node_slug": "retry_assets", "retry": 1, "relative_path": "assets/retry/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_1/assets/retry/report.txt", "size": 5 }, { "node_slug": "retry_assets", "retry": 2, "relative_path": "assets/retry/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_2/assets/retry/report.txt", "size": 6 } ] @@ -92,7 +86,6 @@ fn artifact_commands_share_populated_run_fixture() { "node_slug": "retry_assets", "retry": 2, "relative_path": "assets/retry/report.txt", - "absolute_path": "[RUN_DIR]/cache/artifacts/files/retry_assets/retry_2/assets/retry/report.txt", "size": 6 } ] diff --git a/lib/crates/fabro-github/src/lib.rs b/lib/crates/fabro-github/src/lib.rs index 70e36e9c2..f5cd1db80 100644 --- a/lib/crates/fabro-github/src/lib.rs +++ b/lib/crates/fabro-github/src/lib.rs @@ -579,6 +579,41 @@ pub fn ssh_url_to_https(url: &str) -> String { url.to_string() } +pub fn normalize_repo_origin_url(url: &str) -> String { + let https = ssh_url_to_https(url.trim()); + let without_credentials = strip_https_credentials(&https); + let normalized = normalize_https_host_path(&without_credentials); + let normalized = normalized.trim_end_matches('/'); + normalized + .strip_suffix(".git") + .unwrap_or(normalized) + .to_string() +} + +fn strip_https_credentials(url: &str) -> String { + let Some(rest) = url.strip_prefix("https://") else { + return url.to_string(); + }; + + match rest.split_once('@') { + Some((before, after)) if !before.contains('/') => format!("https://{after}"), + _ => url.to_string(), + } +} + +fn normalize_https_host_path(url: &str) -> String { + let Some(rest) = url.strip_prefix("https://") else { + return url.to_string(); + }; + + match rest.split_once(':') { + Some((host, path)) if !host.contains('/') && !path.starts_with('/') => { + format!("https://{host}/{path}") + } + _ => url.to_string(), + } +} + /// Check whether a branch exists in a GitHub repository. /// /// Uses a GitHub App installation token to query the branches API. @@ -1058,6 +1093,30 @@ mod tests { ); } + #[test] + fn normalize_repo_origin_url_converts_ssh_and_trims_git_suffix() { + assert_eq!( + normalize_repo_origin_url("git@github.com:brynary/arc.git"), + "https://github.com/brynary/arc" + ); + } + + #[test] + fn normalize_repo_origin_url_strips_credentials_and_trailing_slash() { + assert_eq!( + normalize_repo_origin_url("https://token@github.com/acme/widgets.git/"), + "https://github.com/acme/widgets" + ); + } + + #[test] + fn normalize_repo_origin_url_handles_sanitized_git_at_shape() { + assert_eq!( + normalize_repo_origin_url("https://***@github.com:acme/widgets.git"), + "https://github.com/acme/widgets" + ); + } + #[test] fn parse_github_url_with_credentials() { let (owner, repo) = parse_github_owner_repo( diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index 3d9f9f6bb..8df204c20 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -63,6 +63,7 @@ hex.workspace = true reqwest.workspace = true rand.workspace = true bytes = "1" +tempfile = "3" object_store.workspace = true mime_guess.workspace = true rust-embed.workspace = true @@ -73,7 +74,6 @@ semver.workspace = true tokio = { workspace = true, features = ["test-util", "macros"] } tower = "0.5" http-body-util = "0.1" -tempfile = "3" openapiv3 = "2" serde_yaml = "0.9" fabro-sandbox = { path = "../fabro-sandbox" } diff --git a/lib/crates/fabro-server/src/demo/mod.rs b/lib/crates/fabro-server/src/demo/mod.rs index 8dca9f722..599e6bc45 100644 --- a/lib/crates/fabro-server/src/demo/mod.rs +++ b/lib/crates/fabro-server/src/demo/mod.rs @@ -8,7 +8,9 @@ use axum::Json; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use fabro_api::types::{RunStatus, RunStatusResponse, SessionTurn, SmoothnessRating}; +use fabro_api::types::{ + RunArtifactListResponse, RunStatus, RunStatusResponse, SessionTurn, SmoothnessRating, +}; use serde_json::json; use crate::error::ApiError; @@ -94,13 +96,16 @@ pub(crate) async fn get_stage_turns( paginated_response(runs::turns(), &pagination) } -pub(crate) async fn get_run_files( +pub(crate) async fn list_run_artifacts_stub( _auth: AuthenticatedService, State(_state): State>, Path(_id): Path, - Query(pagination): Query, ) -> Response { - paginated_response(runs::files(), &pagination) + ( + StatusCode::OK, + Json(RunArtifactListResponse { data: vec![] }), + ) + .into_response() } pub(crate) async fn get_run_usage( @@ -143,11 +148,56 @@ pub(crate) async fn generate_preview_url_stub( ) -> Response { ( StatusCode::CREATED, - Json(serde_json::json!({"url": "https://google.com"})), + Json(serde_json::json!({"url": "https://google.com", "token": "demo-preview-token"})), ) .into_response() } +pub(crate) async fn create_ssh_access_stub( + _auth: AuthenticatedService, + State(_state): State>, + Path(_id): Path, +) -> Response { + ( + StatusCode::CREATED, + Json(serde_json::json!({"command": "ssh demo@fabro.example"})), + ) + .into_response() +} + +pub(crate) async fn list_sandbox_files_stub( + _auth: AuthenticatedService, + State(_state): State>, + Path(_id): Path, +) -> Response { + ( + StatusCode::OK, + Json(serde_json::json!({ + "data": [ + { "name": "report.txt", "is_dir": false, "size": 12 }, + { "name": "logs", "is_dir": true } + ] + })), + ) + .into_response() +} + +pub(crate) async fn get_sandbox_file_stub( + _auth: AuthenticatedService, + State(_state): State>, + Path(_id): Path, +) -> Response { + (StatusCode::OK, "demo sandbox file").into_response() +} + +pub(crate) async fn put_sandbox_file_stub( + _auth: AuthenticatedService, + State(_state): State>, + Path(_id): Path, +) -> Response { + StatusCode::NO_CONTENT.into_response() +} + pub(crate) async fn get_run_status( _auth: AuthenticatedService, State(_state): State>, @@ -1206,23 +1256,6 @@ mod runs { ] } - pub(super) fn files() -> Vec { - vec![ - FileDiff { - old_file: DiffFile { name: "src/commands/run.ts".into(), contents: "import { parseArgs } from \"node:util\";\nimport { loadConfig } from \"../config.js\";\nimport { execute } from \"../executor.js\";\n\ninterface RunOptions {\n config: string;\n dryRun: boolean;\n}\n\nexport async function run(argv: string[]) {\n const { values } = parseArgs({\n args: argv,\n options: {\n config: { type: \"string\", short: \"c\", default: \"fabro.toml\" },\n \"dry-run\": { type: \"boolean\", default: false },\n },\n });\n\n const opts: RunOptions = {\n config: values.config ?? \"fabro.toml\",\n dryRun: values[\"dry-run\"] ?? false,\n };\n\n const config = await loadConfig(opts.config);\n const result = await execute(config, { dryRun: opts.dryRun });\n\n if (result.success) {\n console.log(\"Run completed successfully.\");\n } else {\n console.error(\"Run failed:\", result.error);\n process.exitCode = 1;\n }\n}\n".into() }, - new_file: DiffFile { name: "src/commands/run.ts".into(), contents: "import { parseArgs } from \"node:util\";\nimport { loadConfig } from \"../config.js\";\nimport { execute } from \"../executor.js\";\nimport { createLogger, type Logger } from \"../logger.js\";\n\ninterface RunOptions {\n config: string;\n dryRun: boolean;\n verbose: boolean;\n}\n\nexport async function run(argv: string[]) {\n const { values } = parseArgs({\n args: argv,\n options: {\n config: { type: \"string\", short: \"c\", default: \"fabro.toml\" },\n \"dry-run\": { type: \"boolean\", default: false },\n verbose: { type: \"boolean\", short: \"v\", default: false },\n },\n });\n\n const opts: RunOptions = {\n config: values.config ?? \"fabro.toml\",\n dryRun: values[\"dry-run\"] ?? false,\n verbose: values.verbose ?? false,\n };\n\n const logger: Logger = createLogger({ verbose: opts.verbose });\n\n const config = await loadConfig(opts.config);\n logger.debug(\"Loaded config from %s\", opts.config);\n\n const result = await execute(config, { dryRun: opts.dryRun, logger });\n logger.debug(\"Execution finished in %dms\", result.elapsed);\n\n if (result.success) {\n console.log(\"Run completed successfully.\");\n } else {\n console.error(\"Run failed:\", result.error);\n process.exitCode = 1;\n }\n}\n".into() }, - }, - FileDiff { - old_file: DiffFile { name: "src/logger.ts".into(), contents: String::new() }, - new_file: DiffFile { name: "src/logger.ts".into(), contents: "export interface Logger {\n info(message: string, ...args: unknown[]): void;\n debug(message: string, ...args: unknown[]): void;\n error(message: string, ...args: unknown[]): void;\n}\n\ninterface LoggerOptions {\n verbose: boolean;\n}\n\nexport function createLogger({ verbose }: LoggerOptions): Logger {\n return {\n info(message, ...args) {\n console.log(message, ...args);\n },\n debug(message, ...args) {\n if (verbose) {\n console.log(\"[debug]\", message, ...args);\n }\n },\n error(message, ...args) {\n console.error(message, ...args);\n },\n };\n}\n".into() }, - }, - FileDiff { - old_file: DiffFile { name: "src/executor.ts".into(), contents: "import type { Config } from \"./config.js\";\n\ninterface ExecuteOptions {\n dryRun: boolean;\n}\n\ninterface ExecuteResult {\n success: boolean;\n error?: string;\n}\n\nexport async function execute(\n config: Config,\n options: ExecuteOptions,\n): Promise {\n if (options.dryRun) {\n console.log(\"Dry run — skipping execution.\");\n return { success: true };\n }\n\n try {\n for (const step of config.steps) {\n await step.run();\n }\n return { success: true };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { success: false, error: message };\n }\n}\n".into() }, - new_file: DiffFile { name: "src/executor.ts".into(), contents: "import type { Config } from \"./config.js\";\nimport type { Logger } from \"./logger.js\";\n\ninterface ExecuteOptions {\n dryRun: boolean;\n logger: Logger;\n}\n\ninterface ExecuteResult {\n success: boolean;\n elapsed: number;\n error?: string;\n}\n\nexport async function execute(\n config: Config,\n options: ExecuteOptions,\n): Promise {\n const start = performance.now();\n\n if (options.dryRun) {\n options.logger.info(\"Dry run — skipping execution.\");\n return { success: true, elapsed: performance.now() - start };\n }\n\n try {\n for (const step of config.steps) {\n options.logger.debug(\"Running step: %s\", step.name);\n await step.run();\n }\n return { success: true, elapsed: performance.now() - start };\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n return { success: false, elapsed: performance.now() - start, error: message };\n }\n}\n".into() }, - }, - ] - } - pub(super) fn usage() -> RunUsage { RunUsage { stages: vec![ diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 4e13b03ed..b45f8b2c5 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -130,6 +130,10 @@ pub(crate) fn create_run_input(prepared: PreparedManifest) -> CreateRunInput { workflow_bundle: Some(prepared.workflow_bundle), run_id: prepared.run_id, host_repo_path: Some(prepared.working_directory.display().to_string()), + repo_origin_url: prepared + .git + .as_ref() + .map(|git| fabro_github::normalize_repo_origin_url(&git.origin_url)), base_branch: prepared.git.as_ref().map(|git| git.branch.clone()), } } diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index 1ab2f98e6..14e5844d8 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::path::{Component, PathBuf}; use std::str::FromStr; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -29,10 +29,13 @@ use fabro_store::{EventEnvelope, EventPayload, StageId, StoreHandle}; use fabro_types::{RunBlobId, RunEvent, RunId, Settings}; use fabro_util::redact::redact_jsonl_line; use fabro_util::version::FABRO_VERSION; +use fabro_workflow::artifacts as workflow_artifacts; use fabro_workflow::error::FabroError; use fabro_workflow::handler::HandlerRegistry; use futures_util::stream; use object_store::memory::InMemory as MemoryObjectStore; +use tempfile::NamedTempFile; +use tokio::fs; use tokio::sync::Notify; use tokio::sync::RwLock as AsyncRwLock; use tokio::sync::broadcast; @@ -56,6 +59,9 @@ use crate::sessions::{SessionStore, new_session_store}; use crate::static_files; use crate::web_auth; use fabro_interview::{Answer, Interviewer, QuestionType, WebInterviewer}; +use fabro_sandbox::daytona::DaytonaSandbox; +use fabro_sandbox::reconnect::reconnect; +use fabro_sandbox::{Sandbox, SandboxProvider}; use fabro_workflow::event::{self as workflow_event, Emitter}; use fabro_workflow::operations::{self}; use fabro_workflow::pipeline::Persisted; @@ -69,9 +75,11 @@ pub use fabro_api::types::{ ArtifactListResponse, CompletionContentPart, CompletionMessage, CompletionMessageRole, CompletionResponse, CompletionToolChoiceMode, CompletionUsage, CreateCompletionRequest, EventEnvelope as ApiEventEnvelope, ModelReference, PaginatedEventList, PaginatedRunList, - PaginationMeta, PreflightResponse, QuestionType as ApiQuestionType, - RenderWorkflowGraphDirection, RenderWorkflowGraphFormat, RenderWorkflowGraphRequest, RunError, - RunEvent as ApiRunEvent, RunManifest, RunStatus, RunStatusResponse, SetSecretRequest, + PaginationMeta, PreflightResponse, PreviewUrlRequest, PreviewUrlResponse, + QuestionType as ApiQuestionType, RenderWorkflowGraphDirection, RenderWorkflowGraphFormat, + RenderWorkflowGraphRequest, RunArtifactEntry, RunArtifactListResponse, RunError, + RunEvent as ApiRunEvent, RunManifest, RunStatus, RunStatusResponse, SandboxFileEntry, + SandboxFileListResponse, SetSecretRequest, SshAccessRequest, SshAccessResponse, StartRunRequest, SubmitAnswerRequest, TokenUsage, UsageByModel, WriteBlobResponse, }; use fabro_graphviz::render::GraphFormat; @@ -136,6 +144,18 @@ struct ArtifactFilenameParams { filename: Option, } +#[derive(serde::Deserialize)] +struct SandboxFilesParams { + path: String, + #[serde(default)] + depth: Option, +} + +#[derive(serde::Deserialize)] +struct SandboxFileParams { + path: String, +} + /// Non-paginated list response wrapper with `has_more: false`. #[derive(serde::Serialize)] pub struct ListResponse { @@ -362,6 +382,7 @@ fn demo_routes() -> Router> { .route("/runs/{id}/graph", get(demo::get_run_graph)) .route("/runs/{id}/retro", get(demo::get_run_retro)) .route("/runs/{id}/stages", get(demo::get_run_stages)) + .route("/runs/{id}/artifacts", get(demo::list_run_artifacts_stub)) .route( "/runs/{id}/stages/{stageId}/turns", get(demo::get_stage_turns), @@ -374,12 +395,20 @@ fn demo_routes() -> Router> { "/runs/{id}/stages/{stageId}/artifacts/download", get(not_implemented), ) - .route("/runs/{id}/files", get(demo::get_run_files)) .route("/runs/{id}/usage", get(demo::get_run_usage)) .route("/runs/{id}/verification", get(demo::get_run_verification)) .route("/runs/{id}/settings", get(demo::get_run_settings)) .route("/runs/{id}/steer", post(demo::steer_run_stub)) .route("/runs/{id}/preview", post(demo::generate_preview_url_stub)) + .route("/runs/{id}/ssh", post(demo::create_ssh_access_stub)) + .route( + "/runs/{id}/sandbox/files", + get(demo::list_sandbox_files_stub), + ) + .route( + "/runs/{id}/sandbox/file", + get(demo::get_sandbox_file_stub).put(demo::put_sandbox_file_stub), + ) .route("/workflows", get(demo::list_workflows)) .route("/workflows/{name}", get(demo::get_workflow)) .route("/workflows/{name}/runs", get(demo::list_workflow_runs)) @@ -463,6 +492,7 @@ fn real_routes() -> Router> { .route("/runs/{id}/graph", get(get_graph)) .route("/runs/{id}/retro", get(get_retro)) .route("/runs/{id}/stages", get(not_implemented)) + .route("/runs/{id}/artifacts", get(list_run_artifacts)) .route("/runs/{id}/stages/{stageId}/turns", get(not_implemented)) .route( "/runs/{id}/stages/{stageId}/artifacts", @@ -472,12 +502,17 @@ fn real_routes() -> Router> { "/runs/{id}/stages/{stageId}/artifacts/download", get(get_stage_artifact), ) - .route("/runs/{id}/files", get(not_implemented)) .route("/runs/{id}/usage", get(not_implemented)) .route("/runs/{id}/verification", get(not_implemented)) .route("/runs/{id}/settings", get(not_implemented)) .route("/runs/{id}/steer", post(not_implemented)) - .route("/runs/{id}/preview", post(not_implemented)) + .route("/runs/{id}/preview", post(generate_preview_url)) + .route("/runs/{id}/ssh", post(create_ssh_access)) + .route("/runs/{id}/sandbox/files", get(list_sandbox_files)) + .route( + "/runs/{id}/sandbox/file", + get(get_sandbox_file).put(put_sandbox_file), + ) .route("/workflows", get(not_implemented)) .route("/workflows/{name}", get(not_implemented)) .route("/workflows/{name}/runs", get(not_implemented)) @@ -1070,6 +1105,46 @@ fn required_filename(params: ArtifactFilenameParams) -> Result } } +#[allow(clippy::result_large_err)] +fn validate_relative_artifact_path(kind: &str, value: &str) -> Result { + let mut normalized = PathBuf::new(); + for component in PathBuf::from(value).components() { + match component { + Component::Normal(part) => normalized.push(part), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(ApiError::bad_request(format!( + "{kind} must be a relative path without '..'" + )) + .into_response()); + } + } + } + if normalized.as_os_str().is_empty() { + return Err(ApiError::bad_request(format!("{kind} must not be empty")).into_response()); + } + Ok(normalized) +} + +#[allow(clippy::result_large_err)] +fn run_artifacts_dir(run: &fabro_types::RunRecord, run_id: &RunId) -> PathBuf { + operations::make_run_dir(&run.settings.storage_dir().join("runs"), run_id) + .join("cache/artifacts/files") +} + +#[allow(clippy::result_large_err)] +fn scan_run_artifacts( + run: &fabro_types::RunRecord, + run_id: &RunId, + node_filter: Option<&str>, + retry_filter: Option, +) -> Result, Response> { + workflow_artifacts::scan_artifacts(&run_artifacts_dir(run, run_id), node_filter, retry_filter) + .map_err(|err| { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + }) +} + fn octet_stream_response(bytes: Bytes) -> Response { ( StatusCode::OK, @@ -2029,6 +2104,51 @@ async fn read_run_blob( } } +async fn list_run_artifacts( + _auth: AuthenticatedService, + State(state): State>, + Path(id): Path, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + match state.store.open_run_reader(&id).await { + Ok(run_store) => match run_store.state().await { + Ok(run_state) => { + let Some(run) = run_state.run.as_ref() else { + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "run record missing from store", + ) + .into_response(); + }; + match scan_run_artifacts(run, &id, None, None) { + Ok(entries) => Json(RunArtifactListResponse { + data: entries + .into_iter() + .map(|entry| RunArtifactEntry { + stage_id: StageId::new(entry.node_slug.clone(), entry.retry) + .to_string(), + node_slug: entry.node_slug, + retry: entry.retry.cast_signed(), + relative_path: entry.relative_path, + size: entry.size.cast_signed(), + }) + .collect(), + }) + .into_response(), + Err(response) => response, + } + } + Err(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + }, + Err(_) => ApiError::not_found("Run not found.").into_response(), + } +} + async fn list_stage_artifacts( _auth: AuthenticatedService, State(state): State>, @@ -2043,14 +2163,44 @@ async fn list_stage_artifacts( Err(response) => return response, }; match state.store.open_run_reader(&id).await { - Ok(run_store) => match run_store.list_artifacts_for_stage(&stage_id).await { - Ok(filenames) => Json(ArtifactListResponse { - data: filenames - .into_iter() - .map(|filename| ArtifactEntry { filename }) - .collect(), - }) - .into_response(), + Ok(run_store) => match run_store.state().await { + Ok(run_state) => { + let Some(run) = run_state.run.as_ref() else { + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "run record missing from store", + ) + .into_response(); + }; + match run_store.list_artifacts_for_stage(&stage_id).await { + Ok(filenames) if !filenames.is_empty() => Json(ArtifactListResponse { + data: filenames + .into_iter() + .map(|filename| ArtifactEntry { filename }) + .collect(), + }) + .into_response(), + Ok(_) => match scan_run_artifacts( + run, + &id, + Some(stage_id.node_id()), + Some(stage_id.visit()), + ) { + Ok(entries) => Json(ArtifactListResponse { + data: entries + .into_iter() + .map(|entry| ArtifactEntry { + filename: entry.relative_path, + }) + .collect(), + }) + .into_response(), + Err(response) => response, + }, + Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(), + } + } Err(err) => { ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() } @@ -2108,9 +2258,42 @@ async fn get_stage_artifact( Err(response) => return response, }; match state.store.open_run_reader(&id).await { - Ok(run_store) => match run_store.get_artifact(&stage_id, &filename).await { - Ok(Some(bytes)) => octet_stream_response(bytes), - Ok(None) => ApiError::not_found("Artifact not found.").into_response(), + Ok(run_store) => match run_store.state().await { + Ok(run_state) => { + let Some(run) = run_state.run.as_ref() else { + return ApiError::new( + StatusCode::INTERNAL_SERVER_ERROR, + "run record missing from store", + ) + .into_response(); + }; + match run_store.get_artifact(&stage_id, &filename).await { + Ok(Some(bytes)) => octet_stream_response(bytes), + Ok(None) => { + let relative_path = + match validate_relative_artifact_path("filename", &filename) { + Ok(path) => path, + Err(response) => return response, + }; + let artifact_path = run_artifacts_dir(run, &id) + .join(stage_id.node_id()) + .join(format!("retry_{}", stage_id.visit())) + .join(relative_path); + match std::fs::read(&artifact_path) { + Ok(bytes) => octet_stream_response(Bytes::from(bytes)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + ApiError::not_found("Artifact not found.").into_response() + } + Err(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response() + } + } + } + Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(), + } + } Err(err) => { ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() } @@ -2119,6 +2302,226 @@ async fn get_stage_artifact( } } +async fn generate_preview_url( + _auth: AuthenticatedService, + State(state): State>, + Path(id): Path, + Json(request): Json, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + let Ok(port) = u16::try_from(request.port) else { + return ApiError::bad_request("Port must fit in a u16.").into_response(); + }; + let Ok(expires_in_secs) = i32::try_from(request.expires_in_secs.get()) else { + return ApiError::bad_request("Preview expiry exceeds supported range.").into_response(); + }; + + let sandbox = match reconnect_daytona_sandbox(&state, &id).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; + + let response = if request.signed { + match sandbox + .get_signed_preview_url(port, Some(expires_in_secs)) + .await + { + Ok(preview) => PreviewUrlResponse { + token: None, + url: preview.url, + }, + Err(err) => { + return ApiError::new(StatusCode::CONFLICT, err).into_response(); + } + } + } else { + match sandbox.get_preview_link(port).await { + Ok(preview) => PreviewUrlResponse { + token: Some(preview.token), + url: preview.url, + }, + Err(err) => { + return ApiError::new(StatusCode::CONFLICT, err).into_response(); + } + } + }; + + (StatusCode::CREATED, Json(response)).into_response() +} + +async fn create_ssh_access( + _auth: AuthenticatedService, + State(state): State>, + Path(id): Path, + Json(request): Json, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + let sandbox = match reconnect_daytona_sandbox(&state, &id).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; + match sandbox.create_ssh_access(Some(request.ttl_minutes)).await { + Ok(command) => (StatusCode::CREATED, Json(SshAccessResponse { command })).into_response(), + Err(err) => ApiError::new(StatusCode::CONFLICT, err).into_response(), + } +} + +async fn list_sandbox_files( + _auth: AuthenticatedService, + State(state): State>, + Path(id): Path, + Query(params): Query, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + let sandbox = match reconnect_run_sandbox(&state, &id).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; + match sandbox.list_directory(¶ms.path, params.depth).await { + Ok(entries) => Json(SandboxFileListResponse { + data: entries + .into_iter() + .map(|entry| SandboxFileEntry { + is_dir: entry.is_dir, + name: entry.name, + size: entry.size.map(u64::cast_signed), + }) + .collect(), + }) + .into_response(), + Err(err) => ApiError::new(StatusCode::NOT_FOUND, err).into_response(), + } +} + +async fn get_sandbox_file( + _auth: AuthenticatedService, + State(state): State>, + Path(id): Path, + Query(params): Query, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + let sandbox = match reconnect_run_sandbox(&state, &id).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; + let temp = match NamedTempFile::new() { + Ok(temp) => temp, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + if let Err(err) = sandbox + .download_file_to_local(¶ms.path, temp.path()) + .await + { + return ApiError::new(StatusCode::NOT_FOUND, err).into_response(); + } + match fs::read(temp.path()).await { + Ok(bytes) => octet_stream_response(bytes.into()), + Err(err) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + } +} + +async fn put_sandbox_file( + _auth: AuthenticatedService, + State(state): State>, + Path(id): Path, + Query(params): Query, + body: Bytes, +) -> Response { + let id = match parse_run_id_path(&id) { + Ok(id) => id, + Err(response) => return response, + }; + let sandbox = match reconnect_run_sandbox(&state, &id).await { + Ok(sandbox) => sandbox, + Err(response) => return response, + }; + let temp = match NamedTempFile::new() { + Ok(temp) => temp, + Err(err) => { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()) + .into_response(); + } + }; + if let Err(err) = fs::write(temp.path(), &body).await { + return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(); + } + match sandbox + .upload_file_from_local(temp.path(), ¶ms.path) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err).into_response(), + } +} + +async fn reconnect_run_sandbox( + state: &Arc, + run_id: &RunId, +) -> Result, Response> { + let record = load_run_sandbox_record(state, run_id).await?; + reconnect(&record) + .await + .map_err(|err| ApiError::new(StatusCode::CONFLICT, format!("{err}")).into_response()) +} + +async fn reconnect_daytona_sandbox( + state: &Arc, + run_id: &RunId, +) -> Result { + let record = load_run_sandbox_record(state, run_id).await?; + if record.provider != SandboxProvider::Daytona.to_string() { + return Err(ApiError::new( + StatusCode::CONFLICT, + "Sandbox provider does not support this capability.", + ) + .into_response()); + } + let Some(name) = record.identifier.as_deref() else { + return Err(ApiError::new( + StatusCode::CONFLICT, + "Sandbox record is missing the Daytona identifier.", + ) + .into_response()); + }; + DaytonaSandbox::reconnect(name) + .await + .map_err(|err| ApiError::new(StatusCode::CONFLICT, err.clone()).into_response()) +} + +async fn load_run_sandbox_record( + state: &Arc, + run_id: &RunId, +) -> Result { + match state.store.open_run_reader(run_id).await { + Ok(run_store) => match run_store.state().await { + Ok(run_state) => run_state.sandbox.ok_or_else(|| { + ApiError::new(StatusCode::CONFLICT, "Run has no active sandbox.").into_response() + }), + Err(err) => Err( + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response(), + ), + }, + Err(_) => Err(ApiError::not_found("Run not found.").into_response()), + } +} + async fn cancel_run( _auth: AuthenticatedService, State(state): State>, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index f813b5e69..f7515b738 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -94,6 +94,7 @@ impl RunProjection { workflow_slug: props.workflow_slug.clone(), working_directory, host_repo_path: props.host_repo_path.clone(), + repo_origin_url: props.repo_origin_url.clone(), base_branch: props.base_branch.clone(), labels, }); diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 0053bfe6e..e15ef1e04 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -263,6 +263,7 @@ mod tests { workflow_slug: Some("night-sky".to_string()), working_directory: PathBuf::from(format!("/tmp/{label}")), host_repo_path: Some("github.com/fabro-sh/fabro".to_string()), + repo_origin_url: Some("https://github.com/fabro-sh/fabro".to_string()), base_branch: Some("main".to_string()), labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]), } diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 7d270ca5f..bfb201817 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -18,6 +18,8 @@ pub struct RunRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub host_repo_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_origin_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub base_branch: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub labels: HashMap, diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index ac8456786..f983110ac 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -21,6 +21,8 @@ pub struct RunCreatedProps { #[serde(default, skip_serializing_if = "Option::is_none")] pub host_repo_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub repo_origin_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub base_branch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_slug: Option, diff --git a/lib/crates/fabro-workflow/src/event.rs b/lib/crates/fabro-workflow/src/event.rs index 3428afdd8..2828c71dd 100644 --- a/lib/crates/fabro-workflow/src/event.rs +++ b/lib/crates/fabro-workflow/src/event.rs @@ -39,6 +39,8 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] host_repo_path: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + repo_origin_url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] base_branch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] workflow_slug: Option, @@ -1321,6 +1323,7 @@ fn event_body_from_event(event: &Event) -> EventBody { run_dir, working_directory, host_repo_path, + repo_origin_url, base_branch, workflow_slug, db_prefix, @@ -1334,6 +1337,7 @@ fn event_body_from_event(event: &Event) -> EventBody { run_dir: run_dir.clone(), working_directory: working_directory.clone(), host_repo_path: host_repo_path.clone(), + repo_origin_url: repo_origin_url.clone(), base_branch: base_branch.clone(), workflow_slug: workflow_slug.clone(), db_prefix: db_prefix.clone(), diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 7adde7562..2d7da126c 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -33,6 +33,7 @@ pub struct CreateRunInput { pub workflow_bundle: Option, pub run_id: Option, pub host_repo_path: Option, + pub repo_origin_url: Option, pub base_branch: Option, } @@ -53,6 +54,7 @@ struct PersistCreateOptions { base_branch: Option, working_directory: PathBuf, host_repo_path: Option, + repo_origin_url: Option, } /// Resolve workflow inputs, normalize settings, and persist a run directory. @@ -77,6 +79,7 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result Result Result