fix(server): persist manifest metadata names (#302)

When Fabro creates a detached run through the server, the resulting run
metadata should still read like something a human can trust at a glance.
Before this change, those runs could persist with
`settings.project.name` and `settings.workflow.name` left `null` even
though Fabro already had enough local context to infer them. That made
`inspect` output look half-populated and made it harder to tell whether
the saved run state was complete.

This fixes that trust gap in the server-backed manifest flow.

## Summary
- backfill missing manifest-backed project and workflow names during
server run preparation
- prefer explicit `[workflow].name` from bundled `workflow.toml`, then
fall back to graph name or workflow slug
- cover both manifest preparation and persisted run-state behavior with
server tests

## Testing
- cargo test -p fabro-server
prepare_manifest_backfills_missing_project_and_workflow_names --
--nocapture
- cargo test -p fabro-server
prepare_manifest_preserves_explicit_project_and_workflow_names --
--nocapture
- cargo test -p fabro-server
create_run_persists_backfilled_project_and_workflow_names -- --nocapture

---------

Co-authored-by: Bryan Helmkamp <bryan@brynary.com>
This commit is contained in:
David Bock 2026-05-18 08:31:23 -04:00 • committed by GitHub
parent f24cb05972
commit 6a86ced77c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 603 additions and 140 deletions

View file

@ -14,7 +14,7 @@ function makeRun(overrides: Partial<Run> = {}): Run {
id: "01ABC", id: "01ABC",
goal: "Fix the build", goal: "Fix the build",
title: "Fix the build", title: "Fix the build",
workflow: { slug: "fix_build", name: "Fix Build" }, workflow: { slug: "fix_build", name: "Fix Build", graph_name: "FixBuild" },
automation: null, automation: null,
repository: { name: "myrepo", origin_url: null, provider: "unknown" }, repository: { name: "myrepo", origin_url: null, provider: "unknown" },
created_by: null, created_by: null,
@ -77,7 +77,7 @@ describe("mapRunListItem", () => {
const item = mapRunListItem(summary); const item = mapRunListItem(summary);
expect(item.id).toBe("01ABC"); expect(item.id).toBe("01ABC");
expect(item.title).toBe("Server supplied title"); expect(item.title).toBe("Server supplied title");
expect(item.workflow).toBe("fix_build"); expect(item.workflow).toBe("Fix Build");
expect(item.repo).toBe("myrepo"); expect(item.repo).toBe("myrepo");
expect(item.sourceDirectory).toBe("/home/user/myrepo"); expect(item.sourceDirectory).toBe("/home/user/myrepo");
expect(item.elapsed).toBeDefined(); expect(item.elapsed).toBeDefined();
@ -107,7 +107,7 @@ describe("mapRunToRunItem", () => {
const item = mapRunToRunItem(summary); const item = mapRunToRunItem(summary);
expect(item.id).toBe("01ABC"); expect(item.id).toBe("01ABC");
expect(item.title).toBe("Fix the build"); expect(item.title).toBe("Fix the build");
expect(item.workflow).toBe("fix_build"); expect(item.workflow).toBe("Fix Build");
expect(item.repo).toBe("myrepo"); expect(item.repo).toBe("myrepo");
expect(item.sourceDirectory).toBe("/home/user/myrepo"); expect(item.sourceDirectory).toBe("/home/user/myrepo");
expect(item.elapsed).toBeDefined(); expect(item.elapsed).toBeDefined();
@ -121,7 +121,7 @@ describe("mapRunToRunItem", () => {
id: "01DEF", id: "01DEF",
goal: "", goal: "",
title: "", title: "",
workflow: { slug: null, name: "unknown" }, workflow: { slug: null, name: null, graph_name: null },
source_directory: null, source_directory: null,
repository: { name: "unknown", origin_url: null, provider: "unknown" }, repository: { name: "unknown", origin_url: null, provider: "unknown" },
...withStatus({ kind: "submitted" }), ...withStatus({ kind: "submitted" }),
@ -143,6 +143,18 @@ describe("mapRunToRunItem", () => {
expect(item.sourceDirectory).toBeUndefined(); expect(item.sourceDirectory).toBeUndefined();
}); });
test("falls back to graph name and slug for workflow labels", () => {
const graphFallback = mapRunToRunItem(
makeRun({ workflow: { slug: "fix_build", name: null, graph_name: "FixBuild" } }),
);
const slugFallback = mapRunToRunItem(
makeRun({ workflow: { slug: "fix_build", name: null, graph_name: null } }),
);
expect(graphFallback.workflow).toBe("FixBuild");
expect(slugFallback.workflow).toBe("fix_build");
});
test("recognizes canonical blocked and queued run statuses", () => { test("recognizes canonical blocked and queued run statuses", () => {
expect(isRunStatus("queued")).toBe(true); expect(isRunStatus("queued")).toBe(true);
expect(isRunStatus("blocked")).toBe(true); expect(isRunStatus("blocked")).toBe(true);

View file

@ -85,7 +85,7 @@ export function mapRunListItem(item: Run): RunItem {
id: item.id, id: item.id,
repo: displayRepoName(item.repository?.name ?? "unknown"), repo: displayRepoName(item.repository?.name ?? "unknown"),
title: displayRunTitle(item.title), title: displayRunTitle(item.title),
workflow: item.workflow.slug ?? item.workflow.name ?? "unknown", workflow: item.workflow.name ?? item.workflow.graph_name ?? item.workflow.slug ?? "unknown",
column: columnForRun(item) ?? undefined, column: columnForRun(item) ?? undefined,
lifecycleStatus, lifecycleStatus,
lifecycleStatusLabel: lifecycleStatusLabel(item.lifecycle.status, item.lifecycle.archived), lifecycleStatusLabel: lifecycleStatusLabel(item.lifecycle.status, item.lifecycle.archived),

View file

@ -7386,12 +7386,14 @@ components:
WorkflowRef: WorkflowRef:
type: object type: object
required: [slug, name] required: [slug, name, graph_name]
properties: properties:
slug: slug:
type: ["string", "null"] type: ["string", "null"]
name: name:
type: string type: ["string", "null"]
graph_name:
type: ["string", "null"]
AutomationRef: AutomationRef:
type: object type: object

View file

@ -29,8 +29,9 @@ fn run_summary_json_matches_openapi_shape() {
title: "API title".to_string(), title: "API title".to_string(),
goal: String::new(), goal: String::new(),
workflow: WorkflowRef { workflow: WorkflowRef {
slug: Some("workflow".to_string()), slug: Some("workflow".to_string()),
name: "workflow".to_string(), name: Some("Ship workflow".to_string()),
graph_name: Some("GraphName".to_string()),
}, },
automation: None, automation: None,
repository: Some(RepositoryRef { repository: Some(RepositoryRef {
@ -89,7 +90,8 @@ fn run_summary_json_matches_openapi_shape() {
"goal": "", "goal": "",
"workflow": { "workflow": {
"slug": "workflow", "slug": "workflow",
"name": "workflow" "name": "Ship workflow",
"graph_name": "GraphName"
}, },
"automation": null, "automation": null,
"repository": { "repository": {
@ -159,7 +161,8 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
"title": "ship it", "title": "ship it",
"workflow": { "workflow": {
"slug": null, "slug": null,
"name": "unnamed" "name": null,
"graph_name": "GraphName"
}, },
"origin": { "origin": {
"kind": "api" "kind": "api"
@ -191,7 +194,8 @@ fn run_summary_deserializes_when_optional_fields_are_absent() {
assert_eq!(summary.id, run_id); assert_eq!(summary.id, run_id);
assert_eq!(summary.children_count, 0); assert_eq!(summary.children_count, 0);
assert_eq!(summary.workflow.name, "unnamed"); assert_eq!(summary.workflow.name, None);
assert_eq!(summary.workflow.graph_name.as_deref(), Some("GraphName"));
assert_eq!(summary.workflow.slug, None); assert_eq!(summary.workflow.slug, None);
assert_eq!(summary.goal, "ship it"); assert_eq!(summary.goal, "ship it");
assert_eq!(summary.title, "ship it"); assert_eq!(summary.title, "ship it");

View file

@ -49,6 +49,7 @@ pub(crate) async fn list_command(
"run_id": run.run_id(), "run_id": run.run_id(),
"parent_id": run.parent_id(), "parent_id": run.parent_id(),
"workflow_name": run.workflow_name(), "workflow_name": run.workflow_name(),
"workflow_graph_name": run.workflow_graph_name(),
"workflow_slug": run.workflow_slug(), "workflow_slug": run.workflow_slug(),
"status": run.status(), "status": run.status(),
"start_time": run.start_time(), "start_time": run.start_time(),
@ -138,7 +139,7 @@ pub(crate) async fn list_command(
); );
} }
row.extend([ row.extend([
run.workflow_name().cell(), run.workflow_display_name().cell(),
status_cell(run.status(), use_color), status_cell(run.status(), use_color),
dir_display.cell(), dir_display.cell(),
duration_display.cell(), duration_display.cell(),

View file

@ -25,14 +25,37 @@ impl ServerRunInfo {
self.run.parent_id self.run.parent_id
} }
pub(crate) fn workflow_name(&self) -> String { pub(crate) fn workflow_name(&self) -> Option<&str> {
self.run.workflow.name.clone() self.run.workflow.name.as_deref()
}
pub(crate) fn workflow_graph_name(&self) -> Option<&str> {
self.run.workflow.graph_name.as_deref()
} }
pub(crate) fn workflow_slug(&self) -> Option<&str> { pub(crate) fn workflow_slug(&self) -> Option<&str> {
self.run.workflow.slug.as_deref() self.run.workflow.slug.as_deref()
} }
pub(crate) fn workflow_display_name(&self) -> String {
self.workflow_name()
.or_else(|| self.workflow_graph_name())
.or_else(|| self.workflow_slug())
.unwrap_or("-")
.to_string()
}
pub(crate) fn workflow_matches(&self, pattern: &str) -> bool {
[
self.workflow_name(),
self.workflow_graph_name(),
self.workflow_slug(),
]
.into_iter()
.flatten()
.any(|value| value.contains(pattern))
}
pub(crate) fn status(&self) -> RunStatus { pub(crate) fn status(&self) -> RunStatus {
self.run.lifecycle.status self.run.lifecycle.status
} }
@ -132,7 +155,7 @@ pub(crate) fn filter_server_runs(
start_time.is_empty() || start_time.as_str() < before start_time.is_empty() || start_time.as_str() < before
}) })
}) })
.filter(|run| workflow.is_none_or(|pattern| run.workflow_name().contains(pattern))) .filter(|run| workflow.is_none_or(|pattern| run.workflow_matches(pattern)))
.filter(|run| { .filter(|run| {
labels.iter().all(|(key, value)| { labels.iter().all(|(key, value)| {
run.labels() run.labels()

View file

@ -144,19 +144,20 @@ fn ps_supports_global_flag_and_env_var() {
.expect("ps output should be an array") .expect("ps output should be an array")
.iter() .iter()
.map(|run| { .map(|run| {
let nullable_string = |field: &str| {
run.get(field)
.unwrap_or_else(|| panic!("{field} should be present"))
.as_str()
.map(str::to_string)
};
( (
run["run_id"] run["run_id"]
.as_str() .as_str()
.expect("run_id should be present") .expect("run_id should be present")
.to_string(), .to_string(),
run["workflow_name"] nullable_string("workflow_name"),
.as_str() nullable_string("workflow_graph_name"),
.expect("workflow_name should be present") nullable_string("workflow_slug"),
.to_string(),
run["workflow_slug"]
.as_str()
.expect("workflow_slug should be present")
.to_string(),
run["goal"] run["goal"]
.as_str() .as_str()
.expect("goal should be present") .expect("goal should be present")

View file

@ -558,7 +558,8 @@ async fn mcp_create_and_search_manage_real_runs_with_cli_auth() {
"run_id": "[RUN_ID]", "run_id": "[RUN_ID]",
"parent_id": null, "parent_id": null,
"children_count": 0, "children_count": 0,
"workflow_name": "Simple", "workflow_name": null,
"workflow_graph_name": "Simple",
"workflow_slug": "simple", "workflow_slug": "simple",
"status": "queued", "status": "queued",
"archived": false, "archived": false,
@ -1164,7 +1165,8 @@ async fn mcp_lifecycle_tools_manage_real_run() {
"run_id": "[RUN_ID]", "run_id": "[RUN_ID]",
"parent_id": null, "parent_id": null,
"children_count": 0, "children_count": 0,
"workflow_name": "Simple", "workflow_name": null,
"workflow_graph_name": "Simple",
"workflow_slug": "simple", "workflow_slug": "simple",
"status": "failed", "status": "failed",
"archived": false, "archived": false,

View file

@ -200,8 +200,9 @@ fn ps_all_json_lists_created_and_completed_runs() {
let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse"); let runs: Vec<Value> = serde_json::from_slice(&output.stdout).expect("ps JSON should parse");
assert_eq!(runs.len(), 2, "expected submitted + completed runs"); assert_eq!(runs.len(), 2, "expected submitted + completed runs");
assert!( assert!(
runs.iter().all(|run| run["workflow_name"] == "Simple"), runs.iter()
"all runs should belong to the Simple workflow: {runs:#?}" .all(|run| run["workflow_name"].is_null() && run["workflow_graph_name"] == "Simple"),
"all runs should expose Simple as the graph name, not a workflow name: {runs:#?}"
); );
assert!( assert!(
runs.iter() runs.iter()
@ -318,7 +319,8 @@ fn ps_filters_by_workflow_and_label() {
"workflow+label filter should isolate one run" "workflow+label filter should isolate one run"
); );
let run = &runs[0]; let run = &runs[0];
assert_eq!(run["workflow_name"], "Simple"); assert!(run["workflow_name"].is_null());
assert_eq!(run["workflow_graph_name"], "Simple");
assert_eq!(run["status"]["kind"], "succeeded"); assert_eq!(run["status"]["kind"], "succeeded");
assert_eq!(run["status"]["reason"], "completed"); assert_eq!(run["status"]["reason"], "completed");
assert_eq!(run["labels"]["suite"], "alpha"); assert_eq!(run["labels"]["suite"], "alpha");

View file

@ -148,7 +148,8 @@ pub(crate) fn remote_run_summary_json(
"goal": goal, "goal": goal,
"workflow": { "workflow": {
"slug": workflow_slug, "slug": workflow_slug,
"name": workflow_name "name": workflow_name,
"graph_name": null
}, },
"repository": { "repository": {
"name": "repo", "name": "repo",

View file

@ -452,6 +452,14 @@ impl WorkflowSettingsBuilder {
Ok(self.workflow_layer(layer)) Ok(self.workflow_layer(layer))
} }
pub fn workflow_toml_with_run_layer(self, source: &str, run: RunLayer) -> Result<Self> {
let mut layer = source
.parse::<SettingsLayer>()
.map_err(|err| Error::parse("Failed to parse settings file", err))?;
layer.run = Some(run);
Ok(self.workflow_layer(layer))
}
pub fn workflow_file(self, path: &Path) -> Result<Self> { pub fn workflow_file(self, path: &Path) -> Result<Self> {
Ok(self.workflow_layer(run::load_run_config(path)?)) Ok(self.workflow_layer(run::load_run_config(path)?))
} }

View file

@ -33,20 +33,21 @@ pub(super) type ToolResult<T> = Result<T, ToolError>;
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct RunSummaryResult { pub(crate) struct RunSummaryResult {
pub(crate) run_id: String, pub(crate) run_id: String,
pub(crate) parent_id: Option<String>, pub(crate) parent_id: Option<String>,
pub(crate) children_count: u64, pub(crate) children_count: u64,
pub(crate) workflow_name: String, pub(crate) workflow_name: Option<String>,
pub(crate) workflow_slug: Option<String>, pub(crate) workflow_graph_name: Option<String>,
pub(crate) status: String, pub(crate) workflow_slug: Option<String>,
pub(crate) archived: bool, pub(crate) status: String,
pub(crate) created_at: String, pub(crate) archived: bool,
pub(crate) started_at: Option<String>, pub(crate) created_at: String,
pub(crate) completed_at: Option<String>, pub(crate) started_at: Option<String>,
pub(crate) labels: HashMap<String, String>, pub(crate) completed_at: Option<String>,
pub(crate) source_directory: Option<String>, pub(crate) labels: HashMap<String, String>,
pub(crate) repo_origin_url: Option<String>, pub(crate) source_directory: Option<String>,
pub(crate) goal: String, pub(crate) repo_origin_url: Option<String>,
pub(crate) goal: String,
} }
pub(crate) fn success_result<T: Serialize>( pub(crate) fn success_result<T: Serialize>(
@ -91,29 +92,30 @@ pub(super) async fn retrieve_run(client: &Client, run_id: &RunId) -> ToolResult<
pub(super) fn run_summary_result(run: &Run) -> RunSummaryResult { pub(super) fn run_summary_result(run: &Run) -> RunSummaryResult {
RunSummaryResult { RunSummaryResult {
run_id: run.id.to_string(), run_id: run.id.to_string(),
parent_id: run.parent_id.map(|parent_id| parent_id.to_string()), parent_id: run.parent_id.map(|parent_id| parent_id.to_string()),
children_count: run.children_count, children_count: run.children_count,
workflow_name: run.workflow.name.clone(), workflow_name: run.workflow.name.clone(),
workflow_slug: run.workflow.slug.clone(), workflow_graph_name: run.workflow.graph_name.clone(),
status: run_status_kind(run.lifecycle.status).to_string(), workflow_slug: run.workflow.slug.clone(),
archived: run.lifecycle.archived, status: run_status_kind(run.lifecycle.status).to_string(),
created_at: run.timestamps.created_at.to_rfc3339(), archived: run.lifecycle.archived,
started_at: run created_at: run.timestamps.created_at.to_rfc3339(),
started_at: run
.timestamps .timestamps
.started_at .started_at
.map(|timestamp| timestamp.to_rfc3339()), .map(|timestamp| timestamp.to_rfc3339()),
completed_at: run completed_at: run
.timestamps .timestamps
.completed_at .completed_at
.map(|timestamp| timestamp.to_rfc3339()), .map(|timestamp| timestamp.to_rfc3339()),
labels: run.labels.clone(), labels: run.labels.clone(),
source_directory: run.source_directory.clone(), source_directory: run.source_directory.clone(),
repo_origin_url: run repo_origin_url: run
.repository .repository
.as_ref() .as_ref()
.and_then(|repository| repository.origin_url.clone()), .and_then(|repository| repository.origin_url.clone()),
goal: run.goal.clone(), goal: run.goal.clone(),
} }
} }
@ -161,8 +163,9 @@ mod tests {
title: "test".to_string(), title: "test".to_string(),
goal: "test".to_string(), goal: "test".to_string(),
workflow: WorkflowRef { workflow: WorkflowRef {
slug: Some("simple".to_string()), slug: Some("simple".to_string()),
name: "Simple".to_string(), name: Some("Simple".to_string()),
graph_name: Some("GraphName".to_string()),
}, },
automation: None, automation: None,
repository: None, repository: None,
@ -200,6 +203,8 @@ mod tests {
assert_eq!(summary.parent_id, Some(parent_id.to_string())); assert_eq!(summary.parent_id, Some(parent_id.to_string()));
assert_eq!(summary.children_count, 3); assert_eq!(summary.children_count, 3);
assert_eq!(summary.workflow_name.as_deref(), Some("Simple"));
assert_eq!(summary.workflow_graph_name.as_deref(), Some("GraphName"));
} }
fn run_id(raw: &str) -> RunId { fn run_id(raw: &str) -> RunId {

View file

@ -84,21 +84,22 @@ pub(crate) struct SearchRunsResult {
#[derive(Debug, Serialize, JsonSchema)] #[derive(Debug, Serialize, JsonSchema)]
pub(crate) struct SearchRunSummaryResult { pub(crate) struct SearchRunSummaryResult {
pub(crate) run_id: String, pub(crate) run_id: String,
pub(crate) parent_id: Option<String>, pub(crate) parent_id: Option<String>,
pub(crate) children_count: u64, pub(crate) children_count: u64,
pub(crate) workflow_name: String, pub(crate) workflow_name: Option<String>,
pub(crate) workflow_slug: Option<String>, pub(crate) workflow_graph_name: Option<String>,
pub(crate) status: String, pub(crate) workflow_slug: Option<String>,
pub(crate) archived: bool, pub(crate) status: String,
pub(crate) created_at: String, pub(crate) archived: bool,
pub(crate) started_at: Option<String>, pub(crate) created_at: String,
pub(crate) completed_at: Option<String>, pub(crate) started_at: Option<String>,
pub(crate) labels: HashMap<String, String>, pub(crate) completed_at: Option<String>,
pub(crate) source_directory: Option<String>, pub(crate) labels: HashMap<String, String>,
pub(crate) repo_origin_url: Option<String>, pub(crate) source_directory: Option<String>,
pub(crate) goal_preview: String, pub(crate) repo_origin_url: Option<String>,
pub(crate) goal_truncated: bool, pub(crate) goal_preview: String,
pub(crate) goal_truncated: bool,
} }
pub(crate) async fn search_runs( pub(crate) async fn search_runs(
@ -145,6 +146,7 @@ fn search_run_summary_result(run: &Run) -> SearchRunSummaryResult {
parent_id, parent_id,
children_count, children_count,
workflow_name, workflow_name,
workflow_graph_name,
workflow_slug, workflow_slug,
status, status,
archived, archived,
@ -163,6 +165,7 @@ fn search_run_summary_result(run: &Run) -> SearchRunSummaryResult {
parent_id, parent_id,
children_count, children_count,
workflow_name, workflow_name,
workflow_graph_name,
workflow_slug, workflow_slug,
status, status,
archived, archived,
@ -206,7 +209,9 @@ fn filter_sort_and_page_runs(
} }
if let Some(workflow) = raw.workflow.as_deref() { if let Some(workflow) = raw.workflow.as_deref() {
runs.retain(|run| { runs.retain(|run| {
run.workflow.name == workflow || run.workflow.slug.as_deref() == Some(workflow) run.workflow.name.as_deref() == Some(workflow)
|| run.workflow.graph_name.as_deref() == Some(workflow)
|| run.workflow.slug.as_deref() == Some(workflow)
}); });
} }
if let Some(labels) = raw.labels.as_ref() { if let Some(labels) = raw.labels.as_ref() {
@ -363,6 +368,8 @@ mod tests {
assert_eq!(summary.parent_id, Some(parent_id.to_string())); assert_eq!(summary.parent_id, Some(parent_id.to_string()));
assert_eq!(summary.children_count, 4); assert_eq!(summary.children_count, 4);
assert_eq!(summary.workflow_name.as_deref(), Some("Simple"));
assert_eq!(summary.workflow_graph_name.as_deref(), Some("GraphName"));
assert!(summary.goal_truncated); assert!(summary.goal_truncated);
assert!(summary.goal_preview.len() < run.goal.len()); assert!(summary.goal_preview.len() < run.goal.len());
assert!(!summary.goal_preview.contains("tail-marker")); assert!(!summary.goal_preview.contains("tail-marker"));
@ -427,8 +434,9 @@ mod tests {
title: "test".to_string(), title: "test".to_string(),
goal: "test".to_string(), goal: "test".to_string(),
workflow: WorkflowRef { workflow: WorkflowRef {
slug: Some("simple".to_string()), slug: Some("simple".to_string()),
name: "Simple".to_string(), name: Some("Simple".to_string()),
graph_name: Some("GraphName".to_string()),
}, },
automation: None, automation: None,
repository: None, repository: None,

View file

@ -101,7 +101,7 @@ pub(crate) async fn resolve_run(
&params.selector, &params.selector,
|run| run.id.to_string(), |run| run.id.to_string(),
|run| run.workflow.slug.clone(), |run| run.workflow.slug.clone(),
|run| Some(run.workflow.name.clone()), |run| run.workflow.name.clone(),
|run| run.timestamps.created_at, |run| run.timestamps.created_at,
|run| run.timestamps.created_at.to_rfc3339(), |run| run.timestamps.created_at.to_rfc3339(),
|run| { |run| {
@ -1061,8 +1061,9 @@ mod runs {
title: fabro_types::infer_run_title(goal), title: fabro_types::infer_run_title(goal),
goal: goal.into(), goal: goal.into(),
workflow: WorkflowRef { workflow: WorkflowRef {
slug: Some(workflow_slug.into()), slug: Some(workflow_slug.into()),
name: workflow_name.into(), name: Some(workflow_name.into()),
graph_name: None,
}, },
automation: None, automation: None,
repository: Some(RepositoryRef::from_origin_and_source( repository: Some(RepositoryRef::from_origin_and_source(

View file

@ -93,7 +93,6 @@ pub(crate) fn prepare_manifest(
let args_overrides = let args_overrides =
manifest_args_overrides(manifest.args.as_ref()).context("failed to parse manifest args")?; manifest_args_overrides(manifest.args.as_ref()).context("failed to parse manifest args")?;
let workflow_run_layer = root_workflow_run_layer(&workflow_input)?;
let mut workflow_settings_builder = let mut workflow_settings_builder =
WorkflowSettingsBuilder::new().server_run_defaults(manifest_run_defaults.clone()); WorkflowSettingsBuilder::new().server_run_defaults(manifest_run_defaults.clone());
if let Some(run) = args_overrides.run { if let Some(run) = args_overrides.run {
@ -102,9 +101,10 @@ pub(crate) fn prepare_manifest(
if let Some(cli) = args_overrides.cli { if let Some(cli) = args_overrides.cli {
workflow_settings_builder = workflow_settings_builder.cli_overrides(cli); workflow_settings_builder = workflow_settings_builder.cli_overrides(cli);
} }
if !workflow_run_layer.eq(&RunLayer::default()) { if let Some(config) = workflow_input.config.as_ref() {
workflow_settings_builder = let workflow_run_layer = workflow_run_layer_with_resolved_dockerfile(&workflow_input)?;
workflow_settings_builder.workflow_run_layer(workflow_run_layer); workflow_settings_builder = workflow_settings_builder
.workflow_toml_with_run_layer(&config.source, workflow_run_layer)?;
} }
for config in manifest for config in manifest
.configs .configs
@ -316,11 +316,11 @@ fn workflow_files_from_manifest(
Ok(bundled) Ok(bundled)
} }
fn root_workflow_run_layer(workflow: &BundledWorkflow) -> Result<RunLayer> { fn workflow_run_layer_with_resolved_dockerfile(workflow: &BundledWorkflow) -> Result<RunLayer> {
let Some(config) = workflow.config.as_ref() else { let config = workflow
return Ok(RunLayer::default()); .config
}; .as_ref()
.expect("workflow config should exist before resolving its run layer");
// Parse via `SettingsLayer` so unknown nested keys (like a stale // Parse via `SettingsLayer` so unknown nested keys (like a stale
// `[server.integrations.github.permissions]` after the move to // `[server.integrations.github.permissions]` after the move to
// `[run.integrations.github.permissions]`) trip // `[run.integrations.github.permissions]`) trip
@ -1955,6 +1955,126 @@ app_id = "fixture-app-id"
assert!(settings_json.pointer("/server").is_none()); assert!(settings_json.pointer("/server").is_none());
} }
#[test]
fn prepare_manifest_preserves_bundled_workflow_metadata() {
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().config =
Some(types::ManifestWorkflowConfig {
path: "workflow.toml".to_string(),
source: r#"
_version = 1
[workflow]
name = "Ship feature"
description = "Move the feature through review"
[workflow.metadata]
team = "platform"
priority = "high"
[run.sandbox]
provider = "local"
"#
.to_string(),
});
let prepared = prepare_manifest(
&manifest_run_defaults(Some(&default_settings_fixture())),
&manifest,
)
.unwrap();
assert_eq!(
prepared.settings.workflow.name.as_deref(),
Some("Ship feature")
);
assert_eq!(
prepared.settings.workflow.description.as_deref(),
Some("Move the feature through review")
);
assert_eq!(
prepared
.settings
.workflow
.metadata
.get("team")
.map(String::as_str),
Some("platform")
);
assert_eq!(
prepared
.settings
.workflow
.metadata
.get("priority")
.map(String::as_str),
Some("high")
);
}
#[test]
fn prepare_manifest_keeps_missing_metadata_names_absent() {
let mut manifest = minimal_manifest();
manifest.target.identifier = "release-flow".to_string();
manifest.workflows.get_mut("workflow.fabro").unwrap().source = r"
digraph GraphName {
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}
"
.to_string();
manifest.configs.push(types::ManifestConfig {
path: Some(".fabro/project.toml".to_string()),
source: Some(
r#"_version = 1
[project.metadata]
team = "platform"
"#
.to_string(),
),
type_: types::ManifestConfigType::Project,
});
let prepared = prepare_manifest(
&manifest_run_defaults(Some(&default_settings_fixture())),
&manifest,
)
.unwrap();
assert_eq!(prepared.settings.workflow.name, None);
assert_eq!(prepared.settings.project.name, None);
}
#[test]
fn prepare_manifest_preserves_explicit_project_name() {
let mut manifest = minimal_manifest();
manifest.configs.push(types::ManifestConfig {
path: Some(".fabro/project.toml".to_string()),
source: Some(
r#"_version = 1
[project]
name = "Control Plane"
"#
.to_string(),
),
type_: types::ManifestConfigType::Project,
});
let prepared = prepare_manifest(
&manifest_run_defaults(Some(&default_settings_fixture())),
&manifest,
)
.unwrap();
assert_eq!(
prepared.settings.project.name.as_deref(),
Some("Control Plane")
);
}
#[tokio::test] #[tokio::test]
async fn invalid_preflight_returns_diagnostics_without_runtime_checks() { async fn invalid_preflight_returns_diagnostics_without_runtime_checks() {
let state = crate::test_support::test_app_state(); let state = crate::test_support::test_app_state();
@ -2074,6 +2194,74 @@ provider = "local"
); );
} }
#[test]
fn prepare_manifest_does_not_backfill_missing_project_and_workflow_names() {
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().config =
Some(types::ManifestWorkflowConfig {
path: "workflow.toml".to_string(),
source: "_version = 1\n".to_string(),
});
manifest.configs.push(types::ManifestConfig {
path: Some("/tmp/project/.fabro/project.toml".to_string()),
source: Some("_version = 1\n".to_string()),
type_: types::ManifestConfigType::Project,
});
let prepared = prepare_manifest(
&manifest_run_defaults(Some(&default_settings_fixture())),
&manifest,
)
.unwrap();
assert_eq!(prepared.settings.project.name, None);
assert_eq!(prepared.settings.workflow.name, None);
}
#[test]
fn prepare_manifest_preserves_explicit_project_and_workflow_names() {
let mut manifest = minimal_manifest();
manifest.workflows.get_mut("workflow.fabro").unwrap().config =
Some(types::ManifestWorkflowConfig {
path: "workflow.toml".to_string(),
source: r#"
_version = 1
[workflow]
name = "Workflow Config Name"
"#
.to_string(),
});
manifest.configs.push(types::ManifestConfig {
path: Some("/tmp/project/.fabro/project.toml".to_string()),
source: Some(
r#"
_version = 1
[project]
name = "Project Config Name"
"#
.to_string(),
),
type_: types::ManifestConfigType::Project,
});
let prepared = prepare_manifest(
&manifest_run_defaults(Some(&default_settings_fixture())),
&manifest,
)
.unwrap();
assert_eq!(
prepared.settings.project.name.as_deref(),
Some("Project Config Name")
);
assert_eq!(
prepared.settings.workflow.name.as_deref(),
Some("Workflow Config Name")
);
}
#[tokio::test] #[tokio::test]
async fn preflight_daytona_without_github_credentials_returns_report() { async fn preflight_daytona_without_github_credentials_returns_report() {
let state = crate::test_support::test_app_state(); let state = crate::test_support::test_app_state();
@ -2310,15 +2498,16 @@ digraph Demo {
); );
} }
mod root_workflow_run_layer_tests { mod workflow_run_layer_with_resolved_dockerfile_tests {
//! `root_workflow_run_layer` parses bundled workflow.toml through //! `workflow_run_layer_with_resolved_dockerfile` parses bundled
//! the strict `SettingsLayer` schema, so unknown fields anywhere in //! workflow.toml through the strict `SettingsLayer` schema, so
//! the document trip `deny_unknown_fields`. //! unknown fields anywhere in the document trip
//! `deny_unknown_fields`.
use fabro_types::ManifestPath; use fabro_types::ManifestPath;
use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig}; use fabro_workflow::workflow_bundle::{BundledWorkflow, ParsedWorkflowConfig};
use super::super::root_workflow_run_layer; use super::super::workflow_run_layer_with_resolved_dockerfile;
fn workflow_with_config(source: &str) -> BundledWorkflow { fn workflow_with_config(source: &str) -> BundledWorkflow {
BundledWorkflow { BundledWorkflow {
@ -2343,7 +2532,8 @@ issues = "read"
"#, "#,
); );
let run = root_workflow_run_layer(&workflow).expect("workflow.toml should parse"); let run = workflow_run_layer_with_resolved_dockerfile(&workflow)
.expect("workflow.toml should parse");
let github = run let github = run
.integrations .integrations
.as_ref() .as_ref()
@ -2367,7 +2557,7 @@ issues = "read"
"#, "#,
); );
let err = root_workflow_run_layer(&workflow) let err = workflow_run_layer_with_resolved_dockerfile(&workflow)
.expect_err("stale [server.integrations.github.permissions] should be rejected"); .expect_err("stale [server.integrations.github.permissions] should be rejected");
let message = format!("{err:#}"); let message = format!("{err:#}");
assert!( assert!(
@ -2389,8 +2579,8 @@ contents = "read"
"#, "#,
); );
let run = let run = workflow_run_layer_with_resolved_dockerfile(&workflow)
root_workflow_run_layer(&workflow).expect("workflow + run blocks should parse"); .expect("workflow + run blocks should parse");
assert!(run.integrations.is_some()); assert!(run.integrations.is_some());
} }
} }

View file

@ -1364,7 +1364,7 @@ fn build_disk_usage_response(
if verbose { if verbose {
run_rows.push(DiskUsageRunRow { run_rows.push(DiskUsageRunRow {
run_id: Some(run.run_id().to_string()), run_id: Some(run.run_id().to_string()),
workflow_name: Some(run.workflow_name()), workflow_name: Some(run.workflow_display_name()),
status: Some(run.status().to_string()), status: Some(run.status().to_string()),
start_time: Some(run.start_time()), start_time: Some(run.start_time()),
size_bytes: Some(to_i64(size)), size_bytes: Some(to_i64(size)),
@ -1459,7 +1459,7 @@ fn build_prune_plan(
.map(|run| PruneRunEntry { .map(|run| PruneRunEntry {
run_id: Some(run.run_id().to_string()), run_id: Some(run.run_id().to_string()),
dir_name: Some(run.dir_name.clone()), dir_name: Some(run.dir_name.clone()),
workflow_name: Some(run.workflow_name()), workflow_name: Some(run.workflow_display_name()),
size_bytes: Some(to_i64(dir_size(&run.path))), size_bytes: Some(to_i64(dir_size(&run.path))),
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();

View file

@ -431,7 +431,7 @@ async fn resolve_run(
&query.selector, &query.selector,
|run| run.id.to_string(), |run| run.id.to_string(),
|run| run.workflow.slug.clone(), |run| run.workflow.slug.clone(),
|run| Some(run.workflow.name.clone()), |run| run.workflow.name.clone(),
|run| run.id.created_at(), |run| run.id.created_at(),
|run| run.id.created_at().to_rfc3339(), |run| run.id.created_at().to_rfc3339(),
|run| { |run| {

View file

@ -3175,6 +3175,28 @@ async fn create_run_for_target(app: &Router, target_path: &str, dot_source: &str
body["id"].as_str().unwrap().to_string() body["id"].as_str().unwrap().to_string()
} }
async fn create_run_for_target_with_workflow_name(
app: &Router,
target_path: &str,
dot_source: &str,
workflow_name: &str,
) -> String {
let mut manifest = manifest_json(target_path, dot_source);
manifest["workflows"][target_path]["config"] = serde_json::json!({
"path": "workflow.toml",
"source": format!("_version = 1\n\n[workflow]\nname = {workflow_name:?}\n"),
});
let req = Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(serde_json::to_string(&manifest).unwrap()))
.unwrap();
let response = app.clone().oneshot(req).await.unwrap();
let body = body_json(response.into_body()).await;
body["id"].as_str().unwrap().to_string()
}
fn named_workflow_dot(name: &str, goal: &str) -> String { fn named_workflow_dot(name: &str, goal: &str) -> String {
format!( format!(
r#"digraph {name} {{ r#"digraph {name} {{
@ -5254,16 +5276,18 @@ async fn resolve_run_prefers_most_recent_exact_workflow_slug_match() {
#[tokio::test] #[tokio::test]
async fn resolve_run_prefers_most_recent_collapsed_workflow_name_match() { async fn resolve_run_prefers_most_recent_collapsed_workflow_name_match() {
let app = test_app_with(); let app = test_app_with();
let older_id = create_run_for_target( let older_id = create_run_for_target_with_workflow_name(
&app, &app,
"nightly-alpha.fabro", "nightly-alpha.fabro",
&named_workflow_dot("Nightly_Build", "older"), &named_workflow_dot("OlderNightlyGraph", "older"),
"Nightly_Build",
) )
.await; .await;
let newer_id = create_run_for_target( let newer_id = create_run_for_target_with_workflow_name(
&app, &app,
"nightly-beta.fabro", "nightly-beta.fabro",
&named_workflow_dot("Nightly_Build", "newer"), &named_workflow_dot("NewerNightlyGraph", "newer"),
"Nightly_Build",
) )
.await; .await;
@ -7330,6 +7354,66 @@ async fn create_run_persists_run_spec() {
assert_eq!(run_state.spec.graph.name, "Test"); assert_eq!(run_state.spec.graph.name, "Test");
} }
#[tokio::test]
async fn create_run_keeps_missing_project_and_workflow_names_absent() {
let state = test_app_state();
let app = crate::test_support::build_test_router(Arc::clone(&state));
let manifest = serde_json::json!({
"version": 1,
"cwd": "/tmp/project",
"target": {
"identifier": "workflow.fabro",
"path": "workflow.fabro",
},
"configs": [
{
"path": "/tmp/project/.fabro/project.toml",
"source": "_version = 1\n",
"type": "project",
}
],
"workflows": {
"workflow.fabro": {
"source": "digraph Demo { start [shape=Mdiamond] exit [shape=Msquare] start -> exit }",
"config": {
"path": "workflow.toml",
"source": "_version = 1\n",
},
"files": {},
}
},
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(api("/runs"))
.header("content-type", "application/json")
.body(Body::from(manifest.to_string()))
.unwrap(),
)
.await
.unwrap();
let body = body_json(response.into_body()).await;
let run_id = body["id"].as_str().unwrap().parse::<RunId>().unwrap();
let run_state = state
.store
.open_run_reader(&run_id)
.await
.unwrap()
.state()
.await
.unwrap();
assert_eq!(run_state.spec.settings.project.name.as_deref(), None);
assert_eq!(run_state.spec.settings.workflow.name.as_deref(), None);
assert_eq!(run_state.spec.graph_name(), Some("Demo"));
}
#[tokio::test] #[tokio::test]
async fn stage_artifact_upload_rejects_invalid_filename() { async fn stage_artifact_upload_rejects_invalid_filename() {
let state = test_app_state(); let state = test_app_state();
@ -10713,7 +10797,8 @@ async fn boards_runs_returns_run_list_items_with_board_columns() {
assert!(item["title"].is_string()); assert!(item["title"].is_string());
assert!(item["repository"].is_object()); assert!(item["repository"].is_object());
assert!(item["workflow"]["slug"].is_string() || item["workflow"]["slug"].is_null()); assert!(item["workflow"]["slug"].is_string() || item["workflow"]["slug"].is_null());
assert!(item["workflow"]["name"].is_string()); assert!(item["workflow"]["name"].is_string() || item["workflow"]["name"].is_null());
assert!(item["workflow"]["graph_name"].is_string());
assert!(item["labels"].is_object()); assert!(item["labels"].is_object());
assert!(run_json_status(item).is_object()); assert!(run_json_status(item).is_object());
assert!(item["timestamps"]["created_at"].is_string()); assert!(item["timestamps"]["created_at"].is_string());

View file

@ -626,11 +626,6 @@ fn stage_at_completed_visit<'a>(
} }
pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run { pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
let workflow_name = if state.spec.graph.name.is_empty() {
"unnamed".to_string()
} else {
state.spec.graph.name.clone()
};
let goal = state.spec.graph.goal().to_string(); let goal = state.spec.graph.goal().to_string();
let diff_summary = state let diff_summary = state
.conclusion .conclusion
@ -683,8 +678,9 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {
title: state.title().into_owned(), title: state.title().into_owned(),
goal, goal,
workflow: WorkflowRef { workflow: WorkflowRef {
slug: state.spec.workflow_slug.clone(), slug: state.spec.workflow_slug.clone(),
name: workflow_name, name: state.spec.workflow_name().map(ToOwned::to_owned),
graph_name: state.spec.graph_name().map(ToOwned::to_owned),
}, },
automation: None, automation: None,
repository: Some(RepositoryRef::from_origin_and_source( repository: Some(RepositoryRef::from_origin_and_source(
@ -2038,6 +2034,42 @@ mod tests {
); );
} }
#[test]
fn summary_preserves_absent_workflow_name_and_reports_graph_name() {
let mut state = initialized_projection();
state.spec = fabro_types::RunSpec {
run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(),
graph: fabro_types::Graph::new("GraphName"),
graph_source: None,
workflow_slug: Some("release-flow".to_string()),
source_directory: Some("/tmp/repo".to_string()),
git: None,
labels: HashMap::new(),
provenance: None,
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
};
let summary = build_summary(&state, &fixtures::RUN_1);
assert_eq!(summary.workflow.name, None);
assert_eq!(summary.workflow.graph_name.as_deref(), Some("GraphName"));
assert_eq!(summary.workflow.slug.as_deref(), Some("release-flow"));
}
#[test]
fn summary_uses_explicit_workflow_name() {
let mut state = initialized_projection();
state.spec.settings.workflow.name = Some("Ship workflow".to_string());
let summary = build_summary(&state, &fixtures::RUN_1);
assert_eq!(summary.workflow.name.as_deref(), Some("Ship workflow"));
assert_eq!(summary.workflow.graph_name.as_deref(), Some("test"));
}
#[test] #[test]
fn run_created_title_populates_projection_and_summary() { fn run_created_title_populates_projection_and_summary() {
let event = test_raw_event( let event = test_raw_event(

View file

@ -626,7 +626,8 @@ mod tests {
assert_eq!(summary.len(), 2); assert_eq!(summary.len(), 2);
assert_eq!(summary[0].id, test_run_id("run-2")); assert_eq!(summary[0].id, test_run_id("run-2"));
assert_eq!(summary[1].id, test_run_id("run-1")); assert_eq!(summary[1].id, test_run_id("run-1"));
assert_eq!(summary[1].workflow.name, "night-sky"); assert_eq!(summary[1].workflow.name, None);
assert_eq!(summary[1].workflow.graph_name.as_deref(), Some("night-sky"));
assert_eq!(summary[1].goal, "map the constellations"); assert_eq!(summary[1].goal, "map the constellations");
assert_eq!(summary[1].lifecycle.status, RunStatus::Succeeded { assert_eq!(summary[1].lifecycle.status, RunStatus::Succeeded {
reason: SuccessReason::Completed, reason: SuccessReason::Completed,

View file

@ -123,6 +123,25 @@ impl RunSpec {
self.workflow_slug.as_deref() self.workflow_slug.as_deref()
} }
#[must_use]
pub fn workflow_name(&self) -> Option<&str> {
self.settings.workflow.name.as_deref()
}
#[must_use]
pub fn graph_name(&self) -> Option<&str> {
if self.graph.name.is_empty() {
None
} else {
Some(self.graph.name.as_str())
}
}
#[must_use]
pub fn project_name(&self) -> Option<&str> {
self.settings.project.name.as_deref()
}
#[must_use] #[must_use]
pub fn source_directory(&self) -> Option<&str> { pub fn source_directory(&self) -> Option<&str> {
self.source_directory.as_deref() self.source_directory.as_deref()

View file

@ -49,8 +49,11 @@ pub struct Run {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkflowRef { pub struct WorkflowRef {
#[serde(default)] #[serde(default)]
pub slug: Option<String>, pub slug: Option<String>,
pub name: String, #[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub graph_name: Option<String>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -2,21 +2,34 @@ use std::collections::HashMap;
use fabro_types::graph::Graph; use fabro_types::graph::Graph;
use fabro_types::run::{DirtyStatus, GitContext, PreRunPushOutcome, RunSpec}; use fabro_types::run::{DirtyStatus, GitContext, PreRunPushOutcome, RunSpec};
use fabro_types::settings::{ProjectNamespace, WorkflowNamespace};
use fabro_types::{WorkflowSettings, fixtures}; use fabro_types::{WorkflowSettings, fixtures};
fn sample_run_spec() -> RunSpec { fn sample_run_spec() -> RunSpec {
let settings = WorkflowSettings {
project: ProjectNamespace {
name: Some("Control Plane".to_string()),
..ProjectNamespace::default()
},
workflow: WorkflowNamespace {
name: Some("Ship workflow".to_string()),
..WorkflowNamespace::default()
},
..WorkflowSettings::default()
};
RunSpec { RunSpec {
run_id: fixtures::RUN_1, run_id: fixtures::RUN_1,
settings: WorkflowSettings::default(), settings,
graph: Graph::new("ship"), graph: Graph::new("ship"),
graph_source: None, graph_source: None,
workflow_slug: Some("demo".to_string()), workflow_slug: Some("demo".to_string()),
source_directory: Some("/Users/client/project".to_string()), source_directory: Some("/Users/client/project".to_string()),
labels: HashMap::from([("team".to_string(), "platform".to_string())]), labels: HashMap::from([("team".to_string(), "platform".to_string())]),
provenance: None, provenance: None,
manifest_blob: None, manifest_blob: None,
definition_blob: None, definition_blob: None,
git: Some(GitContext { git: Some(GitContext {
origin_url: "https://github.com/fabro-sh/fabro.git".to_string(), origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
branch: "main".to_string(), branch: "main".to_string(),
sha: Some("abc123".to_string()), sha: Some("abc123".to_string()),
@ -26,7 +39,7 @@ fn sample_run_spec() -> RunSpec {
repo_origin_url: "https://github.com/fabro-sh/fabro.git".to_string(), repo_origin_url: "https://github.com/fabro-sh/fabro.git".to_string(),
}, },
}), }),
fork_source_ref: None, fork_source_ref: None,
} }
} }
@ -36,7 +49,9 @@ fn run_spec_getters_return_declared_fields() {
assert_eq!(run_spec.id(), fixtures::RUN_1); assert_eq!(run_spec.id(), fixtures::RUN_1);
assert_eq!(run_spec.graph().name, "ship"); assert_eq!(run_spec.graph().name, "ship");
assert_eq!(run_spec.settings(), &WorkflowSettings::default()); assert_eq!(run_spec.workflow_name(), Some("Ship workflow"));
assert_eq!(run_spec.graph_name(), Some("ship"));
assert_eq!(run_spec.project_name(), Some("Control Plane"));
assert_eq!(run_spec.workflow_slug(), Some("demo")); assert_eq!(run_spec.workflow_slug(), Some("demo"));
assert_eq!(run_spec.source_directory(), Some("/Users/client/project")); assert_eq!(run_spec.source_directory(), Some("/Users/client/project"));
assert_eq!( assert_eq!(
@ -53,3 +68,16 @@ fn run_spec_getters_return_declared_fields() {
); );
assert_eq!(run_spec.base_branch(), Some("main")); assert_eq!(run_spec.base_branch(), Some("main"));
} }
#[test]
fn run_spec_name_getters_do_not_synthesize_from_graph_or_slug() {
let mut run_spec = sample_run_spec();
run_spec.settings.workflow.name = None;
run_spec.settings.project.name = None;
run_spec.workflow_slug = Some("release-flow".to_string());
run_spec.graph = Graph::new("GraphName");
assert_eq!(run_spec.workflow_name(), None);
assert_eq!(run_spec.project_name(), None);
assert_eq!(run_spec.graph_name(), Some("GraphName"));
}

View file

@ -62,11 +62,16 @@ impl RunInfo {
.expect("RunInfo must have a run id") .expect("RunInfo must have a run id")
} }
pub fn workflow_name(&self) -> String { pub fn workflow_name(&self) -> Option<&str> {
self.summary.as_ref().map_or_else( self.summary
|| "[no run spec]".to_string(), .as_ref()
|summary| summary.workflow.name.clone(), .and_then(|summary| summary.workflow.name.as_deref())
) }
pub fn workflow_graph_name(&self) -> Option<&str> {
self.summary
.as_ref()
.and_then(|summary| summary.workflow.graph_name.as_deref())
} }
pub fn workflow_slug(&self) -> Option<&str> { pub fn workflow_slug(&self) -> Option<&str> {
@ -75,6 +80,30 @@ impl RunInfo {
.and_then(|summary| summary.workflow.slug.as_deref()) .and_then(|summary| summary.workflow.slug.as_deref())
} }
pub fn workflow_display_name(&self) -> String {
self.summary.as_ref().map_or_else(
|| "[no run spec]".to_string(),
|_| {
self.workflow_name()
.or_else(|| self.workflow_graph_name())
.or_else(|| self.workflow_slug())
.unwrap_or("-")
.to_string()
},
)
}
fn workflow_matches(&self, pattern: &str) -> bool {
[
self.workflow_name(),
self.workflow_graph_name(),
self.workflow_slug(),
]
.into_iter()
.flatten()
.any(|value| value.contains(pattern))
}
pub fn status(&self) -> RunStatus { pub fn status(&self) -> RunStatus {
self.summary self.summary
.as_ref() .as_ref()
@ -300,7 +329,7 @@ pub fn filter_runs(
} }
} }
if let Some(pattern) = workflow { if let Some(pattern) = workflow {
if !run.workflow_name().contains(pattern) { if !run.workflow_matches(pattern) {
return false; return false;
} }
} }
@ -352,7 +381,7 @@ fn resolve_run_from_infos(runs: &[RunInfo], identifier: &str) -> Result<RunInfo>
"{} created_at={} workflow={} origin={}", "{} created_at={} workflow={} origin={}",
run.run_id(), run.run_id(),
run.run_id().created_at().to_rfc3339(), run.run_id().created_at().to_rfc3339(),
run.workflow_name(), run.workflow_display_name(),
run.repo_origin_url().unwrap_or("-") run.repo_origin_url().unwrap_or("-")
) )
}) })
@ -376,9 +405,14 @@ fn resolve_run_from_infos(runs: &[RunInfo], identifier: &str) -> Result<RunInfo>
return true; return true;
} }
} }
let name_lower = run.workflow_name().to_lowercase(); [run.workflow_name(), run.workflow_graph_name()]
name_lower.contains(&id_lower) .into_iter()
|| collapse_separators(&name_lower).contains(&id_collapsed) .flatten()
.any(|name| {
let name_lower = name.to_lowercase();
name_lower.contains(&id_lower)
|| collapse_separators(&name_lower).contains(&id_collapsed)
})
}) })
.max_by_key(|run| run.run_id().created_at()); .max_by_key(|run| run.run_id().created_at());

View file

@ -16,6 +16,7 @@
export interface WorkflowRef { export interface WorkflowRef {
'slug': string | null; 'slug': string | null;
'name': string; 'name': string | null;
'graph_name': string | null;
} }