mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-09-15 23:32:46 +00:00
refactor(cli): make run-adjacent commands server-only
This commit is contained in:
parent
36ff3c5377
commit
beec4c8dff
65 changed files with 1473 additions and 687 deletions
|
|
@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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?;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
37
lib/crates/fabro-cli/src/shared/repo.rs
Normal file
37
lib/crates/fabro-cli/src/shared/repo.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'.
|
||||
");
|
||||
|
|
|
|||
|
|
@ -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 -----
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¶ms.path, params.depth).await {
|
||||
Ok(entries) => Json(SandboxFileListResponse {
|
||||
data: entries
|
||||
.into_iter()
|
||||
.map(|entry| SandboxFileEntry {
|
||||
is_dir: entry.is_dir,
|
||||
name: entry.name,
|
||||
size: entry.size.map(u64::cast_signed),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
.into_response(),
|
||||
Err(err) => ApiError::new(StatusCode::NOT_FOUND, err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_sandbox_file(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<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(¶ms.path, temp.path())
|
||||
.await
|
||||
{
|
||||
return ApiError::new(StatusCode::NOT_FOUND, err).into_response();
|
||||
}
|
||||
match fs::read(temp.path()).await {
|
||||
Ok(bytes) => octet_stream_response(bytes.into()),
|
||||
Err(err) => {
|
||||
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn put_sandbox_file(
|
||||
_auth: AuthenticatedService,
|
||||
State(state): State<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(), ¶ms.path)
|
||||
.await
|
||||
{
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(err) => ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reconnect_run_sandbox(
|
||||
state: &Arc<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>>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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())]),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue