mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
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:
parent
ec1b3f2084
commit
e13de9faaf
59 changed files with 1764 additions and 41 deletions
137
Cargo.lock
generated
137
Cargo.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
27
lib/crates/fabro-automation/Cargo.toml
Normal file
27
lib/crates/fabro-automation/Cargo.toml
Normal 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"] }
|
||||
123
lib/crates/fabro-automation/src/error.rs
Normal file
123
lib/crates/fabro-automation/src/error.rs
Normal 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",
|
||||
}
|
||||
}
|
||||
}
|
||||
239
lib/crates/fabro-automation/src/id.rs
Normal file
239
lib/crates/fabro-automation/src/id.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
12
lib/crates/fabro-automation/src/lib.rs
Normal file
12
lib/crates/fabro-automation/src/lib.rs
Normal 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;
|
||||
540
lib/crates/fabro-automation/src/model.rs
Normal file
540
lib/crates/fabro-automation/src/model.rs
Normal 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('['))
|
||||
}
|
||||
}
|
||||
456
lib/crates/fabro-automation/src/store.rs
Normal file
456
lib/crates/fabro-automation/src/store.rs
Normal 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 ¤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<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('['))
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1699,6 +1699,7 @@ mod tests {
|
|||
graph,
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::default(),
|
||||
provenance: None,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -350,6 +350,7 @@ mod tests {
|
|||
graph,
|
||||
graph_source: None,
|
||||
workflow_slug: None,
|
||||
automation: None,
|
||||
source_directory: None,
|
||||
labels: HashMap::new(),
|
||||
provenance: None,
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -17,4 +17,5 @@
|
|||
export interface AutomationRef {
|
||||
'id': string;
|
||||
'name': string | null;
|
||||
'trigger_id'?: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue