From e13de9faafbc61165d6b5115eecbc2f327271eef Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp <19+brynary@users.noreply.github.com> Date: Wed, 27 May 2026 11:52:57 -0400 Subject: [PATCH] feat(automation): persist automation refs on runs (#428) ## Summary Adds durable automation metadata to workflow runs so automation-triggered runs can carry their automation and trigger references through creation, stored events, projections, summaries, fork, retry, and API surfaces. This also introduces the new `fabro-automation` crate with typed automation IDs, TOML parsing/validation, revision hashing, and a file-backed automation store. The store avoids overwriting malformed existing TOML files on create and keeps read access from being blocked by mutation disk I/O. ## Changes - Add `AutomationRef` propagation through `RunSpec`, `run.created`, store projections, summaries, fork, retry, and related tests. - Add `fabro-automation` domain/store crate for automation TOML definitions, trigger validation, revisions, create/replace/delete, and load behavior. - Update OpenAPI and regenerated TypeScript client types for `RunSpec.automation` and `AutomationRef.trigger_id`. - Add API/type regression coverage for the new automation fields. - Harden automation store create semantics so skipped malformed files still reserve their path. ## Verification - `cargo nextest run -p fabro-automation` - `cargo +nightly-2026-04-14 clippy -p fabro-automation --all-targets -- -D warnings` - `cargo nextest run -p fabro-api` - `cargo nextest run -p fabro-types run_spec_round_trips_templated_settings run_created_props_round_trip_templated_settings` - `cd lib/packages/fabro-api-client && bun run typecheck` - `cargo +nightly-2026-04-14 fmt --check --all` - `git diff --check` --- Cargo.lock | 137 ++++- Cargo.toml | 1 + docs/public/api-reference/fabro-api.yaml | 6 + .../tests/run_projection_round_trip.rs | 1 + .../fabro-api/tests/run_summary_round_trip.rs | 20 +- lib/crates/fabro-automation/Cargo.toml | 27 + lib/crates/fabro-automation/src/error.rs | 123 ++++ lib/crates/fabro-automation/src/id.rs | 239 ++++++++ lib/crates/fabro-automation/src/lib.rs | 12 + lib/crates/fabro-automation/src/model.rs | 540 ++++++++++++++++++ lib/crates/fabro-automation/src/store.rs | 456 +++++++++++++++ .../fabro-cli/src/commands/run/attach.rs | 1 + lib/crates/fabro-cli/tests/it/support/mod.rs | 1 + lib/crates/fabro-dump/src/lib.rs | 1 + lib/crates/fabro-server/src/run_files.rs | 1 + lib/crates/fabro-server/src/run_manifest.rs | 1 + .../fabro-server/src/server/handler/events.rs | 1 + .../fabro-server/src/server/handler/pair.rs | 1 + .../src/server/handler/sessions.rs | 1 + lib/crates/fabro-server/src/server/tests.rs | 7 + .../fabro-server/tests/it/api/run_files.rs | 1 + lib/crates/fabro-store/src/run_state.rs | 45 +- lib/crates/fabro-store/src/slate/mod.rs | 1 + .../tests/serializable_projection.rs | 1 + lib/crates/fabro-types/src/run.rs | 8 + lib/crates/fabro-types/src/run_event/run.rs | 6 +- lib/crates/fabro-types/src/run_projection.rs | 2 + lib/crates/fabro-types/src/run_summary.rs | 6 +- .../fabro-types/tests/run_event_serde.rs | 13 +- .../fabro-types/tests/run_spec_methods.rs | 1 + .../fabro-types/tests/run_spec_serde.rs | 23 +- .../fabro-workflow/src/billing_rollup.rs | 1 + .../fabro-workflow/src/event/convert.rs | 17 +- lib/crates/fabro-workflow/src/event/events.rs | 14 +- lib/crates/fabro-workflow/src/event/sink.rs | 1 + lib/crates/fabro-workflow/src/git.rs | 1 + .../fabro-workflow/src/handler/agent.rs | 1 + .../fabro-workflow/src/handler/command.rs | 2 + .../fabro-workflow/src/handler/parallel.rs | 1 + .../fabro-workflow/src/handler/prompt.rs | 1 + .../fabro-workflow/src/lifecycle/git.rs | 1 + .../fabro-workflow/src/operations/archive.rs | 1 + .../fabro-workflow/src/operations/create.rs | 26 +- .../fabro-workflow/src/operations/fork.rs | 2 + .../fabro-workflow/src/operations/retry.rs | 3 + .../fabro-workflow/src/operations/start.rs | 2 + .../fabro-workflow/src/operations/timeline.rs | 1 + .../src/pipeline/execute/tests.rs | 2 + .../fabro-workflow/src/pipeline/finalize.rs | 2 + .../fabro-workflow/src/pipeline/initialize.rs | 1 + .../fabro-workflow/src/pipeline/persist.rs | 2 + .../src/pipeline/pull_request.rs | 11 + lib/crates/fabro-workflow/src/run_lookup.rs | 2 + lib/crates/fabro-workflow/src/run_metadata.rs | 1 + .../fabro-workflow/src/runtime_store.rs | 2 + lib/crates/fabro-workflow/src/test_support.rs | 1 + .../src/models/automation-ref.ts | 1 + .../fabro-api-client/src/models/run-spec.ts | 4 + .../tests/principal-exhaustive.ts | 17 +- 59 files changed, 1764 insertions(+), 41 deletions(-) create mode 100644 lib/crates/fabro-automation/Cargo.toml create mode 100644 lib/crates/fabro-automation/src/error.rs create mode 100644 lib/crates/fabro-automation/src/id.rs create mode 100644 lib/crates/fabro-automation/src/lib.rs create mode 100644 lib/crates/fabro-automation/src/model.rs create mode 100644 lib/crates/fabro-automation/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index b74f11aa3..fe93492ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,7 +105,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "strum", + "strum 0.28.0", "tracing", ] @@ -1022,6 +1022,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "croner" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +dependencies = [ + "chrono", + "derive_builder", + "strum 0.27.2", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -1166,6 +1177,16 @@ dependencies = [ "darling_macro 0.14.4", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" @@ -1190,6 +1211,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + [[package]] name = "darling_core" version = "0.23.0" @@ -1214,6 +1249,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" @@ -1332,6 +1378,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1632,7 +1709,7 @@ dependencies = [ "serde_json", "sha2", "shell-escape", - "strum", + "strum 0.28.0", "tempfile", "thiserror 2.0.18", "tokio", @@ -1687,6 +1764,21 @@ dependencies = [ "toml 0.8.23", ] +[[package]] +name = "fabro-automation" +version = "0.246.0-nightly.0" +dependencies = [ + "croner", + "hex", + "serde", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "toml 0.8.23", + "tracing", +] + [[package]] name = "fabro-build-support" version = "0.246.0-nightly.0" @@ -1963,7 +2055,7 @@ dependencies = [ "nom", "regex", "serde", - "strum", + "strum 0.28.0", "thiserror 2.0.18", ] @@ -2056,7 +2148,7 @@ dependencies = [ "rand 0.9.4", "serde", "serde_json", - "strum", + "strum 0.28.0", "thiserror 2.0.18", "tokio", "tokio-stream", @@ -2137,7 +2229,7 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_json", - "strum", + "strum 0.28.0", "tempfile", "tokio", "toml 0.8.23", @@ -2153,7 +2245,7 @@ dependencies = [ "rust-embed", "serde", "serde_json", - "strum", + "strum 0.28.0", "thiserror 2.0.18", "toml 0.8.23", "tracing", @@ -2248,7 +2340,7 @@ dependencies = [ "serde_json", "sha2", "shlex", - "strum", + "strum 0.28.0", "tar", "tempfile", "thiserror 2.0.18", @@ -2326,7 +2418,7 @@ dependencies = [ "serde_json", "serde_yaml", "sha2", - "strum", + "strum 0.28.0", "sysinfo", "tempfile", "thiserror 2.0.18", @@ -2359,7 +2451,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "strum", + "strum 0.28.0", "thiserror 2.0.18", "tokio", "tokio-tungstenite 0.26.2", @@ -2484,7 +2576,7 @@ dependencies = [ "schemars 1.2.1", "serde", "serde_json", - "strum", + "strum 0.28.0", "tempfile", "tokio", "toml 0.8.23", @@ -2518,7 +2610,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "strum", + "strum 0.28.0", "tempfile", "toml 0.8.23", "ulid", @@ -6706,13 +6798,34 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros 0.27.2", +] + [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "strum_macros", + "strum_macros 0.28.0", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index eca64f348..de3a7e4ca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ anyhow = "1" axum = { version = "0.8" } axum-extra = { version = "0.10", features = ["cookie-private", "query"] } cookie = { version = "0.18", features = ["percent-encode", "private", "signed", "key-expansion"] } +croner = "3.0.1" thiserror = "2" serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = ["preserve_order"] } diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index a319ff041..67d8e5830 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -9303,6 +9303,10 @@ components: type: ["string", "null"] workflow_slug: type: ["string", "null"] + automation: + oneOf: + - $ref: "#/components/schemas/AutomationRef" + - type: "null" source_directory: type: ["string", "null"] labels: @@ -9730,6 +9734,8 @@ components: type: string name: type: ["string", "null"] + trigger_id: + type: ["string", "null"] RunOrigin: type: object diff --git a/lib/crates/fabro-api/tests/run_projection_round_trip.rs b/lib/crates/fabro-api/tests/run_projection_round_trip.rs index 64a00df91..bd3d521d7 100644 --- a/lib/crates/fabro-api/tests/run_projection_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_projection_round_trip.rs @@ -128,6 +128,7 @@ fn run_spec_json() -> serde_json::Value { graph: Graph::new("test"), graph_source: Some("digraph test {}".to_string()), workflow_slug: None, + automation: None, source_directory: None, labels: std::collections::HashMap::new(), provenance: None, diff --git a/lib/crates/fabro-api/tests/run_summary_round_trip.rs b/lib/crates/fabro-api/tests/run_summary_round_trip.rs index 21d237434..0c5a70fb5 100644 --- a/lib/crates/fabro-api/tests/run_summary_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_summary_round_trip.rs @@ -9,10 +9,10 @@ use fabro_api::types::{ }; use fabro_types::status::{RunStatus, SuccessReason}; use fabro_types::{ - AskFabro, AskFabroUnavailableReason, DiffSummary, PullRequestLink, RepositoryProvider, - RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary, RunId, RunLifecycle, - RunLinks, RunOrigin, RunRunnableSource, RunSize, RunTimestamps, RunTiming, WorkflowRef, - fixtures, + AskFabro, AskFabroUnavailableReason, AutomationRef, DiffSummary, PullRequestLink, + RepositoryProvider, RepositoryRef, Run, RunApproval, RunApprovalState, RunBillingSummary, + RunId, RunLifecycle, RunLinks, RunOrigin, RunRunnableSource, RunSize, RunTimestamps, RunTiming, + WorkflowRef, fixtures, }; use serde_json::json; @@ -77,7 +77,11 @@ fn run_summary_json_matches_openapi_shape() { node_count: 7, edge_count: 9, }, - automation: None, + automation: Some(AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule_1".to_string()), + }), repository: Some(RepositoryRef { name: "fabro".to_string(), origin_url: None, @@ -146,7 +150,11 @@ fn run_summary_json_matches_openapi_shape() { "node_count": 7, "edge_count": 9 }, - "automation": null, + "automation": { + "id": "nightly", + "name": "Nightly", + "trigger_id": "schedule_1" + }, "repository": { "name": "fabro", "origin_url": null, diff --git a/lib/crates/fabro-automation/Cargo.toml b/lib/crates/fabro-automation/Cargo.toml new file mode 100644 index 000000000..6ed2bd595 --- /dev/null +++ b/lib/crates/fabro-automation/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "fabro-automation" +edition.workspace = true +version.workspace = true +publish = false +license.workspace = true +description = "Automation domain and durable storage for Fabro" + +[lib] +doctest = false + +[lints] +workspace = true + +[dependencies] +croner.workspace = true +hex.workspace = true +serde.workspace = true +sha2.workspace = true +thiserror.workspace = true +tokio.workspace = true +toml.workspace = true +tracing.workspace = true + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true, features = ["macros", "test-util"] } diff --git a/lib/crates/fabro-automation/src/error.rs b/lib/crates/fabro-automation/src/error.rs new file mode 100644 index 000000000..cf5ffa595 --- /dev/null +++ b/lib/crates/fabro-automation/src/error.rs @@ -0,0 +1,123 @@ +use std::path::PathBuf; + +use croner::errors::CronError; +use toml::de::Error as TomlDeError; +use toml::ser::Error as TomlSerError; + +use crate::{AutomationId, AutomationRevision}; + +#[derive(Debug, thiserror::Error)] +pub enum AutomationValidationError { + #[error("automation id {value:?} must match [a-z0-9][a-z0-9-]{{0,62}}")] + InvalidAutomationId { value: String }, + #[error("automation trigger id {value:?} must match [a-z0-9][a-z0-9_-]{{0,62}}")] + InvalidAutomationTriggerId { value: String }, + #[error("automation name must not be empty")] + EmptyName, + #[error("repository slug {value:?} must be a GitHub owner/repo slug")] + InvalidRepositorySlug { value: String }, + #[error("git ref selector {value:?} is not safe")] + InvalidGitRefSelector { value: String }, + #[error("workflow selector {value:?} is not safe")] + InvalidWorkflowSelector { value: String }, + #[error("duplicate automation trigger id {id:?}")] + DuplicateTriggerId { id: String }, + #[error("automation can have at most one API trigger")] + MultipleApiTriggers, + #[error("schedule trigger {trigger_id:?} cron expression {expression:?} must have five fields")] + InvalidCronFieldCount { + trigger_id: String, + expression: String, + }, + #[error("schedule trigger {trigger_id:?} cron expression {expression:?} is invalid")] + InvalidCronExpression { + trigger_id: String, + expression: String, + #[source] + source: CronError, + }, +} + +#[derive(Debug, thiserror::Error)] +pub enum AutomationStoreError { + #[error("automation not found: {id}")] + NotFound { id: AutomationId }, + #[error("automation already exists: {id}")] + AlreadyExists { id: AutomationId }, + #[error("automation revision is missing: {id}")] + MissingRevision { id: AutomationId }, + #[error("automation revision is stale for {id}: expected {expected}, actual {actual}")] + StaleRevision { + id: AutomationId, + expected: AutomationRevision, + actual: AutomationRevision, + }, + #[error("automation validation failed")] + Validation { + #[from] + source: AutomationValidationError, + }, + #[error("invalid automation filename at {path:?}")] + InvalidFilename { path: PathBuf, reason: String }, + #[error("failed to parse automation TOML at {path:?}")] + Parse { + path: PathBuf, + #[source] + source: TomlDeError, + }, + #[error("automation TOML at {path:?} is not UTF-8")] + InvalidUtf8 { + path: PathBuf, + #[source] + source: std::str::Utf8Error, + }, + #[error("failed to serialize automation TOML")] + Serialize { + #[from] + source: TomlSerError, + }, + #[error("I/O error at {path:?}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +impl AutomationStoreError { + pub(crate) fn io(path: impl Into, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } + + pub(crate) fn parse(path: impl Into, source: TomlDeError) -> Self { + Self::Parse { + path: path.into(), + source, + } + } + + pub(crate) fn invalid_utf8(path: impl Into, source: std::str::Utf8Error) -> Self { + Self::InvalidUtf8 { + path: path.into(), + source, + } + } + + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::NotFound { .. } => "not_found", + Self::AlreadyExists { .. } => "already_exists", + Self::MissingRevision { .. } => "missing_revision", + Self::StaleRevision { .. } => "stale_revision", + Self::Validation { .. } => "validation", + Self::InvalidFilename { .. } => "invalid_filename", + Self::Parse { .. } | Self::InvalidUtf8 { .. } => "parse", + Self::Serialize { .. } => "serialize", + Self::Io { .. } => "io", + } + } +} diff --git a/lib/crates/fabro-automation/src/id.rs b/lib/crates/fabro-automation/src/id.rs new file mode 100644 index 000000000..228dfcce6 --- /dev/null +++ b/lib/crates/fabro-automation/src/id.rs @@ -0,0 +1,239 @@ +use std::fmt; +use std::str::FromStr; + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest, Sha256}; + +use crate::AutomationValidationError; + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AutomationId(String); + +impl AutomationId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if is_valid_automation_id(&value) { + Ok(Self(value)) + } else { + Err(AutomationValidationError::InvalidAutomationId { value }) + } + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AutomationId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for AutomationId { + type Err = AutomationValidationError; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl Serialize for AutomationId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AutomationId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AutomationTriggerId(String); + +impl AutomationTriggerId { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if is_valid_automation_trigger_id(&value) { + Ok(Self(value)) + } else { + Err(AutomationValidationError::InvalidAutomationTriggerId { value }) + } + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AutomationTriggerId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for AutomationTriggerId { + type Err = AutomationValidationError; + + fn from_str(value: &str) -> Result { + Self::new(value) + } +} + +impl Serialize for AutomationTriggerId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AutomationTriggerId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct AutomationRevision(String); + +impl AutomationRevision { + #[must_use] + pub fn from_bytes(bytes: &[u8]) -> Self { + Self(hex::encode(Sha256::digest(bytes))) + } + + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for AutomationRevision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +impl FromStr for AutomationRevision { + type Err = AutomationRevisionParseError; + + fn from_str(value: &str) -> Result { + if value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + Ok(Self(value.to_string())) + } else { + Err(AutomationRevisionParseError(value.to_string())) + } + } +} + +impl Serialize for AutomationRevision { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AutomationRevision { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + value.parse().map_err(D::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AutomationRevisionParseError(String); + +impl fmt::Display for AutomationRevisionParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid automation revision: {:?}", self.0) + } +} + +impl std::error::Error for AutomationRevisionParseError {} + +fn is_valid_automation_id(value: &str) -> bool { + is_valid_id(value, false) +} + +fn is_valid_automation_trigger_id(value: &str) -> bool { + is_valid_id(value, true) +} + +fn is_valid_id(value: &str, allow_underscore: bool) -> bool { + let mut bytes = value.bytes(); + let Some(first) = bytes.next() else { + return false; + }; + if !first.is_ascii_lowercase() && !first.is_ascii_digit() { + return false; + } + if value.len() > 63 { + return false; + } + bytes.all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || byte == b'-' + || (allow_underscore && byte == b'_') + }) +} + +#[cfg(test)] +mod tests { + use super::{AutomationId, AutomationRevision, AutomationTriggerId}; + + #[test] + fn automation_id_validation_matches_contract() { + assert!("a".parse::().is_ok()); + assert!("a-1".parse::().is_ok()); + assert!("0".parse::().is_ok()); + assert!("A".parse::().is_err()); + assert!("a_1".parse::().is_err()); + assert!("-a".parse::().is_err()); + assert!("a".repeat(64).parse::().is_err()); + } + + #[test] + fn trigger_id_allows_underscore() { + assert!("api_trigger".parse::().is_ok()); + assert!("api.trigger".parse::().is_err()); + } + + #[test] + fn revision_is_lowercase_sha256_hex() { + let revision = AutomationRevision::from_bytes(b"hello"); + assert_eq!( + revision.to_string(), + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" + ); + assert!(revision.to_string().parse::().is_ok()); + assert!("ABC".parse::().is_err()); + } +} diff --git a/lib/crates/fabro-automation/src/lib.rs b/lib/crates/fabro-automation/src/lib.rs new file mode 100644 index 000000000..482f4c3fd --- /dev/null +++ b/lib/crates/fabro-automation/src/lib.rs @@ -0,0 +1,12 @@ +mod error; +mod id; +mod model; +mod store; + +pub use error::{AutomationStoreError, AutomationValidationError}; +pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId}; +pub use model::{ + ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTarget, + AutomationTrigger, ScheduleTrigger, +}; +pub use store::AutomationStore; diff --git a/lib/crates/fabro-automation/src/model.rs b/lib/crates/fabro-automation/src/model.rs new file mode 100644 index 000000000..b44ba54e2 --- /dev/null +++ b/lib/crates/fabro-automation/src/model.rs @@ -0,0 +1,540 @@ +use std::collections::HashSet; + +use croner::parser::{CronParser, Seconds, Year}; +use serde::{Deserialize, Serialize}; + +use crate::{ + AutomationId, AutomationRevision, AutomationStoreError, AutomationTriggerId, + AutomationValidationError, +}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Automation { + pub id: AutomationId, + pub revision: AutomationRevision, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default = "default_true")] + pub enabled: bool, + pub target: AutomationTarget, + #[serde(default)] + pub triggers: Vec, +} + +impl Automation { + pub fn from_toml_bytes(id: AutomationId, bytes: &[u8]) -> Result { + let revision = AutomationRevision::from_bytes(bytes); + let persisted = parse_persisted(bytes, None)?; + Self::from_persisted(id, revision, persisted).map_err(AutomationStoreError::from) + } + + pub(crate) fn from_persisted_path( + id: AutomationId, + bytes: &[u8], + path: impl Into, + ) -> Result { + let path = path.into(); + let revision = AutomationRevision::from_bytes(bytes); + let persisted = parse_persisted(bytes, Some(path))?; + Self::from_persisted(id, revision, persisted).map_err(AutomationStoreError::from) + } + + pub(crate) fn from_replace( + id: AutomationId, + draft: AutomationReplace, + ) -> Result<(Self, Vec), AutomationStoreError> { + validate_fields(&draft)?; + let persisted = PersistedAutomation::from(draft.clone()); + let bytes = canonical_bytes(&persisted)?; + let revision = AutomationRevision::from_bytes(&bytes); + let automation = Self::from_validated_replace(id, revision, draft); + Ok((automation, bytes)) + } + + pub(crate) fn to_persisted(&self) -> PersistedAutomation { + PersistedAutomation { + name: self.name.clone(), + description: self.description.clone(), + enabled: self.enabled, + target: self.target.clone(), + triggers: self.triggers.clone(), + } + } + + pub fn to_toml_string(&self) -> Result { + toml::to_string_pretty(&self.to_persisted()).map_err(AutomationStoreError::from) + } + + fn from_persisted( + id: AutomationId, + revision: AutomationRevision, + persisted: PersistedAutomation, + ) -> Result { + let replace = AutomationReplace::from(persisted); + validate_fields(&replace)?; + Ok(Self::from_validated_replace(id, revision, replace)) + } + + fn from_validated_replace( + id: AutomationId, + revision: AutomationRevision, + replace: AutomationReplace, + ) -> Self { + Self { + id, + revision, + name: replace.name, + description: replace.description, + enabled: replace.enabled, + target: replace.target, + triggers: replace.triggers, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AutomationTarget { + pub repository: String, + #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")] + pub ref_selector: Option, + pub workflow: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum AutomationTrigger { + Api(ApiTrigger), + Schedule(ScheduleTrigger), +} + +impl AutomationTrigger { + #[must_use] + pub fn id(&self) -> &AutomationTriggerId { + match self { + Self::Api(trigger) => &trigger.id, + Self::Schedule(trigger) => &trigger.id, + } + } + + #[must_use] + pub fn enabled(&self) -> bool { + match self { + Self::Api(trigger) => trigger.enabled, + Self::Schedule(trigger) => trigger.enabled, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApiTrigger { + pub id: AutomationTriggerId, + #[serde(default = "default_true")] + pub enabled: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScheduleTrigger { + pub id: AutomationTriggerId, + #[serde(default = "default_true")] + pub enabled: bool, + pub cron: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AutomationDraft { + pub id: AutomationId, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default = "default_true")] + pub enabled: bool, + pub target: AutomationTarget, + #[serde(default)] + pub triggers: Vec, +} + +impl From for (AutomationId, AutomationReplace) { + fn from(value: AutomationDraft) -> Self { + (value.id, AutomationReplace { + name: value.name, + description: value.description, + enabled: value.enabled, + target: value.target, + triggers: value.triggers, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AutomationReplace { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default = "default_true")] + pub enabled: bool, + pub target: AutomationTarget, + #[serde(default)] + pub triggers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PersistedAutomation { + name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(default = "default_true")] + enabled: bool, + target: AutomationTarget, + #[serde(default)] + triggers: Vec, +} + +impl From for PersistedAutomation { + fn from(value: AutomationReplace) -> Self { + Self { + name: value.name, + description: value.description, + enabled: value.enabled, + target: value.target, + triggers: value.triggers, + } + } +} + +impl From for AutomationReplace { + fn from(value: PersistedAutomation) -> Self { + Self { + name: value.name, + description: value.description, + enabled: value.enabled, + target: value.target, + triggers: value.triggers, + } + } +} + +pub(crate) fn canonical_bytes( + persisted: &PersistedAutomation, +) -> Result, AutomationStoreError> { + let toml = toml::to_string_pretty(persisted)?; + Ok(toml.into_bytes()) +} + +fn parse_persisted( + bytes: &[u8], + path: Option, +) -> Result { + let content = std::str::from_utf8(bytes).map_err(|err| match &path { + Some(path) => AutomationStoreError::invalid_utf8(path.clone(), err), + None => AutomationStoreError::invalid_utf8("", err), + })?; + toml::from_str(content).map_err(|err| match path { + Some(path) => AutomationStoreError::parse(path, err), + None => AutomationStoreError::parse("", err), + }) +} + +fn validate_fields(value: &AutomationReplace) -> Result<(), AutomationValidationError> { + if value.name.trim().is_empty() { + return Err(AutomationValidationError::EmptyName); + } + validate_repository_slug(&value.target.repository)?; + if let Some(ref_selector) = &value.target.ref_selector { + validate_git_ref_selector(ref_selector)?; + } + validate_workflow_selector(&value.target.workflow)?; + validate_triggers(&value.triggers) +} + +fn validate_repository_slug(value: &str) -> Result<(), AutomationValidationError> { + let Some((owner, repo)) = value.split_once('/') else { + return Err(AutomationValidationError::InvalidRepositorySlug { + value: value.to_string(), + }); + }; + if repo.contains('/') || !valid_github_owner(owner) || !valid_github_repo(repo) { + return Err(AutomationValidationError::InvalidRepositorySlug { + value: value.to_string(), + }); + } + Ok(()) +} + +fn valid_github_owner(value: &str) -> bool { + if value.is_empty() || value.len() > 39 { + return false; + } + let bytes = value.as_bytes(); + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + (first.is_ascii_alphanumeric() && last.is_ascii_alphanumeric()) + && bytes + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || *byte == b'-') +} + +fn valid_github_repo(value: &str) -> bool { + !value.is_empty() + && value.len() <= 100 + && value != "." + && value != ".." + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn validate_git_ref_selector(value: &str) -> Result<(), AutomationValidationError> { + let valid = !value.is_empty() + && value.len() <= 255 + && value.trim() == value + && !value.starts_with(['/', '-', '.']) + && !value.ends_with(['/', '.']) + && !has_lock_suffix(value) + && value != "@" + && !value.contains("..") + && !value.contains("//") + && !value.contains("@{") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-')) + && value + .split('/') + .all(|part| !part.is_empty() && !part.starts_with('.') && !has_lock_suffix(part)); + if valid { + Ok(()) + } else { + Err(AutomationValidationError::InvalidGitRefSelector { + value: value.to_string(), + }) + } +} + +fn validate_workflow_selector(value: &str) -> Result<(), AutomationValidationError> { + let valid = !value.is_empty() + && value.len() <= 255 + && value.trim() == value + && !value.starts_with(['/', '~']) + && !value.ends_with('/') + && !value.contains("//") + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-')) + && value + .split('/') + .all(|part| !part.is_empty() && part != "." && part != ".."); + if valid { + Ok(()) + } else { + Err(AutomationValidationError::InvalidWorkflowSelector { + value: value.to_string(), + }) + } +} + +fn has_lock_suffix(value: &str) -> bool { + value + .rsplit_once('.') + .is_some_and(|(_, extension)| extension == "lock") +} + +fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationValidationError> { + let mut seen = HashSet::new(); + let mut has_api_trigger = false; + let cron_parser = CronParser::builder() + .seconds(Seconds::Disallowed) + .year(Year::Disallowed) + .build(); + + for trigger in triggers { + let id = trigger.id().as_str(); + if !seen.insert(id) { + return Err(AutomationValidationError::DuplicateTriggerId { id: id.to_string() }); + } + match trigger { + AutomationTrigger::Api(_) => { + if has_api_trigger { + return Err(AutomationValidationError::MultipleApiTriggers); + } + has_api_trigger = true; + } + AutomationTrigger::Schedule(trigger) => { + if trigger.cron.split_whitespace().count() != 5 { + return Err(AutomationValidationError::InvalidCronFieldCount { + trigger_id: id.to_string(), + expression: trigger.cron.clone(), + }); + } + cron_parser.parse(&trigger.cron).map_err(|source| { + AutomationValidationError::InvalidCronExpression { + trigger_id: id.to_string(), + expression: trigger.cron.clone(), + source, + } + })?; + } + } + } + + Ok(()) +} + +fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use crate::{ + ApiTrigger, Automation, AutomationId, AutomationReplace, AutomationTarget, + AutomationTrigger, AutomationTriggerId, ScheduleTrigger, + }; + + fn target() -> AutomationTarget { + AutomationTarget { + repository: "fabro-sh/fabro".to_string(), + ref_selector: Some("main".to_string()), + workflow: ".fabro/workflows/test/workflow.toml".to_string(), + } + } + + fn api_trigger(id: &str) -> AutomationTrigger { + AutomationTrigger::Api(ApiTrigger { + id: AutomationTriggerId::new(id).unwrap(), + enabled: true, + }) + } + + fn schedule_trigger(id: &str, cron: &str) -> AutomationTrigger { + AutomationTrigger::Schedule(ScheduleTrigger { + id: AutomationTriggerId::new(id).unwrap(), + enabled: true, + cron: cron.to_string(), + }) + } + + #[test] + fn persisted_toml_applies_defaults_and_canonicalizes_without_id_or_revision() { + let bytes = br#" +name = "Nightly" + +[target] +repository = "fabro-sh/fabro" +ref = "main" +workflow = "release" + +[[triggers]] +type = "api" +id = "manual" + +[[triggers]] +type = "schedule" +id = "nightly" +cron = "0 0 * * *" +"#; + + let automation = + Automation::from_toml_bytes(AutomationId::new("nightly").unwrap(), bytes).unwrap(); + + assert_eq!(automation.description, None); + assert!(automation.enabled); + assert!(automation.triggers.iter().all(AutomationTrigger::enabled)); + + let toml = automation.to_toml_string().unwrap(); + assert!(!top_level_lines(&toml).any(|line| line.starts_with("id = "))); + assert!(!top_level_lines(&toml).any(|line| line.starts_with("revision = "))); + assert!(toml.contains("enabled = true")); + assert!(toml.contains("type = \"api\"")); + } + + #[test] + fn validation_rejects_invalid_inputs() { + let cases = [ + AutomationReplace { + name: " ".to_string(), + description: None, + enabled: true, + target: target(), + triggers: vec![api_trigger("manual")], + }, + AutomationReplace { + name: "Bad repo".to_string(), + description: None, + enabled: true, + target: AutomationTarget { + repository: "not/github/slug".to_string(), + ref_selector: Some("main".to_string()), + workflow: "release".to_string(), + }, + triggers: vec![api_trigger("manual")], + }, + AutomationReplace { + name: "Bad ref".to_string(), + description: None, + enabled: true, + target: AutomationTarget { + repository: "fabro-sh/fabro".to_string(), + ref_selector: Some("main;rm".to_string()), + workflow: "release".to_string(), + }, + triggers: vec![api_trigger("manual")], + }, + AutomationReplace { + name: "Bad workflow".to_string(), + description: None, + enabled: true, + target: AutomationTarget { + repository: "fabro-sh/fabro".to_string(), + ref_selector: Some("main".to_string()), + workflow: "../release".to_string(), + }, + triggers: vec![api_trigger("manual")], + }, + AutomationReplace { + name: "Duplicate trigger".to_string(), + description: None, + enabled: true, + target: target(), + triggers: vec![ + api_trigger("manual"), + schedule_trigger("manual", "0 0 * * *"), + ], + }, + AutomationReplace { + name: "Two API triggers".to_string(), + description: None, + enabled: true, + target: target(), + triggers: vec![api_trigger("one"), api_trigger("two")], + }, + AutomationReplace { + name: "Six field cron".to_string(), + description: None, + enabled: true, + target: target(), + triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")], + }, + AutomationReplace { + name: "Bad cron".to_string(), + description: None, + enabled: true, + target: target(), + triggers: vec![schedule_trigger("nightly", "99 0 * * *")], + }, + ]; + + for case in cases { + assert!(Automation::from_replace(AutomationId::new("test").unwrap(), case).is_err()); + } + } + + fn top_level_lines(toml: &str) -> impl Iterator { + toml.lines().take_while(|line| !line.starts_with('[')) + } +} diff --git a/lib/crates/fabro-automation/src/store.rs b/lib/crates/fabro-automation/src/store.rs new file mode 100644 index 000000000..77692065e --- /dev/null +++ b/lib/crates/fabro-automation/src/store.rs @@ -0,0 +1,456 @@ +use std::collections::HashMap; +use std::io::ErrorKind; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tokio::fs; +use tokio::io::AsyncWriteExt as _; +use tokio::sync::{Mutex, RwLock}; +use tracing::warn; + +use crate::{ + Automation, AutomationDraft, AutomationId, AutomationReplace, AutomationRevision, + AutomationStoreError, +}; + +#[derive(Debug)] +pub struct AutomationStore { + dir: PathBuf, + mutations: Mutex<()>, + automations: RwLock>, +} + +impl AutomationStore { + pub async fn load(dir: impl Into) -> Result { + let dir = dir.into(); + let automations = load_automations(&dir).await?; + Ok(Self { + dir, + mutations: Mutex::new(()), + automations: RwLock::new(automations), + }) + } + + pub async fn list(&self) -> Vec { + let automations = self.automations.read().await; + let mut values = automations.values().cloned().collect::>(); + values.sort_by(|left, right| left.id.cmp(&right.id)); + values + } + + pub async fn get(&self, id: &AutomationId) -> Option { + self.automations.read().await.get(id).cloned() + } + + pub async fn create(&self, draft: AutomationDraft) -> Result { + let (id, replace) = draft.into(); + let (automation, bytes) = Automation::from_replace(id.clone(), replace)?; + let _mutation = self.mutations.lock().await; + if self.automations.read().await.contains_key(&id) { + return Err(AutomationStoreError::AlreadyExists { id }); + } + + let path = automation_path(&self.dir, &id); + write_new(&self.dir, &path, &bytes) + .await + .map_err(|err| create_error_for(id.clone(), err))?; + + let mut automations = self.automations.write().await; + automations.insert(id, automation.clone()); + Ok(automation) + } + + pub async fn replace( + &self, + id: &AutomationId, + expected: &AutomationRevision, + draft: AutomationReplace, + ) -> Result { + let (automation, bytes) = Automation::from_replace(id.clone(), draft)?; + let _mutation = self.mutations.lock().await; + { + let automations = self.automations.read().await; + let current = automations + .get(id) + .ok_or_else(|| AutomationStoreError::NotFound { id: id.clone() })?; + if ¤t.revision != expected { + return Err(AutomationStoreError::StaleRevision { + id: id.clone(), + expected: expected.clone(), + actual: current.revision.clone(), + }); + } + } + + write_atomic(&self.dir, &automation_path(&self.dir, id), &bytes).await?; + let mut automations = self.automations.write().await; + automations.insert(id.clone(), automation.clone()); + Ok(automation) + } + + pub async fn delete( + &self, + id: &AutomationId, + expected: &AutomationRevision, + ) -> Result<(), AutomationStoreError> { + let _mutation = self.mutations.lock().await; + { + let automations = self.automations.read().await; + let current = automations + .get(id) + .ok_or_else(|| AutomationStoreError::NotFound { id: id.clone() })?; + if ¤t.revision != expected { + return Err(AutomationStoreError::StaleRevision { + id: id.clone(), + expected: expected.clone(), + actual: current.revision.clone(), + }); + } + } + + let path = automation_path(&self.dir, id); + fs::remove_file(&path) + .await + .map_err(|err| AutomationStoreError::io(path, err))?; + let mut automations = self.automations.write().await; + automations.remove(id); + Ok(()) + } +} + +async fn load_automations( + dir: &Path, +) -> Result, AutomationStoreError> { + let mut entries = match fs::read_dir(dir).await { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(HashMap::new()), + Err(err) => return Err(AutomationStoreError::io(dir, err)), + }; + + let mut automations = HashMap::new(); + while let Some(entry) = entries + .next_entry() + .await + .map_err(|err| AutomationStoreError::io(dir, err))? + { + let path = entry.path(); + let file_type = match entry.file_type().await { + Ok(file_type) => file_type, + Err(err) => { + warn_load_failure(&path, &AutomationStoreError::io(path.clone(), err)); + continue; + } + }; + if !file_type.is_file() || !is_toml_file(&path) { + continue; + } + + match load_automation_file(&path).await { + Ok(automation) => { + automations.insert(automation.id.clone(), automation); + } + Err(err) => warn_load_failure(&path, &err), + } + } + Ok(automations) +} + +async fn load_automation_file(path: &Path) -> Result { + let id = id_from_path(path)?; + let bytes = fs::read(path) + .await + .map_err(|err| AutomationStoreError::io(path, err))?; + Automation::from_persisted_path(id, &bytes, path) +} + +fn id_from_path(path: &Path) -> Result { + let stem = path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| AutomationStoreError::InvalidFilename { + path: path.to_path_buf(), + reason: "filename is not valid UTF-8".to_string(), + })?; + AutomationId::new(stem).map_err(|source| AutomationStoreError::InvalidFilename { + path: path.to_path_buf(), + reason: source.to_string(), + }) +} + +fn is_toml_file(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension == "toml") +} + +async fn write_atomic(dir: &Path, path: &Path, bytes: &[u8]) -> Result<(), AutomationStoreError> { + fs::create_dir_all(dir) + .await + .map_err(|err| AutomationStoreError::io(dir, err))?; + let temp_path = temp_path_for(path); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .await + .map_err(|err| AutomationStoreError::io(&temp_path, err))?; + + if let Err(err) = file.write_all(bytes).await { + cleanup_temp(&temp_path).await; + return Err(AutomationStoreError::io(&temp_path, err)); + } + if let Err(err) = file.sync_all().await { + cleanup_temp(&temp_path).await; + return Err(AutomationStoreError::io(&temp_path, err)); + } + drop(file); + + if let Err(err) = fs::rename(&temp_path, path).await { + cleanup_temp(&temp_path).await; + return Err(AutomationStoreError::io(path, err)); + } + + Ok(()) +} + +async fn write_new(dir: &Path, path: &Path, bytes: &[u8]) -> Result<(), AutomationStoreError> { + fs::create_dir_all(dir) + .await + .map_err(|err| AutomationStoreError::io(dir, err))?; + let temp_path = temp_path_for(path); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .await + .map_err(|err| AutomationStoreError::io(&temp_path, err))?; + + if let Err(err) = file.write_all(bytes).await { + cleanup_temp(&temp_path).await; + return Err(AutomationStoreError::io(&temp_path, err)); + } + if let Err(err) = file.sync_all().await { + cleanup_temp(&temp_path).await; + return Err(AutomationStoreError::io(&temp_path, err)); + } + drop(file); + + if let Err(err) = fs::hard_link(&temp_path, path).await { + cleanup_temp(&temp_path).await; + return Err(AutomationStoreError::io(path, err)); + } + cleanup_temp(&temp_path).await; + Ok(()) +} + +async fn cleanup_temp(path: &Path) { + let _ = fs::remove_file(path).await; +} + +fn create_error_for(id: AutomationId, err: AutomationStoreError) -> AutomationStoreError { + match err { + AutomationStoreError::Io { source, .. } if source.kind() == ErrorKind::AlreadyExists => { + AutomationStoreError::AlreadyExists { id } + } + err => err, + } +} + +fn temp_path_for(path: &Path) -> PathBuf { + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("automation.toml"); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |duration| duration.as_nanos()); + parent.join(format!(".{file_name}.{}.{}.tmp", std::process::id(), now)) +} + +fn automation_path(dir: &Path, id: &AutomationId) -> PathBuf { + dir.join(format!("{id}.toml")) +} + +fn warn_load_failure(path: &Path, err: &AutomationStoreError) { + warn!( + path = %path.display(), + failure_kind = err.kind(), + error = %err, + "Skipping automation file" + ); +} + +#[cfg(test)] +mod tests { + use tokio::fs; + + use crate::{ + ApiTrigger, AutomationDraft, AutomationId, AutomationReplace, AutomationStore, + AutomationStoreError, AutomationTarget, AutomationTrigger, AutomationTriggerId, + ScheduleTrigger, + }; + + fn target() -> AutomationTarget { + AutomationTarget { + repository: "fabro-sh/fabro".to_string(), + ref_selector: Some("main".to_string()), + workflow: "release".to_string(), + } + } + + fn draft(id: &str, name: &str) -> AutomationDraft { + AutomationDraft { + id: AutomationId::new(id).unwrap(), + name: name.to_string(), + description: None, + enabled: true, + target: target(), + triggers: vec![ + AutomationTrigger::Api(ApiTrigger { + id: AutomationTriggerId::new("manual").unwrap(), + enabled: true, + }), + AutomationTrigger::Schedule(ScheduleTrigger { + id: AutomationTriggerId::new("nightly").unwrap(), + enabled: true, + cron: "0 0 * * *".to_string(), + }), + ], + } + } + + fn replacement(name: &str) -> AutomationReplace { + AutomationReplace { + name: name.to_string(), + description: Some("updated".to_string()), + enabled: false, + target: target(), + triggers: vec![AutomationTrigger::Api(ApiTrigger { + id: AutomationTriggerId::new("manual").unwrap(), + enabled: false, + })], + } + } + + #[tokio::test] + async fn missing_directory_loads_empty_store() { + let dir = tempfile::tempdir().unwrap(); + let store = AutomationStore::load(dir.path().join("automations")) + .await + .unwrap(); + + assert!(store.list().await.is_empty()); + } + + #[tokio::test] + async fn load_skips_invalid_files_and_keeps_valid_automations() { + let dir = tempfile::tempdir().unwrap(); + let automation_dir = dir.path().join("automations"); + fs::create_dir_all(&automation_dir).await.unwrap(); + fs::write(automation_dir.join("notes.txt"), "ignore") + .await + .unwrap(); + fs::write(automation_dir.join("bad name.toml"), "name = \"Bad\"") + .await + .unwrap(); + fs::write(automation_dir.join("broken.toml"), "not valid toml =") + .await + .unwrap(); + fs::write( + automation_dir.join("empty-name.toml"), + r#" +name = " " + +[target] +repository = "fabro-sh/fabro" +workflow = "release" +"#, + ) + .await + .unwrap(); + fs::write( + automation_dir.join("valid.toml"), + r#" +name = "Valid" + +[target] +repository = "fabro-sh/fabro" +ref = "main" +workflow = "release" +"#, + ) + .await + .unwrap(); + + let store = AutomationStore::load(&automation_dir).await.unwrap(); + let automations = store.list().await; + + assert_eq!(automations.len(), 1); + assert_eq!(automations[0].id.as_str(), "valid"); + assert_eq!(automations[0].name, "Valid"); + } + + #[tokio::test] + async fn create_does_not_overwrite_existing_malformed_file() { + let dir = tempfile::tempdir().unwrap(); + let automation_dir = dir.path().join("automations"); + fs::create_dir_all(&automation_dir).await.unwrap(); + let path = automation_dir.join("nightly.toml"); + fs::write(&path, "not valid toml =").await.unwrap(); + + let store = AutomationStore::load(&automation_dir).await.unwrap(); + let result = store.create(draft("nightly", "Nightly")).await; + + assert!(matches!( + result, + Err(AutomationStoreError::AlreadyExists { id }) if id.as_str() == "nightly" + )); + assert_eq!(fs::read_to_string(&path).await.unwrap(), "not valid toml ="); + } + + #[tokio::test] + async fn create_replace_and_delete_round_trip_files_and_revisions() { + let dir = tempfile::tempdir().unwrap(); + let automation_dir = dir.path().join("automations"); + let store = AutomationStore::load(&automation_dir).await.unwrap(); + + let created = store.create(draft("nightly", "Nightly")).await.unwrap(); + let path = automation_dir.join("nightly.toml"); + let persisted = fs::read_to_string(&path).await.unwrap(); + assert!(persisted.contains("name = \"Nightly\"")); + assert!(!top_level_lines(&persisted).any(|line| line.starts_with("id = "))); + assert!(!top_level_lines(&persisted).any(|line| line.starts_with("revision = "))); + assert_eq!( + created.revision, + crate::AutomationRevision::from_bytes(persisted.as_bytes()) + ); + assert!(store.create(draft("nightly", "Duplicate")).await.is_err()); + + let stale = crate::AutomationRevision::from_bytes(b"stale"); + assert!( + store + .replace(&created.id, &stale, replacement("Updated")) + .await + .is_err() + ); + + let replaced = store + .replace(&created.id, &created.revision, replacement("Updated")) + .await + .unwrap(); + assert_ne!(replaced.revision, created.revision); + assert_eq!( + store.get(&created.id).await.unwrap().revision, + replaced.revision + ); + + store.delete(&created.id, &replaced.revision).await.unwrap(); + assert!(store.get(&created.id).await.is_none()); + assert!(!path.exists()); + } + + fn top_level_lines(toml: &str) -> impl Iterator { + toml.lines().take_while(|line| !line.starts_with('[')) + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index c8ad2fff2..77c5c113a 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -838,6 +838,7 @@ mod tests { graph: fabro_types::Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: std::collections::HashMap::default(), provenance: None, diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs index 7c7c59a22..80b65e4aa 100644 --- a/lib/crates/fabro-cli/tests/it/support/mod.rs +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -46,6 +46,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s graph: Graph::new("Remote Workflow"), graph_source: None, workflow_slug: Some("remote-workflow".to_string()), + automation: None, source_directory: Some("/srv/repo".to_string()), labels: std::collections::HashMap::default(), provenance: None, diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs index d44e01215..f042cef01 100644 --- a/lib/crates/fabro-dump/src/lib.rs +++ b/lib/crates/fabro-dump/src/lib.rs @@ -488,6 +488,7 @@ mod tests { graph: Graph::new("ship"), graph_source: Some("digraph Ship {}".to_string()), workflow_slug: Some("demo".to_string()), + automation: None, source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { origin_url: "https://github.com/fabro-sh/fabro.git".to_string(), diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs index 53657282a..645c53424 100644 --- a/lib/crates/fabro-server/src/run_files.rs +++ b/lib/crates/fabro-server/src/run_files.rs @@ -2384,6 +2384,7 @@ index 1111111..2222222 160000 graph: fabro_types::Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::default(), provenance: None, diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 00a97e13a..c60d03817 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -213,6 +213,7 @@ pub(crate) fn create_run_input( submitted_manifest_bytes: None, run_id: prepared.run_id, title: prepared.title, + automation: None, git: prepared.git, fork_source_ref: None, parent_id: prepared.parent_id, diff --git a/lib/crates/fabro-server/src/server/handler/events.rs b/lib/crates/fabro-server/src/server/handler/events.rs index 8e7806522..180c04bdd 100644 --- a/lib/crates/fabro-server/src/server/handler/events.rs +++ b/lib/crates/fabro-server/src/server/handler/events.rs @@ -568,6 +568,7 @@ mod stage_events_tests { run_dir: "/tmp/test".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/crates/fabro-server/src/server/handler/pair.rs index e43f4e6f8..7b673fad8 100644 --- a/lib/crates/fabro-server/src/server/handler/pair.rs +++ b/lib/crates/fabro-server/src/server/handler/pair.rs @@ -1022,6 +1022,7 @@ mod tests { run_dir: "/tmp/test".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs index 1791ee57b..aaaaf368f 100644 --- a/lib/crates/fabro-server/src/server/handler/sessions.rs +++ b/lib/crates/fabro-server/src/server/handler/sessions.rs @@ -1699,6 +1699,7 @@ mod tests { graph, graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::default(), provenance: None, diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 83e98195d..b0105392c 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -3370,6 +3370,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, @@ -3415,6 +3416,7 @@ async fn create_slack_notification_run( run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: workflow_slug.map(str::to_string), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, @@ -4421,6 +4423,7 @@ async fn list_run_stages_distinguishes_visits() { run_dir: String::new(), source_directory: None, workflow_slug: Some("test".to_string()), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, @@ -5472,6 +5475,7 @@ async fn create_completed_run_ready_for_pull_request( graph, graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some("/tmp/project".to_string()), git: git.clone(), labels: HashMap::new(), @@ -5493,6 +5497,7 @@ async fn create_completed_run_ready_for_pull_request( run_dir: run_spec.source_directory.clone().unwrap_or_default(), source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), + automation: None, db_prefix: None, provenance: run_spec.provenance.clone(), manifest_blob: None, @@ -11608,6 +11613,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc, run_id: RunId run_dir: "/tmp/fabro-run".to_string(), source_directory: Some("/tmp/fabro-run".to_string()), workflow_slug: Some("test".to_string()), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, @@ -12359,6 +12365,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() { run_dir: "/tmp/fabro-run".to_string(), source_directory: Some("/tmp/fabro-run".to_string()), workflow_slug: Some("test".to_string()), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs index e820b00f9..783100d5c 100644 --- a/lib/crates/fabro-server/tests/it/api/run_files.rs +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -67,6 +67,7 @@ async fn append_completed_run_with_final_patch( run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index c1358fe0c..2955cbbed 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -780,6 +780,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result { graph: props.graph.clone(), graph_source: props.workflow_source.clone(), workflow_slug: props.workflow_slug.clone(), + automation: props.automation.clone(), source_directory: props.source_directory.clone(), labels, provenance: props.provenance.clone(), @@ -936,7 +937,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run { edge_count: i64::try_from(state.spec.graph.edges.len()) .expect("graph edge count should fit in i64"), }, - automation: None, + automation: state.spec.automation.clone(), repository: Some(RepositoryRef::from_origin_and_source( repo_origin_url, source_directory.as_deref(), @@ -1248,11 +1249,11 @@ mod tests { }; use fabro_types::settings::run::{DockerfileSource, EnvironmentProvider}; use fabro_types::{ - AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, - CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail, - FailureReason, Graph, McpServerStatus, Outcome, PendingReason, PermissionLevel, - PullRequestLink, QuestionType, ReasoningEffort, RunApprovalState, RunBlobId, - RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, Speed, + AgentBackend, AutomationRef, BilledModelUsage, BilledTokenCounts, BlockedReason, + Checkpoint, CheckpointRecord, CommandTermination, EventBody, FailureCategory, + FailureDetail, FailureReason, Graph, McpServerStatus, Outcome, PendingReason, + PermissionLevel, PullRequestLink, QuestionType, ReasoningEffort, RunApprovalState, + RunBlobId, RunControlAction, RunDiff, RunEvent, RunSize, RunSpec, RunStatus, Speed, StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod, StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning, StageModelUsage, StageOutcome, StageState, SubAgentStatus, SuccessReason, WorkflowSettings, @@ -1335,6 +1336,7 @@ mod tests { graph: Graph::new("test"), graph_source: Some("digraph test {}".to_string()), workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::new(), provenance: None, @@ -1438,6 +1440,35 @@ mod tests { ); } + #[test] + fn run_created_projects_automation_into_spec_and_summary() { + let automation = AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule_1".to_string()), + }; + let event = test_raw_event( + 1, + "run.created", + &json!({ + "settings": WorkflowSettings::default(), + "graph": Graph::new("test"), + "automation": automation, + "labels": {}, + "run_dir": "/tmp/run" + }), + None, + ); + + let projection = RunProjection::apply_events(&[event]).unwrap(); + + assert_eq!(projection.spec.automation, Some(automation.clone())); + assert_eq!( + build_summary(&projection, &fixtures::RUN_1).automation, + Some(automation) + ); + } + fn test_raw_event( seq: u32, event: &str, @@ -2647,6 +2678,7 @@ mod tests { graph: fabro_types::Graph::new("test"), graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some("/tmp/repo".to_string()), git: None, labels: HashMap::new(), @@ -2672,6 +2704,7 @@ mod tests { graph: fabro_types::Graph::new("GraphName"), graph_source: None, workflow_slug: Some("release-flow".to_string()), + automation: None, source_directory: Some("/tmp/repo".to_string()), git: None, labels: HashMap::new(), diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 784868ace..c6406fd8d 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -539,6 +539,7 @@ mod tests { graph, graph_source: None, workflow_slug: Some("night-sky".to_string()), + automation: None, source_directory: Some(format!("/tmp/{label}")), labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]), provenance: None, diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index f6aa256fd..db34fa664 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -19,6 +19,7 @@ fn sample_run_spec() -> RunSpec { graph: Graph::new("ship"), graph_source: None, workflow_slug: Some("demo".to_string()), + automation: None, source_directory: Some("/tmp/project".to_string()), labels: HashMap::from([("team".to_string(), "platform".to_string())]), provenance: None, diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 269db43ee..2b27d0a06 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -7,6 +7,7 @@ use crate::graph::Graph; use crate::principal::Principal; use crate::run_blob_id::RunBlobId; use crate::run_id::RunId; +use crate::run_summary::AutomationRef; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct RunServerProvenance { @@ -87,6 +88,8 @@ pub struct RunSpec { #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub automation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub source_directory: Option, #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub labels: HashMap, @@ -123,6 +126,11 @@ impl RunSpec { self.workflow_slug.as_deref() } + #[must_use] + pub fn automation(&self) -> Option<&AutomationRef> { + self.automation.as_ref() + } + #[must_use] pub fn workflow_name(&self) -> Option<&str> { self.settings.workflow.name.as_deref() diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index fa189171f..e3023997a 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize}; use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel}; use crate::status::{BlockedReason, PendingReason, SuccessReason}; use crate::{ - DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId, RunControlAction, - RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings, + AutomationRef, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId, + RunControlAction, RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -27,6 +27,8 @@ pub struct RunCreatedProps { #[serde(default, skip_serializing_if = "Option::is_none")] pub workflow_slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub automation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub db_prefix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub provenance: Option, diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index cb28d1ff0..4b52dcc96 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -697,6 +697,7 @@ mod title_tests { graph, graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::new(), provenance: None, @@ -766,6 +767,7 @@ mod iter_stages_tests { graph: Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::default(), provenance: None, diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index 1cde31614..fd35dc2a4 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -104,9 +104,11 @@ pub struct WorkflowRef { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AutomationRef { - pub id: String, + pub id: String, #[serde(default)] - pub name: Option, + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs index 8f2df1972..49af80c50 100644 --- a/lib/crates/fabro-types/tests/run_event_serde.rs +++ b/lib/crates/fabro-types/tests/run_event_serde.rs @@ -6,7 +6,7 @@ use fabro_types::run_event::run::{RunCreatedProps, RunParentLinkedProps, RunPare use fabro_types::run_event::{RunSessionTurnFailedCode, RunSessionTurnFailedProps}; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; -use fabro_types::{EventBody, TurnId, WorkflowSettings, fixtures}; +use fabro_types::{AutomationRef, EventBody, TurnId, WorkflowSettings, fixtures}; fn templated_settings() -> WorkflowSettings { let mut settings = WorkflowSettings::default(); @@ -26,6 +26,11 @@ fn run_created_props_round_trip_templated_settings() { run_dir: "/tmp/run".to_string(), source_directory: Some("/Users/client/project".to_string()), workflow_slug: Some("demo".to_string()), + automation: Some(AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule_1".to_string()), + }), db_prefix: Some("run_".to_string()), provenance: None, manifest_blob: None, @@ -62,6 +67,8 @@ fn run_created_props_round_trip_templated_settings() { ); assert_eq!(json["retried_from"], fixtures::RUN_1.to_string()); assert_eq!(json["parent_id"], fixtures::RUN_2.to_string()); + assert_eq!(json["automation"]["id"], "nightly"); + assert_eq!(json["automation"]["trigger_id"], "schedule_1"); let round_trip: RunCreatedProps = serde_json::from_value(json.clone()).expect("props should deserialize"); @@ -88,6 +95,7 @@ fn run_created_props_omits_web_url_when_absent() { run_dir: "/tmp/run".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, @@ -120,7 +128,7 @@ fn run_created_props_omits_web_url_when_absent() { } #[test] -fn run_created_props_defaults_retried_from_for_legacy_events() { +fn run_created_props_defaults_additive_fields_for_legacy_events() { let json = serde_json::json!({ "title": null, "settings": WorkflowSettings::default(), @@ -132,6 +140,7 @@ fn run_created_props_defaults_retried_from_for_legacy_events() { let props: RunCreatedProps = serde_json::from_value(json).expect("legacy props should deserialize"); assert_eq!(props.retried_from, None); + assert_eq!(props.automation, None); } #[test] diff --git a/lib/crates/fabro-types/tests/run_spec_methods.rs b/lib/crates/fabro-types/tests/run_spec_methods.rs index f5e8cf25f..b05dca05e 100644 --- a/lib/crates/fabro-types/tests/run_spec_methods.rs +++ b/lib/crates/fabro-types/tests/run_spec_methods.rs @@ -24,6 +24,7 @@ fn sample_run_spec() -> RunSpec { graph: Graph::new("ship"), graph_source: None, workflow_slug: Some("demo".to_string()), + automation: None, source_directory: Some("/Users/client/project".to_string()), labels: HashMap::from([("team".to_string(), "platform".to_string())]), provenance: None, diff --git a/lib/crates/fabro-types/tests/run_spec_serde.rs b/lib/crates/fabro-types/tests/run_spec_serde.rs index f6278ff34..89b09ccbd 100644 --- a/lib/crates/fabro-types/tests/run_spec_serde.rs +++ b/lib/crates/fabro-types/tests/run_spec_serde.rs @@ -4,7 +4,7 @@ use fabro_types::graph::Graph; use fabro_types::run::{DirtyStatus, ForkSourceRef, GitContext, PreRunPushOutcome, RunSpec}; use fabro_types::settings::InterpString; use fabro_types::settings::run::RunGoal; -use fabro_types::{WorkflowSettings, fixtures}; +use fabro_types::{AutomationRef, WorkflowSettings, fixtures}; fn templated_settings() -> WorkflowSettings { let mut settings = WorkflowSettings::default(); @@ -20,6 +20,11 @@ fn run_spec_round_trips_templated_settings() { graph: Graph::new("ship"), graph_source: None, workflow_slug: Some("demo".to_string()), + automation: Some(AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule_1".to_string()), + }), source_directory: Some("/Users/client/project".to_string()), labels: HashMap::from([("team".to_string(), "platform".to_string())]), provenance: None, @@ -54,6 +59,8 @@ fn run_spec_round_trips_templated_settings() { assert_eq!(json["git"]["dirty"], "clean"); assert_eq!(json["git"]["push_outcome"]["type"], "succeeded"); assert_eq!(json["fork_source_ref"]["checkpoint_sha"], "def456"); + assert_eq!(json["automation"]["id"], "nightly"); + assert_eq!(json["automation"]["trigger_id"], "schedule_1"); let round_trip: RunSpec = serde_json::from_value(json.clone()).expect("record should deserialize"); @@ -66,3 +73,17 @@ fn run_spec_round_trips_templated_settings() { Some(RunGoal::Inline(InterpString::parse("Ship {{ env.TASK }}"))) ); } + +#[test] +fn run_spec_defaults_automation_for_legacy_specs() { + let json = serde_json::json!({ + "run_id": fixtures::RUN_1, + "settings": WorkflowSettings::default(), + "graph": Graph::new("ship"), + "labels": {} + }); + + let record: RunSpec = serde_json::from_value(json).expect("legacy spec should deserialize"); + + assert_eq!(record.automation, None); +} diff --git a/lib/crates/fabro-workflow/src/billing_rollup.rs b/lib/crates/fabro-workflow/src/billing_rollup.rs index feceb0e12..d9e2867ab 100644 --- a/lib/crates/fabro-workflow/src/billing_rollup.rs +++ b/lib/crates/fabro-workflow/src/billing_rollup.rs @@ -350,6 +350,7 @@ mod tests { graph, graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::new(), provenance: None, diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 032aae39b..fb2f9f054 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -34,6 +34,7 @@ fn event_body_from_event(event: &Event) -> EventBody { run_dir, source_directory, workflow_slug, + automation, db_prefix, provenance, manifest_blob, @@ -54,6 +55,7 @@ fn event_body_from_event(event: &Event) -> EventBody { run_dir: run_dir.clone(), source_directory: source_directory.clone(), workflow_slug: workflow_slug.clone(), + automation: automation.clone(), db_prefix: db_prefix.clone(), provenance: provenance.clone(), manifest_blob: *manifest_blob, @@ -1414,8 +1416,9 @@ mod tests { use std::collections::BTreeMap; use ::fabro_types::{ - EventBody, FailureReason, ParallelBranchId, Principal, RunNoticeCode, RunNoticeLevel, - RunProvenance, StageId, SystemActorKind, fixtures, run_event as fabro_types, + AutomationRef, EventBody, FailureReason, ParallelBranchId, Principal, RunNoticeCode, + RunNoticeLevel, RunProvenance, StageId, SystemActorKind, fixtures, + run_event as fabro_types, }; use chrono::Utc; use fabro_agent::{ @@ -2409,6 +2412,11 @@ mod tests { client: None, subject: Some(user_principal("alice")), }; + let automation = AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule_1".to_string()), + }; let stored = to_run_event(&fixtures::RUN_1, &Event::RunCreated { run_id: fixtures::RUN_1, @@ -2421,6 +2429,7 @@ mod tests { run_dir: "/tmp/run".to_string(), source_directory: Some("/tmp/run".to_string()), workflow_slug: None, + automation: Some(automation.clone()), db_prefix: None, provenance: Some(provenance), manifest_blob: None, @@ -2432,6 +2441,10 @@ mod tests { }); let actor = stored.actor.as_ref().expect("actor set"); assert_eq!(actor, &user_principal("alice")); + let EventBody::RunCreated(props) = stored.body else { + panic!("expected run.created body"); + }; + assert_eq!(props.automation, Some(automation)); } #[test] diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index 8615f53bd..0627829c0 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -1,12 +1,12 @@ use std::collections::BTreeMap; use ::fabro_types::{ - BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason, - ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget, - ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, RunBlobId, - RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, - RunRunnableSource, RunTiming, SandboxProviderKind, StageId, StageTiming, SuccessReason, - run_event as fabro_types, + AutomationRef, BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, + FailureReason, ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, + PairTarget, ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, + RunBlobId, RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, + RunProvenance, RunRunnableSource, RunTiming, SandboxProviderKind, StageId, StageTiming, + SuccessReason, run_event as fabro_types, }; use fabro_agent::{AgentEvent, SandboxEvent}; use fabro_model::{ReasoningEffort, Speed}; @@ -38,6 +38,8 @@ pub enum Event { #[serde(default, skip_serializing_if = "Option::is_none")] workflow_slug: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + automation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] db_prefix: Option, #[serde(default, skip_serializing_if = "Option::is_none")] provenance: Option, diff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs index 967f0570a..7b65fc99c 100644 --- a/lib/crates/fabro-workflow/src/event/sink.rs +++ b/lib/crates/fabro-workflow/src/event/sink.rs @@ -242,6 +242,7 @@ mod tests { run_dir: "/tmp/test".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index f48683dd0..643d49b58 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -467,6 +467,7 @@ mod tests { run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 648f00c4b..3a120e8fc 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -482,6 +482,7 @@ mod tests { run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index dde68dd5d..f10ebafde 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/fabro-workflow/src/handler/command.rs @@ -253,6 +253,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: std::collections::HashMap::default(), provenance: None, @@ -354,6 +355,7 @@ mod tests { run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index 7c85042cc..b52478bac 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -726,6 +726,7 @@ mod tests { run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 8d5b2fdf6..1c2a82267 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -281,6 +281,7 @@ mod tests { run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index fefaafa8c..ccd24632d 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -734,6 +734,7 @@ mod tests { run_dir: "/tmp/run".to_string(), source_directory: Some("/tmp/project".to_string()), workflow_slug: Some("metadata".to_string()), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/crates/fabro-workflow/src/operations/archive.rs index bb3693398..db4b0ccf9 100644 --- a/lib/crates/fabro-workflow/src/operations/archive.rs +++ b/lib/crates/fabro-workflow/src/operations/archive.rs @@ -224,6 +224,7 @@ mod tests { run_dir: "/tmp".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 0460a1874..9ce70f963 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -13,7 +13,7 @@ use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, ProviderId}; use fabro_store::Database; use fabro_types::{ - ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings, + AutomationRef, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings, }; use fabro_util::json::normalize_json_value; use tokio::task::spawn_blocking; @@ -41,6 +41,7 @@ pub struct CreateRunInput { pub submitted_manifest_bytes: Option>, pub run_id: Option, pub title: Option, + pub automation: Option, pub git: Option, pub fork_source_ref: Option, pub parent_id: Option, @@ -68,6 +69,7 @@ struct PersistCreateOptions { source_name: Option, labels: HashMap, source_directory: Option, + automation: Option, git: Option, fork_source_ref: Option, provenance: Option, @@ -102,6 +104,7 @@ pub async fn create( submitted_manifest_bytes, run_id, title, + automation, git, fork_source_ref, parent_id, @@ -144,6 +147,7 @@ pub async fn create( source_name, labels, source_directory, + automation, git, fork_source_ref, provenance, @@ -240,6 +244,7 @@ async fn persist_created_run( run_dir: persisted.run_dir().display().to_string(), source_directory: record.source_directory.clone(), workflow_slug: record.workflow_slug.clone(), + automation: record.automation.clone(), db_prefix: None, provenance: record.provenance.clone(), manifest_blob, @@ -356,6 +361,7 @@ fn persist_validated( source_name: _, labels, source_directory, + automation, git, fork_source_ref, provenance, @@ -379,6 +385,7 @@ fn persist_validated( graph: validated.graph().clone(), graph_source: Some(validated.source().to_string()), workflow_slug, + automation, source_directory, labels, provenance, @@ -1096,6 +1103,7 @@ mod tests { submitted_manifest_bytes: None, run_id: None, title: None, + automation: None, git: None, fork_source_ref: None, parent_id: None, @@ -1157,6 +1165,7 @@ mod tests { submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_1), title: None, + automation: None, git: Some(fabro_types::GitContext { origin_url: String::new(), branch: "main".to_string(), @@ -1274,6 +1283,7 @@ mod tests { submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_2), title: None, + automation: None, git: None, fork_source_ref: None, parent_id: None, @@ -1313,6 +1323,7 @@ mod tests { submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_2), title: None, + automation: None, git: Some(fabro_types::GitContext { origin_url: "https://github.com/acme/widgets".to_string(), branch: String::new(), @@ -1371,6 +1382,11 @@ mod tests { Duration::from_millis(1), None, )); + let automation = fabro_types::AutomationRef { + id: "nightly".to_string(), + name: Some("Nightly".to_string()), + trigger_id: Some("schedule_1".to_string()), + }; let created = create( store.as_ref(), CreateRunInput { @@ -1386,6 +1402,7 @@ mod tests { submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_3), title: None, + automation: Some(automation.clone()), git: None, fork_source_ref: None, parent_id: None, @@ -1400,8 +1417,14 @@ mod tests { .unwrap(); let run_store = store.open_run_reader(&created.run_id).await.unwrap(); let events = run_store.list_events().await.unwrap(); + let state = run_store.state().await.unwrap(); assert_eq!(events.first().unwrap().event.event_name(), "run.created"); + assert_eq!( + created.persisted.run_spec().automation, + Some(automation.clone()) + ); + assert_eq!(state.spec.automation, Some(automation)); } #[tokio::test] @@ -1432,6 +1455,7 @@ mod tests { submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_64), title: None, + automation: None, git: None, fork_source_ref: None, parent_id: None, diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index 513975d71..2bf7e144f 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -161,6 +161,7 @@ async fn persist_forked_run( run_dir: String::new(), source_directory: spec.source_directory.clone(), workflow_slug: spec.workflow_slug.clone(), + automation: spec.automation.clone(), db_prefix: None, provenance: spec.provenance.clone(), manifest_blob: spec.manifest_blob, @@ -380,6 +381,7 @@ mod tests { run_dir: "/tmp/source".to_string(), source_directory: Some("/client/source".to_string()), workflow_slug: Some("fork-source".to_string()), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/crates/fabro-workflow/src/operations/retry.rs index ce8ddbc7c..66c37c022 100644 --- a/lib/crates/fabro-workflow/src/operations/retry.rs +++ b/lib/crates/fabro-workflow/src/operations/retry.rs @@ -48,6 +48,7 @@ pub async fn retry_run( graph, graph_source, workflow_slug, + automation, source_directory, labels, provenance: _, @@ -76,6 +77,7 @@ pub async fn retry_run( run_dir: String::new(), source_directory, workflow_slug, + automation, db_prefix: None, provenance: input.provenance.clone(), manifest_blob, @@ -186,6 +188,7 @@ mod tests { run_dir: "/tmp/source".to_string(), source_directory: Some("/workspace/source".to_string()), workflow_slug: Some("retry-source".to_string()), + automation: None, db_prefix: None, provenance: Some(provenance("source-user")), manifest_blob, diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 1caa26f47..a885871da 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -1413,6 +1413,7 @@ reasoning = false submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_1), title: None, + automation: None, git: None, fork_source_ref: None, parent_id: None, @@ -1834,6 +1835,7 @@ reasoning = false submitted_manifest_bytes: None, run_id: Some(fixtures::RUN_1), title: None, + automation: None, git: None, fork_source_ref: None, parent_id: None, diff --git a/lib/crates/fabro-workflow/src/operations/timeline.rs b/lib/crates/fabro-workflow/src/operations/timeline.rs index 2170dc28a..68e0ee959 100644 --- a/lib/crates/fabro-workflow/src/operations/timeline.rs +++ b/lib/crates/fabro-workflow/src/operations/timeline.rs @@ -245,6 +245,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::new(), provenance: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index cef35cc39..985b5e247 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -150,6 +150,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI graph, graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some( std::env::current_dir() .unwrap_or_else(|_| PathBuf::from(".")) @@ -206,6 +207,7 @@ async fn seed_created_and_starting( run_dir: run_options.run_dir.display().to_string(), source_directory: Some(std::env::current_dir().unwrap().display().to_string()), workflow_slug: run_options.workflow_slug.clone(), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 05d6db1d0..c677c1007 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -737,6 +737,7 @@ mod tests { run_dir: "/tmp/run".to_string(), source_directory: Some("/tmp/project".to_string()), workflow_slug: Some("metadata".to_string()), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, @@ -852,6 +853,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::new(), provenance: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index c8755e7c7..1b97a5d15 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -881,6 +881,7 @@ mod tests { graph, graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some(std::env::current_dir().unwrap().display().to_string()), git: Some(fabro_types::GitContext { origin_url: String::new(), diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index ee6150696..7a8c896e4 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -135,6 +135,7 @@ mod tests { graph, graph_source: None, workflow_slug: Some("ship".to_string()), + automation: None, source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { origin_url: String::new(), @@ -168,6 +169,7 @@ mod tests { run_dir: run_dir.to_string_lossy().to_string(), source_directory: record.source_directory.clone(), workflow_slug: record.workflow_slug.clone(), + automation: record.automation.clone(), db_prefix: None, provenance: record.provenance.clone(), manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 92f4a0bc3..c02376988 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -820,6 +820,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: None, labels: HashMap::new(), provenance: None, @@ -1137,6 +1138,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { origin_url: String::new(), @@ -1162,6 +1164,7 @@ mod tests { run_dir: "/tmp/project".to_string(), source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), + automation: None, db_prefix: None, provenance: run_spec.provenance.clone(), manifest_blob: None, @@ -1206,6 +1209,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { origin_url: String::new(), @@ -1231,6 +1235,7 @@ mod tests { run_dir: "/tmp/project".to_string(), source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), + automation: None, db_prefix: None, provenance: run_spec.provenance.clone(), manifest_blob: None, @@ -1566,6 +1571,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: Some(tmp.path().display().to_string()), git: None, labels: std::collections::HashMap::new(), @@ -1585,6 +1591,7 @@ mod tests { run_dir: tmp.path().display().to_string(), source_directory: run_spec.source_directory.clone(), workflow_slug: None, + automation: None, db_prefix: None, provenance: run_spec.provenance.clone(), manifest_blob: None, @@ -1693,6 +1700,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some("/tmp/project".to_string()), git: None, labels: HashMap::new(), @@ -1712,6 +1720,7 @@ mod tests { run_dir: "/tmp/project".to_string(), source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, @@ -1862,6 +1871,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: None, + automation: None, source_directory: None, git: None, labels: HashMap::new(), @@ -1881,6 +1891,7 @@ mod tests { run_dir: "/tmp/x".to_string(), source_directory: None, workflow_slug: None, + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index 5d8cdeb3b..e7b0badfc 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -481,6 +481,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some("/tmp/project".to_string()), git: Some(fabro_types::GitContext { origin_url: String::new(), @@ -517,6 +518,7 @@ mod tests { run_dir: run_dir.display().to_string(), source_directory: run_spec.source_directory.clone(), workflow_slug: run_spec.workflow_slug.clone(), + automation: None, db_prefix: None, provenance: run_spec.provenance.clone(), manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/run_metadata.rs b/lib/crates/fabro-workflow/src/run_metadata.rs index 9d679d0b4..b11c9667c 100644 --- a/lib/crates/fabro-workflow/src/run_metadata.rs +++ b/lib/crates/fabro-workflow/src/run_metadata.rs @@ -629,6 +629,7 @@ mod tests { graph: fabro_types::Graph::new("metadata"), graph_source: None, workflow_slug: Some("metadata".to_string()), + automation: None, source_directory: Some("/Users/client/project".to_string()), git: Some(GitContext { origin_url: "https://github.com/fabro-sh/fabro.git".to_string(), diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index 0f590c70f..0dafbfdad 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -144,6 +144,7 @@ mod tests { graph: Graph::new("test"), graph_source: None, workflow_slug: Some("test".to_string()), + automation: None, source_directory: Some("/tmp/test".to_string()), git: None, labels: HashMap::new(), @@ -167,6 +168,7 @@ mod tests { run_dir: "/tmp/test".to_string(), source_directory: Some("/tmp/test".to_string()), workflow_slug: Some("test".to_string()), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index c587e49fa..e72a0ee1b 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -173,6 +173,7 @@ async fn initialized( run_dir: run_options.run_dir.display().to_string(), source_directory: Some(sandbox.working_directory().to_string()), workflow_slug: run_options.workflow_slug.clone(), + automation: None, db_prefix: None, provenance: None, manifest_blob: None, diff --git a/lib/packages/fabro-api-client/src/models/automation-ref.ts b/lib/packages/fabro-api-client/src/models/automation-ref.ts index 46465579c..d8055703b 100644 --- a/lib/packages/fabro-api-client/src/models/automation-ref.ts +++ b/lib/packages/fabro-api-client/src/models/automation-ref.ts @@ -17,4 +17,5 @@ export interface AutomationRef { 'id': string; 'name': string | null; + 'trigger_id'?: string | null; } diff --git a/lib/packages/fabro-api-client/src/models/run-spec.ts b/lib/packages/fabro-api-client/src/models/run-spec.ts index 2be7312b7..f1e5adab6 100644 --- a/lib/packages/fabro-api-client/src/models/run-spec.ts +++ b/lib/packages/fabro-api-client/src/models/run-spec.ts @@ -13,6 +13,9 @@ */ +// May contain unused imports in some cases +// @ts-ignore +import type { AutomationRef } from './automation-ref'; // May contain unused imports in some cases // @ts-ignore import type { ForkSourceRef } from './fork-source-ref'; @@ -35,6 +38,7 @@ export interface RunSpec { 'graph': { [key: string]: any; }; 'graph_source'?: string | null; 'workflow_slug'?: string | null; + 'automation'?: AutomationRef | null; 'source_directory'?: string | null; 'labels'?: { [key: string]: string; }; 'provenance'?: RunProvenance | null; diff --git a/lib/packages/fabro-api-client/tests/principal-exhaustive.ts b/lib/packages/fabro-api-client/tests/principal-exhaustive.ts index 7b6bd4afd..114b1e640 100644 --- a/lib/packages/fabro-api-client/tests/principal-exhaustive.ts +++ b/lib/packages/fabro-api-client/tests/principal-exhaustive.ts @@ -1,4 +1,4 @@ -import type { Principal, PrincipalSystem, RunSpec } from "../src"; +import type { AutomationRef, Principal, PrincipalSystem, RunSpec } from "../src"; type AssertFalse = T; type AssertExtends = true; @@ -49,12 +49,27 @@ type SubjectIsNotAny = AssertFalse>; type SubjectExtendsPrincipal = AssertExtends; type PrincipalExtendsSubject = AssertExtends; +type Automation = NonNullable; +type AutomationExtendsRef = AssertExtends; +type AutomationTriggerId = NonNullable; + const _principalSubject: Subject = { kind: "system", system_kind: "watchdog", }; +const _automation: Automation = { + id: "nightly", + name: "Nightly", + trigger_id: "schedule_1", +}; + +const _automationTriggerId: AutomationTriggerId = "schedule_1"; + void (null as unknown as SubjectIsNotAny); void (null as unknown as SubjectExtendsPrincipal); void (null as unknown as PrincipalExtendsSubject); +void (null as unknown as AutomationExtendsRef); void _principalSubject; +void _automation; +void _automationTriggerId;