refactor(cli): make run-adjacent commands server-only

This commit is contained in:
Bryan Helmkamp 2026-04-06 06:12:16 -04:00
parent 36ff3c5377
commit beec4c8dff
No known key found for this signature in database
65 changed files with 1473 additions and 687 deletions

View file

@ -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 ─────────────────────────────────────────────────

View file

@ -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(),
}

View file

@ -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: <run-id>:<path> 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<String>,
/// 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,

View file

@ -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,
};

View file

@ -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!(
"{:<node_width$} {:>retry_width$} {:>size_width$} PATH",
"NODE", "RETRY", "SIZE"
);
let total_size: u64 = entries.iter().map(|entry| entry.size).sum();
println!("{:<node_width$} {:>retry_width$} PATH", "NODE", "RETRY");
for entry in &entries {
println!(
"{:<node_width$} {:>retry_width$} {:>size_width$} {}",
entry.node_slug,
entry.retry,
format_size(entry.size),
entry.relative_path
"{:<node_width$} {:>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(())
}

View file

@ -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<u32>,
) -> Result<(RunId, ServerStoreClient, Vec<ArtifactEntry>)> {
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 {

View file

@ -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<fabro_github::GitHubAppCredentials>,
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<fabro_github::GitHubAppCredentials>,
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",

View file

@ -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<fabro_github::GitHubAppCredentials>,
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<fabro_github::GitHubAppCredentials>,
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

View file

@ -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<fabro_github::GitHubAppCredentials>,
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<fabro_github::GitHubAppCredentials>,
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));
}

View file

@ -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<fabro_github::GitHubAppCredentials>,
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<fabro_github::GitHubAppCredentials>,
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",

View file

@ -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))
}

View file

@ -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<fabro_github::GitHubAppCredentials>,
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<fabro_github::GitHubAppCredentials>,
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",

View file

@ -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<PathBuf> {
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<RunId> {
std::fs::read_to_string(run_dir.join("id.txt"))
.ok()

View file

@ -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<CopyDirection> {
}
}
async fn load_sandbox(storage_dir: &Path, run_prefix: &str) -> Result<Box<dyn Sandbox>> {
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<usize> {
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<usize> {
@ -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")
);
}
}

View file

@ -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<String> {
fn resolve_diff(state: &RunProjection, args: &DiffArgs) -> Result<String> {
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 {

View file

@ -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?;

View file

@ -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

View file

@ -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,

View file

@ -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(())

View file

@ -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);

View file

@ -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,

View file

@ -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")
}

View file

@ -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,

View file

@ -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,

View file

@ -467,10 +467,7 @@ fn build_manifest_git(cwd: &Path) -> Option<types::ManifestGit> {
}
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<PathBuf> {

View file

@ -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<Vec<types::RunArtifactEntry>> {
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<Vec<u8>> {
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<types::PreviewUrlResponse> {
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<types::SshAccessResponse> {
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<u32>,
) -> Result<Vec<types::SandboxFileEntry>> {
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<Vec<u8>> {
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<u8>,
) -> 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<E>(err: progenitor_client::Error<E>) -> anyhow::Error

View file

@ -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::*;

View file

@ -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();
}
}

View file

@ -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();

View file

@ -20,16 +20,16 @@ fn help() {
[DEST] Destination directory (defaults to current directory) [default: .]
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <NODE> Filter to artifacts from a specific node
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--retry <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 <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 <NODE> Filter to artifacts from a specific node
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--retry <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 -----
");
}

View file

@ -19,15 +19,15 @@ fn help() {
<RUN_ID> Run ID (or prefix)
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <NODE> Filter to artifacts from a specific node
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--retry <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 <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 <NODE> Filter to artifacts from a specific node
--no-upgrade-check Disable automatic upgrade check [env: FABRO_NO_UPGRADE_CHECK=true]
--retry <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 -----
");
}

View file

@ -19,16 +19,14 @@ fn help() {
<RUN> Run ID or prefix
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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 <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 <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 -----
");
}

View file

@ -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 <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 <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 -----
");
}

View file

@ -17,13 +17,13 @@ fn help() {
<RUN_ID> Run ID or prefix
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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 -----
");
}

View file

@ -19,14 +19,14 @@ fn help() {
<RUN_ID> Run ID or prefix
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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 <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 <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 -----
");
}

View file

@ -14,14 +14,14 @@ fn help() {
Usage: fabro pr list [OPTIONS]
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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 -----
");
}

View file

@ -17,14 +17,14 @@ fn help() {
<RUN_ID> Run ID or prefix
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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 <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 <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 -----
");
}

View file

@ -23,13 +23,13 @@ fn help() {
<RUN_ID> Run ID or prefix
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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 -----
");
}

View file

@ -21,14 +21,14 @@ fn help() {
<RUN> Run ID or unambiguous prefix
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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:
<RUN>
Usage: fabro resume --storage-dir <STORAGE_DIR> --no-upgrade-check <RUN>
Usage: fabro resume --no-upgrade-check <RUN>
For more information, try '--help'.
");

View file

@ -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 <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 <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 -----
");
}

View file

@ -20,14 +20,14 @@ fn help() {
<DST> Destination: <run-id>:<path> or local path
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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.
");
}

View file

@ -20,16 +20,16 @@ fn help() {
<PORT> Port number
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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 <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 <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.
");
}

View file

@ -19,15 +19,15 @@ fn help() {
<RUN> Run ID or prefix
Options:
--json Output as JSON [env: FABRO_JSON=]
--storage-dir <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 <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 <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 <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.
");
}

View file

@ -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.

View file

@ -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
}
]

View file

@ -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(

View file

@ -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" }

View file

@ -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<Arc<AppState>>,
Path(_id): Path<String>,
Query(pagination): Query<PaginationParams>,
) -> 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<Arc<AppState>>,
Path(_id): Path<String>,
) -> 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<Arc<AppState>>,
Path(_id): Path<String>,
) -> 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<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
(StatusCode::OK, "demo sandbox file").into_response()
}
pub(crate) async fn put_sandbox_file_stub(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
Path(_id): Path<String>,
) -> Response {
StatusCode::NO_CONTENT.into_response()
}
pub(crate) async fn get_run_status(
_auth: AuthenticatedService,
State(_state): State<Arc<AppState>>,
@ -1206,23 +1256,6 @@ mod runs {
]
}
pub(super) fn files() -> Vec<FileDiff> {
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<ExecuteResult> {\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<ExecuteResult> {\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![

View file

@ -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()),
}
}

View file

@ -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<String>,
}
#[derive(serde::Deserialize)]
struct SandboxFilesParams {
path: String,
#[serde(default)]
depth: Option<usize>,
}
#[derive(serde::Deserialize)]
struct SandboxFileParams {
path: String,
}
/// Non-paginated list response wrapper with `has_more: false`.
#[derive(serde::Serialize)]
pub struct ListResponse<T: serde::Serialize> {
@ -362,6 +382,7 @@ fn demo_routes() -> Router<Arc<AppState>> {
.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<Arc<AppState>> {
"/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<Arc<AppState>> {
.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<Arc<AppState>> {
"/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<String, Response>
}
}
#[allow(clippy::result_large_err)]
fn validate_relative_artifact_path(kind: &str, value: &str) -> Result<PathBuf, Response> {
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<u32>,
) -> Result<Vec<workflow_artifacts::ArtifactEntry>, 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<Arc<AppState>>,
Path(id): Path<String>,
) -> 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<Arc<AppState>>,
@ -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<Arc<AppState>>,
Path(id): Path<String>,
Json(request): Json<PreviewUrlRequest>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
Json(request): Json<SshAccessRequest>,
) -> 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<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<SandboxFilesParams>,
) -> 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(&params.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<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<SandboxFileParams>,
) -> 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(&params.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<Arc<AppState>>,
Path(id): Path<String>,
Query(params): Query<SandboxFileParams>,
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(), &params.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<AppState>,
run_id: &RunId,
) -> Result<Box<dyn Sandbox>, 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<AppState>,
run_id: &RunId,
) -> Result<DaytonaSandbox, Response> {
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<AppState>,
run_id: &RunId,
) -> Result<fabro_types::SandboxRecord, Response> {
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<Arc<AppState>>,

View file

@ -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,
});

View file

@ -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())]),
}

View file

@ -18,6 +18,8 @@ pub struct RunRecord {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,

View file

@ -21,6 +21,8 @@ pub struct RunCreatedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub host_repo_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workflow_slug: Option<String>,

View file

@ -39,6 +39,8 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
host_repo_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
repo_origin_url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
base_branch: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
workflow_slug: Option<String>,
@ -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(),

View file

@ -33,6 +33,7 @@ pub struct CreateRunInput {
pub workflow_bundle: Option<WorkflowBundle>,
pub run_id: Option<RunId>,
pub host_repo_path: Option<String>,
pub repo_origin_url: Option<String>,
pub base_branch: Option<String>,
}
@ -53,6 +54,7 @@ struct PersistCreateOptions {
base_branch: Option<String>,
working_directory: PathBuf,
host_repo_path: Option<String>,
repo_origin_url: Option<String>,
}
/// Resolve workflow inputs, normalize settings, and persist a run directory.
@ -77,6 +79,7 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result<Creat
workflow_bundle,
run_id,
host_repo_path,
repo_origin_url,
base_branch,
} = request;
@ -87,10 +90,16 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result<Creat
let working_directory = resolved.working_directory.clone();
let host_repo_path =
host_repo_path.or_else(|| Some(working_directory.to_string_lossy().to_string()));
let detected_repo = detect_repo_info(&working_directory).ok();
let repo_origin_url = repo_origin_url.or_else(|| {
detected_repo
.as_ref()
.map(|(origin_url, _)| fabro_github::normalize_repo_origin_url(origin_url))
});
let base_branch = base_branch.or_else(|| {
detect_repo_info(&working_directory)
.ok()
.and_then(|(_, branch)| branch)
detected_repo
.as_ref()
.and_then(|(_, branch)| branch.clone())
});
let goal_override = resolved.goal_override.clone();
@ -108,6 +117,7 @@ pub async fn create(store: &SlateStore, request: CreateRunInput) -> Result<Creat
base_branch,
working_directory,
host_repo_path,
repo_origin_url,
},
current_dir,
file_resolver,
@ -169,6 +179,7 @@ async fn persist_created_run(
run_dir: persisted.run_dir().display().to_string(),
working_directory: record.working_directory.display().to_string(),
host_repo_path: record.host_repo_path.clone(),
repo_origin_url: record.repo_origin_url.clone(),
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,
@ -302,6 +313,7 @@ fn persist_validated(
base_branch,
working_directory,
host_repo_path,
repo_origin_url,
} = options;
let settings = resolve_run_settings(settings, validated.graph());
@ -316,6 +328,7 @@ fn persist_validated(
workflow_slug,
working_directory,
host_repo_path,
repo_origin_url,
base_branch,
labels,
};
@ -673,6 +686,7 @@ mod tests {
workflow_bundle: None,
run_id: None,
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
},
)
@ -719,6 +733,7 @@ mod tests {
workflow_bundle: None,
run_id: Some(fixtures::RUN_1),
host_repo_path: Some(dir.path().display().to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
},
)
@ -797,6 +812,7 @@ mod tests {
workflow_bundle: None,
run_id: Some(fixtures::RUN_2),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
},
)
@ -817,6 +833,40 @@ mod tests {
);
}
#[tokio::test]
async fn create_persists_repo_origin_url_from_request() {
let dir = tempfile::tempdir().unwrap();
let store = memory_store();
let created = create(
&store,
CreateRunInput {
workflow: WorkflowInput::DotSource {
source: MINIMAL_DOT.to_string(),
base_dir: None,
},
settings: Settings {
dry_run: Some(true),
..Default::default()
},
cwd: dir.path().to_path_buf(),
workflow_slug: None,
workflow_path: None,
workflow_bundle: None,
run_id: Some(fixtures::RUN_2),
host_repo_path: None,
repo_origin_url: Some("https://github.com/acme/widgets".to_string()),
base_branch: None,
},
)
.await
.unwrap();
assert_eq!(
created.persisted.run_record().repo_origin_url.as_deref(),
Some("https://github.com/acme/widgets")
);
}
#[tokio::test]
async fn create_hydrates_run_created_event_into_store() {
let dir = tempfile::tempdir().unwrap();
@ -847,6 +897,7 @@ mod tests {
workflow_bundle: None,
run_id: Some(fixtures::RUN_3),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
},
)

View file

@ -375,6 +375,7 @@ mod tests {
workflow_slug: None,
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: host_repo_path.map(ToOwned::to_owned),
repo_origin_url: None,
base_branch: None,
labels: HashMap::new(),
}
@ -446,6 +447,7 @@ mod tests {
run_dir: String::new(),
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,

View file

@ -868,6 +868,7 @@ mod tests {
workflow_bundle: None,
run_id: Some(fixtures::RUN_1),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
},
)

View file

@ -141,6 +141,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
.display()
.to_string(),
),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
},

View file

@ -743,6 +743,7 @@ mod tests {
workflow_slug: Some("test".to_string()),
working_directory: std::env::current_dir().unwrap(),
host_repo_path: Some(std::env::current_dir().unwrap().display().to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
},

View file

@ -128,6 +128,7 @@ mod tests {
workflow_slug: Some("ship".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::from([
("env".to_string(), "test".to_string()),
@ -156,6 +157,7 @@ mod tests {
run_dir: run_dir.to_string_lossy().to_string(),
working_directory: record.working_directory.display().to_string(),
host_repo_path: record.host_repo_path.clone(),
repo_origin_url: record.repo_origin_url.clone(),
base_branch: record.base_branch.clone(),
workflow_slug: record.workflow_slug.clone(),
db_prefix: None,

View file

@ -1090,6 +1090,7 @@ mod tests {
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
};
@ -1106,6 +1107,7 @@ mod tests {
run_dir: run_record.working_directory.display().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,
@ -1156,6 +1158,7 @@ mod tests {
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
};
@ -1172,6 +1175,7 @@ mod tests {
run_dir: run_record.working_directory.display().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,
@ -1375,6 +1379,7 @@ mod tests {
workflow_slug: None,
working_directory: tmp.path().to_path_buf(),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
};
@ -1391,6 +1396,7 @@ mod tests {
run_dir: run_record.working_directory.display().to_string(),
working_directory: tmp.path().display().to_string(),
host_repo_path: None,
repo_origin_url: run_record.repo_origin_url.clone(),
base_branch: None,
workflow_slug: None,
db_prefix: None,

View file

@ -224,6 +224,7 @@ mod tests {
workflow_slug: None,
working_directory: run_dir.to_path_buf(),
host_repo_path: None,
repo_origin_url: None,
base_branch: None,
labels: std::collections::HashMap::new(),
};
@ -240,6 +241,7 @@ mod tests {
run_dir: run_dir.to_string_lossy().to_string(),
working_directory: run_dir.to_string_lossy().to_string(),
host_repo_path: None,
repo_origin_url: run_record.repo_origin_url.clone(),
base_branch: None,
workflow_slug: None,
db_prefix: None,

View file

@ -424,6 +424,7 @@ mod tests {
workflow_slug: Some("test".to_string()),
working_directory: PathBuf::from("/tmp/project"),
host_repo_path: Some("/tmp/project".to_string()),
repo_origin_url: None,
base_branch: Some("main".to_string()),
labels: HashMap::new(),
}
@ -452,6 +453,7 @@ mod tests {
run_dir: run_dir.display().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,

View file

@ -87,6 +87,7 @@ async fn initialized(
.host_repo_path
.as_ref()
.map(|path| path.display().to_string()),
repo_origin_url: None,
base_branch: run_options.base_branch.clone(),
workflow_slug: run_options.workflow_slug.clone(),
db_prefix: None,