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`
This commit is contained in:
Bryan Helmkamp 2026-05-27 11:52:57 -04:00 committed by GitHub
parent ec1b3f2084
commit e13de9faaf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
59 changed files with 1764 additions and 41 deletions

137
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<PathBuf>, source: std::io::Error) -> Self {
Self::Io {
path: path.into(),
source,
}
}
pub(crate) fn parse(path: impl Into<PathBuf>, source: TomlDeError) -> Self {
Self::Parse {
path: path.into(),
source,
}
}
pub(crate) fn invalid_utf8(path: impl Into<PathBuf>, 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",
}
}
}

View file

@ -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<String>) -> Result<Self, AutomationValidationError> {
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, Self::Err> {
Self::new(value)
}
}
impl Serialize for AutomationId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for AutomationId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<String>) -> Result<Self, AutomationValidationError> {
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, Self::Err> {
Self::new(value)
}
}
impl Serialize for AutomationTriggerId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for AutomationTriggerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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<Self, Self::Err> {
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<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for AutomationRevision {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
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::<AutomationId>().is_ok());
assert!("a-1".parse::<AutomationId>().is_ok());
assert!("0".parse::<AutomationId>().is_ok());
assert!("A".parse::<AutomationId>().is_err());
assert!("a_1".parse::<AutomationId>().is_err());
assert!("-a".parse::<AutomationId>().is_err());
assert!("a".repeat(64).parse::<AutomationId>().is_err());
}
#[test]
fn trigger_id_allows_underscore() {
assert!("api_trigger".parse::<AutomationTriggerId>().is_ok());
assert!("api.trigger".parse::<AutomationTriggerId>().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::<AutomationRevision>().is_ok());
assert!("ABC".parse::<AutomationRevision>().is_err());
}
}

View file

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

View file

@ -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<String>,
#[serde(default = "default_true")]
pub enabled: bool,
pub target: AutomationTarget,
#[serde(default)]
pub triggers: Vec<AutomationTrigger>,
}
impl Automation {
pub fn from_toml_bytes(id: AutomationId, bytes: &[u8]) -> Result<Self, AutomationStoreError> {
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<std::path::PathBuf>,
) -> Result<Self, AutomationStoreError> {
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<u8>), 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<String, AutomationStoreError> {
toml::to_string_pretty(&self.to_persisted()).map_err(AutomationStoreError::from)
}
fn from_persisted(
id: AutomationId,
revision: AutomationRevision,
persisted: PersistedAutomation,
) -> Result<Self, AutomationValidationError> {
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<String>,
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<String>,
#[serde(default = "default_true")]
pub enabled: bool,
pub target: AutomationTarget,
#[serde(default)]
pub triggers: Vec<AutomationTrigger>,
}
impl From<AutomationDraft> 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<String>,
#[serde(default = "default_true")]
pub enabled: bool,
pub target: AutomationTarget,
#[serde(default)]
pub triggers: Vec<AutomationTrigger>,
}
#[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<String>,
#[serde(default = "default_true")]
enabled: bool,
target: AutomationTarget,
#[serde(default)]
triggers: Vec<AutomationTrigger>,
}
impl From<AutomationReplace> 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<PersistedAutomation> 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<Vec<u8>, AutomationStoreError> {
let toml = toml::to_string_pretty(persisted)?;
Ok(toml.into_bytes())
}
fn parse_persisted(
bytes: &[u8],
path: Option<std::path::PathBuf>,
) -> Result<PersistedAutomation, AutomationStoreError> {
let content = std::str::from_utf8(bytes).map_err(|err| match &path {
Some(path) => AutomationStoreError::invalid_utf8(path.clone(), err),
None => AutomationStoreError::invalid_utf8("<memory>", err),
})?;
toml::from_str(content).map_err(|err| match path {
Some(path) => AutomationStoreError::parse(path, err),
None => AutomationStoreError::parse("<memory>", 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<Item = &str> {
toml.lines().take_while(|line| !line.starts_with('['))
}
}

View file

@ -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<HashMap<AutomationId, Automation>>,
}
impl AutomationStore {
pub async fn load(dir: impl Into<PathBuf>) -> Result<Self, AutomationStoreError> {
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<Automation> {
let automations = self.automations.read().await;
let mut values = automations.values().cloned().collect::<Vec<_>>();
values.sort_by(|left, right| left.id.cmp(&right.id));
values
}
pub async fn get(&self, id: &AutomationId) -> Option<Automation> {
self.automations.read().await.get(id).cloned()
}
pub async fn create(&self, draft: AutomationDraft) -> Result<Automation, AutomationStoreError> {
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<Automation, AutomationStoreError> {
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 &current.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 &current.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<HashMap<AutomationId, Automation>, 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<Automation, AutomationStoreError> {
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<AutomationId, AutomationStoreError> {
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<Item = &str> {
toml.lines().take_while(|line| !line.starts_with('['))
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1699,6 +1699,7 @@ mod tests {
graph,
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: HashMap::default(),
provenance: None,

View file

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

View file

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

View file

@ -780,6 +780,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
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(),

View file

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

View file

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

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_directory: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub labels: HashMap<String, String>,
@ -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()

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<RunProvenance>,

View file

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

View file

@ -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<String>,
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

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

View file

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

View file

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

View file

@ -350,6 +350,7 @@ mod tests {
graph,
graph_source: None,
workflow_slug: None,
automation: None,
source_directory: None,
labels: HashMap::new(),
provenance: None,

View file

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

View file

@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
db_prefix: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<RunProvenance>,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<Vec<u8>>,
pub run_id: Option<RunId>,
pub title: Option<String>,
pub automation: Option<AutomationRef>,
pub git: Option<GitContext>,
pub fork_source_ref: Option<ForkSourceRef>,
pub parent_id: Option<RunId>,
@ -68,6 +69,7 @@ struct PersistCreateOptions {
source_name: Option<String>,
labels: HashMap<String, String>,
source_directory: Option<String>,
automation: Option<AutomationRef>,
git: Option<GitContext>,
fork_source_ref: Option<ForkSourceRef>,
provenance: Option<RunProvenance>,
@ -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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,4 +17,5 @@
export interface AutomationRef {
'id': string;
'name': string | null;
'trigger_id'?: string | null;
}

View file

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

View file

@ -1,4 +1,4 @@
import type { Principal, PrincipalSystem, RunSpec } from "../src";
import type { AutomationRef, Principal, PrincipalSystem, RunSpec } from "../src";
type AssertFalse<T extends false> = T;
type AssertExtends<T extends U, U> = true;
@ -49,12 +49,27 @@ type SubjectIsNotAny = AssertFalse<IsAny<Subject>>;
type SubjectExtendsPrincipal = AssertExtends<Subject, Principal>;
type PrincipalExtendsSubject = AssertExtends<Principal, Subject>;
type Automation = NonNullable<RunSpec["automation"]>;
type AutomationExtendsRef = AssertExtends<Automation, AutomationRef>;
type AutomationTriggerId = NonNullable<AutomationRef["trigger_id"]>;
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;