fabro(01KSE2PAVXD56N4TWNK4T5H5VA): implement (succeeded)

Fabro-Run: 01KSE2PAVXD56N4TWNK4T5H5VA
Fabro-Completed: 7
Fabro-Checkpoint: 86b9c2e810

⚒️ Generated with [Fabro](https://fabro.sh)
This commit is contained in:
Fabro 2026-05-25 00:27:06 +00:00
parent 2a9ad6f0c1
commit afdd4900fa
85 changed files with 5138 additions and 48 deletions

140
Cargo.lock generated
View file

@ -105,7 +105,7 @@ dependencies = [
"serde",
"serde_json",
"serde_with",
"strum",
"strum 0.28.0",
"tracing",
]
@ -1022,6 +1022,17 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "croner"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576"
dependencies = [
"chrono",
"derive_builder",
"strum 0.27.2",
]
[[package]]
name = "crossbeam"
version = "0.8.4"
@ -1166,6 +1177,16 @@ dependencies = [
"darling_macro 0.14.4",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core 0.20.11",
"darling_macro 0.20.11",
]
[[package]]
name = "darling"
version = "0.23.0"
@ -1190,6 +1211,20 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 2.0.117",
]
[[package]]
name = "darling_core"
version = "0.23.0"
@ -1214,6 +1249,17 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core 0.20.11",
"quote",
"syn 2.0.117",
]
[[package]]
name = "darling_macro"
version = "0.23.0"
@ -1332,6 +1378,37 @@ dependencies = [
"serde_core",
]
[[package]]
name = "derive_builder"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
dependencies = [
"derive_builder_macro",
]
[[package]]
name = "derive_builder_core"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
dependencies = [
"darling 0.20.11",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "derive_builder_macro"
version = "0.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
dependencies = [
"derive_builder_core",
"syn 2.0.117",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@ -1632,7 +1709,7 @@ dependencies = [
"serde_json",
"sha2",
"shell-escape",
"strum",
"strum 0.28.0",
"tempfile",
"thiserror 2.0.18",
"tokio",
@ -1647,6 +1724,7 @@ name = "fabro-api"
version = "0.243.0-nightly.1"
dependencies = [
"chrono",
"fabro-automation",
"fabro-config",
"fabro-model",
"fabro-types",
@ -1687,6 +1765,22 @@ dependencies = [
"toml 0.8.23",
]
[[package]]
name = "fabro-automation"
version = "0.243.0-nightly.1"
dependencies = [
"chrono",
"croner",
"hex",
"serde",
"sha2",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.8.23",
"toml_edit",
]
[[package]]
name = "fabro-build-support"
version = "0.243.0-nightly.1"
@ -1963,7 +2057,7 @@ dependencies = [
"nom",
"regex",
"serde",
"strum",
"strum 0.28.0",
"thiserror 2.0.18",
]
@ -2056,7 +2150,7 @@ dependencies = [
"rand 0.9.4",
"serde",
"serde_json",
"strum",
"strum 0.28.0",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
@ -2137,7 +2231,7 @@ dependencies = [
"schemars 1.2.1",
"serde",
"serde_json",
"strum",
"strum 0.28.0",
"tempfile",
"tokio",
"toml 0.8.23",
@ -2153,7 +2247,7 @@ dependencies = [
"rust-embed",
"serde",
"serde_json",
"strum",
"strum 0.28.0",
"thiserror 2.0.18",
"toml 0.8.23",
"tracing",
@ -2245,7 +2339,7 @@ dependencies = [
"serde",
"serde_json",
"shlex",
"strum",
"strum 0.28.0",
"tar",
"tempfile",
"thiserror 2.0.18",
@ -2274,6 +2368,7 @@ dependencies = [
"fabro-agent",
"fabro-api",
"fabro-auth",
"fabro-automation",
"fabro-build-support",
"fabro-client",
"fabro-config",
@ -2322,7 +2417,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"sha2",
"strum",
"strum 0.28.0",
"sysinfo",
"tempfile",
"thiserror 2.0.18",
@ -2355,7 +2450,7 @@ dependencies = [
"rustls",
"serde",
"serde_json",
"strum",
"strum 0.28.0",
"thiserror 2.0.18",
"tokio",
"tokio-tungstenite 0.26.2",
@ -2480,7 +2575,7 @@ dependencies = [
"schemars 1.2.1",
"serde",
"serde_json",
"strum",
"strum 0.28.0",
"tempfile",
"tokio",
"toml 0.8.23",
@ -2514,7 +2609,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"strum",
"strum 0.28.0",
"tempfile",
"toml 0.8.23",
"ulid",
@ -6689,13 +6784,34 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros 0.27.2",
]
[[package]]
name = "strum"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
dependencies = [
"strum_macros",
"strum_macros 0.28.0",
]
[[package]]
name = "strum_macros"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]

View file

@ -15,6 +15,8 @@ tags:
description: Browser authentication and demo-mode controls
- name: Runs
description: Run management operations
- name: Automations
description: Server-owned runnable automation bindings
- name: Sessions
description: Ask Fabro sessions bound to runs
- name: Human-in-the-Loop
@ -1086,6 +1088,408 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Automations ───────────────────────────────────────────────────────
/api/v1/automations:
get:
operationId: listAutomations
tags: [Automations]
summary: List Automations
description: Returns automation definitions sorted by automation ID.
responses:
"200":
description: Automation definitions
content:
application/json:
schema:
$ref: "#/components/schemas/AutomationListResponse"
"400":
description: Invalid request
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
post:
operationId: createAutomation
tags: [Automations]
summary: Create Automation
description: Creates an automation and persists it as canonical TOML.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateAutomationRequest"
responses:
"201":
description: Automation created
content:
application/json:
schema:
$ref: "#/components/schemas/Automation"
"400":
description: Malformed JSON
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Automation already exists
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Invalid automation domain data, including unknown trigger types
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/automations/{id}:
parameters:
- name: id
in: path
required: true
description: Automation ID.
schema:
type: string
pattern: '^[a-z0-9][a-z0-9-]{0,62}$'
get:
operationId: getAutomation
tags: [Automations]
summary: Get Automation
responses:
"200":
description: Automation definition
headers:
ETag:
description: Current automation revision.
schema:
type: string
content:
application/json:
schema:
$ref: "#/components/schemas/Automation"
"400":
description: Invalid path syntax
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Automation not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
put:
operationId: replaceAutomation
tags: [Automations]
summary: Replace Automation
parameters:
- name: If-Match
in: header
required: true
description: Current automation revision, quoted or unquoted.
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ReplaceAutomationRequest"
responses:
"200":
description: Automation replaced
headers:
ETag:
description: New automation revision.
schema:
type: string
content:
application/json:
schema:
$ref: "#/components/schemas/Automation"
"400":
description: Malformed JSON or invalid path syntax
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Automation not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Stale automation revision
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Invalid automation domain data, including unknown trigger types
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"428":
description: Missing If-Match header
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
patch:
operationId: patchAutomation
tags: [Automations]
summary: Patch Automation
parameters:
- name: If-Match
in: header
required: true
description: Current automation revision, quoted or unquoted.
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/PatchAutomationRequest"
responses:
"200":
description: Automation patched
headers:
ETag:
description: New automation revision.
schema:
type: string
content:
application/json:
schema:
$ref: "#/components/schemas/Automation"
"400":
description: Malformed JSON or invalid path syntax
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Automation not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Stale automation revision
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Invalid automation domain data, including unknown trigger types
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"428":
description: Missing If-Match header
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
operationId: deleteAutomation
tags: [Automations]
summary: Delete Automation
parameters:
- name: If-Match
in: header
required: true
description: Current automation revision, quoted or unquoted.
schema:
type: string
responses:
"204":
description: Automation deleted
"400":
description: Invalid path syntax
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Automation not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Stale automation revision
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"428":
description: Missing If-Match header
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/automations/{id}/runs:
parameters:
- name: id
in: path
required: true
description: Automation ID.
schema:
type: string
pattern: '^[a-z0-9][a-z0-9-]{0,62}$'
get:
operationId: listAutomationRuns
tags: [Automations]
summary: List Automation Runs
parameters:
- $ref: "#/components/parameters/PageLimit"
- $ref: "#/components/parameters/PageOffset"
responses:
"200":
description: Runs associated with the automation
content:
application/json:
schema:
$ref: "#/components/schemas/PaginatedRunList"
"400":
description: Invalid path syntax
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Automation not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
post:
operationId: createAutomationRun
tags: [Automations]
summary: Start Automation Run
description: Materializes the automation target and creates a run when an enabled `api` trigger is present.
responses:
"201":
description: Run created
content:
application/json:
schema:
$ref: "#/components/schemas/Run"
"400":
description: Invalid path syntax
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Automation not found
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
description: Automation disabled or missing an enabled api trigger
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"422":
description: Automation target could not be materialized
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
# ── Runs ──────────────────────────────────────────────────────────────
/api/v1/runs:
@ -5341,6 +5745,200 @@ components:
meta:
$ref: "#/components/schemas/PaginationMeta"
Automation:
description: Server-owned runnable automation binding.
type: object
additionalProperties: false
required:
- id
- revision
- name
- description
- enabled
- target
- triggers
properties:
id:
type: string
pattern: '^[a-z0-9][a-z0-9-]{0,62}$'
revision:
type: string
description: Lowercase hex SHA-256 revision of the canonical TOML bytes.
name:
type: string
description:
type: ["string", "null"]
enabled:
type: boolean
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
type: array
items:
$ref: "#/components/schemas/AutomationTrigger"
AutomationTarget:
description: Repository, ref, and Fabro workflow selector materialized when the automation starts.
type: object
additionalProperties: false
required:
- repository
- ref
- workflow
properties:
repository:
type: string
description: GitHub owner/repo slug.
example: fabro-sh/fabro
ref:
type: string
description: Branch, tag, or SHA selector to check out.
example: main
workflow:
type: string
description: Fabro workflow slug or relative workflow path.
example: dependency-update
AutomationTrigger:
description: Automation trigger definition.
oneOf:
- $ref: "#/components/schemas/AutomationApiTrigger"
- $ref: "#/components/schemas/AutomationScheduleTrigger"
discriminator:
propertyName: type
mapping:
api: "#/components/schemas/AutomationApiTrigger"
schedule: "#/components/schemas/AutomationScheduleTrigger"
AutomationApiTrigger:
type: object
additionalProperties: false
required:
- id
- type
properties:
id:
type: string
pattern: '^[a-z0-9][a-z0-9_-]{0,62}$'
example: api
type:
type: string
enum: [api]
enabled:
type: boolean
default: true
AutomationScheduleTrigger:
type: object
additionalProperties: false
required:
- id
- type
- expression
properties:
id:
type: string
pattern: '^[a-z0-9][a-z0-9_-]{0,62}$'
example: nightly
type:
type: string
enum: [schedule]
enabled:
type: boolean
default: true
expression:
type: string
description: Five-field cron expression accepted by croner.
example: "0 3 * * *"
CreateAutomationRequest:
type: object
additionalProperties: false
required:
- id
- name
- target
- triggers
properties:
id:
type: string
pattern: '^[a-z0-9][a-z0-9-]{0,62}$'
name:
type: string
description:
type: ["string", "null"]
enabled:
type: boolean
default: true
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
type: array
items:
$ref: "#/components/schemas/AutomationTrigger"
ReplaceAutomationRequest:
type: object
additionalProperties: false
required:
- name
- enabled
- target
- triggers
properties:
name:
type: string
description:
type: ["string", "null"]
enabled:
type: boolean
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
type: array
items:
$ref: "#/components/schemas/AutomationTrigger"
PatchAutomationRequest:
type: object
additionalProperties: false
properties:
name:
type: string
description:
type: ["string", "null"]
enabled:
type: boolean
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
type: array
items:
$ref: "#/components/schemas/AutomationTrigger"
AutomationListResponse:
description: List of automation definitions.
type: object
additionalProperties: false
required:
- data
- meta
properties:
data:
type: array
items:
$ref: "#/components/schemas/Automation"
meta:
type: object
additionalProperties: false
required:
- total
properties:
total:
type: integer
format: uint64
minimum: 0
BatchRunLifecycleRequest:
description: Run IDs to archive or unarchive as one bounded fail-soft batch.
type: object
@ -9445,6 +10043,8 @@ components:
type: string
name:
type: ["string", "null"]
trigger_id:
type: string
RunOrigin:
type: object

View file

@ -15,6 +15,7 @@ wildcard_imports = "warn"
[dependencies]
chrono = { workspace = true, features = ["serde"] }
fabro-automation = { path = "../fabro-automation" }
fabro-config = { path = "../fabro-config" }
fabro-model = { path = "../fabro-model" }
fabro-types = { path = "../fabro-types" }

View file

@ -201,6 +201,29 @@ fn main() {
&[],
),
("Run", "fabro_types::Run", &[]),
("Automation", "fabro_automation::Automation", &[]),
("AutomationTarget", "fabro_automation::AutomationTarget", &[
]),
(
"AutomationTrigger",
"fabro_automation::AutomationTrigger",
&[],
),
(
"CreateAutomationRequest",
"fabro_automation::AutomationDraft",
&[],
),
(
"ReplaceAutomationRequest",
"fabro_automation::AutomationReplace",
&[],
),
(
"PatchAutomationRequest",
"fabro_automation::AutomationPatch",
&[],
),
("RunApproval", "fabro_types::RunApproval", &[]),
("RunApprovalState", "fabro_types::RunApprovalState", &[]),
("RunRunnableSource", "fabro_types::RunRunnableSource", &[]),

View file

@ -14,6 +14,11 @@ mod generated {
include!(concat!(env!("OUT_DIR"), "/codegen.rs"));
}
pub mod types {
pub use fabro_automation::{
ApiTrigger as AutomationApiTrigger, Automation, AutomationDraft as CreateAutomationRequest,
AutomationPatch as PatchAutomationRequest, AutomationReplace as ReplaceAutomationRequest,
AutomationTarget, AutomationTrigger, ScheduleTrigger as AutomationScheduleTrigger,
};
pub use fabro_model::{
Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode,
Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed,

View file

@ -0,0 +1,133 @@
use std::any::{TypeId, type_name};
use std::str::FromStr as _;
use fabro_api::types::{
Automation as ApiAutomation, AutomationTarget as ApiAutomationTarget,
AutomationTrigger as ApiAutomationTrigger, CreateAutomationRequest, PatchAutomationRequest,
ReplaceAutomationRequest,
};
use fabro_automation::{
ApiTrigger, Automation, AutomationDraft, AutomationPatch, AutomationReplace,
AutomationRevision, AutomationTarget, AutomationTrigger, AutomationTriggerId, GitRefSelector,
RepositorySlug, ScheduleTrigger, WorkflowSlug,
};
use serde_json::json;
#[test]
fn automation_api_reuses_domain_types() {
assert_same_type::<ApiAutomation, Automation>();
assert_same_type::<ApiAutomationTarget, AutomationTarget>();
assert_same_type::<ApiAutomationTrigger, AutomationTrigger>();
assert_same_type::<CreateAutomationRequest, AutomationDraft>();
assert_same_type::<ReplaceAutomationRequest, AutomationReplace>();
assert_same_type::<PatchAutomationRequest, AutomationPatch>();
}
#[test]
fn automation_json_matches_openapi_shape() {
let automation = Automation {
id: "nightly-deps".parse().unwrap(),
revision: AutomationRevision::from_str("abc123").unwrap(),
name: "Nightly dependency update".to_string(),
description: Some("Open a PR for dependency updates.".to_string()),
enabled: true,
target: target(),
triggers: vec![
AutomationTrigger::Api(ApiTrigger {
id: "api".parse().unwrap(),
enabled: false,
}),
AutomationTrigger::Schedule(ScheduleTrigger {
id: "nightly".parse().unwrap(),
enabled: true,
expression: "0 3 * * *".to_string(),
}),
],
};
assert_eq!(
serde_json::to_value(automation).unwrap(),
json!({
"id": "nightly-deps",
"revision": "abc123",
"name": "Nightly dependency update",
"description": "Open a PR for dependency updates.",
"enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
"workflow": "dependency-update"
},
"triggers": [
{ "id": "api", "type": "api", "enabled": false },
{ "id": "nightly", "type": "schedule", "enabled": true, "expression": "0 3 * * *" }
]
})
);
}
#[test]
fn automation_request_json_matches_openapi_shape() {
let create = AutomationDraft {
id: "nightly-deps".parse().unwrap(),
name: "Nightly dependency update".to_string(),
description: None,
enabled: None,
target: target(),
triggers: vec![AutomationTrigger::Api(ApiTrigger {
id: "api".parse().unwrap(),
enabled: true,
})],
};
assert_eq!(
serde_json::to_value(create).unwrap(),
json!({
"id": "nightly-deps",
"name": "Nightly dependency update",
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
"workflow": "dependency-update"
},
"triggers": [
{ "id": "api", "type": "api", "enabled": true }
]
})
);
let patch = AutomationPatch {
name: None,
description: Some(None),
enabled: None,
target: None,
triggers: None,
};
assert_eq!(
serde_json::to_value(patch).unwrap(),
json!({ "description": null })
);
}
fn target() -> AutomationTarget {
AutomationTarget {
repository: RepositorySlug::from_str("fabro-sh/fabro").unwrap(),
ref_: GitRefSelector::from_str("main").unwrap(),
workflow: WorkflowSlug::from_str("dependency-update").unwrap(),
}
}
#[test]
fn trigger_id_json_shape_is_string() {
let id = AutomationTriggerId::from_str("api_1").unwrap();
assert_eq!(serde_json::to_value(id).unwrap(), json!("api_1"));
}
fn assert_same_type<T: 'static, U: 'static>() {
assert_eq!(
TypeId::of::<T>(),
TypeId::of::<U>(),
"{} should be the same type as {}",
type_name::<T>(),
type_name::<U>()
);
}

View file

@ -134,6 +134,7 @@ fn run_spec_json() -> serde_json::Value {
manifest_blob: None,
definition_blob: None,
git: None,
automation: None,
fork_source_ref: None,
})
.unwrap()

View file

@ -0,0 +1,27 @@
[package]
name = "fabro-automation"
edition.workspace = true
version.workspace = true
publish = false
license.workspace = true
description = "Automation domain model and file-backed store"
[lib]
doctest = false
[lints]
workspace = true
[dependencies]
chrono.workspace = true
croner = "3.0.1"
hex.workspace = true
serde.workspace = true
sha2.workspace = true
thiserror.workspace = true
tokio.workspace = true
toml.workspace = true
toml_edit.workspace = true
[dev-dependencies]
tempfile = "3"

View file

@ -0,0 +1,75 @@
use std::path::PathBuf;
use toml::de::Error as TomlDeError;
use crate::id::AutomationId;
use crate::model::AutomationRevision;
#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
pub enum AutomationValidationError {
#[error("invalid automation id: {0}")]
InvalidAutomationId(String),
#[error("invalid automation trigger id: {0}")]
InvalidTriggerId(String),
#[error("automation name must not be empty")]
EmptyName,
#[error("invalid repository slug: {0}")]
InvalidRepositorySlug(String),
#[error("invalid git ref selector: {0}")]
InvalidGitRefSelector(String),
#[error("invalid workflow selector: {0}")]
InvalidWorkflowSelector(String),
#[error("duplicate trigger id: {0}")]
DuplicateTriggerId(String),
#[error("at most one api trigger is allowed")]
MultipleApiTriggers,
#[error("invalid schedule expression: {0}")]
InvalidScheduleExpression(String),
#[error("invalid trigger shape: {0}")]
InvalidTriggerShape(String),
#[error("unknown trigger type: {0}")]
UnknownTriggerType(String),
}
#[derive(Debug, thiserror::Error)]
pub enum AutomationStoreError {
#[error("automation not found: {0}")]
NotFound(AutomationId),
#[error("automation already exists: {0}")]
AlreadyExists(AutomationId),
#[error("missing automation revision")]
MissingRevision,
#[error("automation revision mismatch")]
RevisionMismatch {
expected: AutomationRevision,
actual: AutomationRevision,
},
#[error(transparent)]
Validation(#[from] AutomationValidationError),
#[error("failed to parse automation TOML at {}: {source}", path.display())]
Parse {
path: PathBuf,
source: TomlDeError,
},
#[error("I/O error at {}: {source}", path.display())]
Io {
path: PathBuf,
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,
}
}
}

View file

@ -0,0 +1,155 @@
use std::fmt;
use std::str::FromStr;
use serde::de::Error as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::error::AutomationValidationError;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AutomationId(String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AutomationTriggerId(String);
impl AutomationId {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AutomationTriggerId {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl AsRef<str> for AutomationId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl AsRef<str> for AutomationTriggerId {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for AutomationId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Display for AutomationTriggerId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl TryFrom<String> for AutomationId {
type Error = AutomationValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
validate_id(&value, false)
.then_some(Self(value.clone()))
.ok_or(AutomationValidationError::InvalidAutomationId(value))
}
}
impl TryFrom<String> for AutomationTriggerId {
type Error = AutomationValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
validate_id(&value, true)
.then_some(Self(value.clone()))
.ok_or(AutomationValidationError::InvalidTriggerId(value))
}
}
impl FromStr for AutomationId {
type Err = AutomationValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::try_from(value.to_string())
}
}
impl FromStr for AutomationTriggerId {
type Err = AutomationValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::try_from(value.to_string())
}
}
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 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 AutomationId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::try_from(value).map_err(D::Error::custom)
}
}
impl<'de> Deserialize<'de> for AutomationTriggerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::try_from(value).map_err(D::Error::custom)
}
}
fn validate_id(value: &str, allow_underscore: bool) -> bool {
let bytes = value.as_bytes();
matches!(bytes.first(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit())
&& bytes.len() <= 63
&& bytes.iter().skip(1).all(|b| {
b.is_ascii_lowercase()
|| b.is_ascii_digit()
|| *b == b'-'
|| (allow_underscore && *b == b'_')
})
}
#[cfg(test)]
mod tests {
use super::{AutomationId, AutomationTriggerId};
#[test]
fn automation_id_accepts_locked_format() {
assert!("a".parse::<AutomationId>().is_ok());
assert!("a0-b".parse::<AutomationId>().is_ok());
assert!("0".parse::<AutomationId>().is_ok());
}
#[test]
fn trigger_id_accepts_underscore_after_first_character() {
assert!("api_1".parse::<AutomationTriggerId>().is_ok());
assert!("a-b_c".parse::<AutomationTriggerId>().is_ok());
}
}

View file

@ -0,0 +1,14 @@
pub mod error;
pub mod id;
pub mod model;
mod store;
pub use error::{AutomationStoreError, AutomationValidationError};
pub use id::{AutomationId, AutomationTriggerId};
pub use model::{
ApiTrigger, Automation, AutomationDraft, AutomationPatch, AutomationReplace,
AutomationRevision, AutomationTarget, AutomationTrigger, GitRefSelector, RepositorySlug,
ScheduleTrigger, WorkflowSlug,
};
pub use store::AutomationStore;

View file

@ -0,0 +1,776 @@
use std::collections::HashSet;
use std::fmt;
use std::path::{Component, Path};
use std::str::FromStr;
use croner::parser::{CronParser, Seconds, Year};
use serde::de::Error as DeError;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sha2::{Digest, Sha256};
use toml::de::Error as TomlDeError;
use toml_edit::ser::{Error as TomlEditSerError, to_document};
use crate::error::AutomationValidationError;
use crate::id::{AutomationId, AutomationTriggerId};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct AutomationRevision(String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RepositorySlug(String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct GitRefSelector(String);
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorkflowSlug(String);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Automation {
pub id: AutomationId,
pub revision: AutomationRevision,
pub name: String,
pub description: Option<String>,
pub enabled: bool,
pub target: AutomationTarget,
pub triggers: Vec<AutomationTrigger>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationTarget {
pub repository: RepositorySlug,
#[serde(rename = "ref")]
pub ref_: GitRefSelector,
pub workflow: WorkflowSlug,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AutomationTrigger {
Api(ApiTrigger),
Schedule(ScheduleTrigger),
}
#[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 expression: 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, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
pub target: AutomationTarget,
pub triggers: Vec<AutomationTrigger>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AutomationReplace {
pub name: String,
#[serde(default)]
pub description: Option<String>,
pub enabled: bool,
pub target: AutomationTarget,
pub triggers: Vec<AutomationTrigger>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct AutomationPatch {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<Option<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<AutomationTarget>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub triggers: Option<Vec<AutomationTrigger>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct PersistedAutomation {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
pub target: AutomationTarget,
#[serde(default)]
pub triggers: Vec<AutomationTrigger>,
}
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 RepositorySlug {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn owner_repo(&self) -> (&str, &str) {
self.0
.split_once('/')
.expect("repository slugs are validated to contain one slash")
}
}
impl GitRefSelector {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl WorkflowSlug {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Automation {
pub fn from_toml_bytes(id: AutomationId, bytes: &[u8]) -> Result<Self, TomlDeError> {
let source = std::str::from_utf8(bytes).map_err(TomlDeError::custom)?;
let persisted = toml::from_str::<PersistedAutomation>(source)?;
// serde has already validated newtypes and trigger shapes. This call
// checks cross-field invariants.
persisted
.into_automation(id, AutomationRevision::from_bytes(bytes))
.map_err(TomlDeError::custom)
}
pub fn from_draft(
draft: AutomationDraft,
revision: AutomationRevision,
) -> Result<Self, AutomationValidationError> {
let automation = Self {
id: draft.id,
revision,
name: draft.name,
description: draft.description,
enabled: draft.enabled.unwrap_or(true),
target: draft.target,
triggers: draft.triggers,
};
automation.validate()?;
Ok(automation)
}
#[must_use]
pub fn into_replace(self) -> AutomationReplace {
AutomationReplace {
name: self.name,
description: self.description,
enabled: self.enabled,
target: self.target,
triggers: self.triggers,
}
}
pub fn to_toml_bytes(&self) -> Result<Vec<u8>, TomlEditSerError> {
let persisted = PersistedAutomation::from(self);
to_document(&persisted).map(|document| document.to_string().into_bytes())
}
pub fn validate(&self) -> Result<(), AutomationValidationError> {
validate_common(&self.name, &self.triggers)
}
#[must_use]
pub fn api_trigger(&self) -> Option<&ApiTrigger> {
self.triggers.iter().find_map(|trigger| match trigger {
AutomationTrigger::Api(trigger) => Some(trigger),
AutomationTrigger::Schedule(_) => None,
})
}
}
impl AutomationReplace {
pub(crate) fn into_automation(
self,
id: AutomationId,
revision: AutomationRevision,
) -> Result<Automation, AutomationValidationError> {
let automation = Automation {
id,
revision,
name: self.name,
description: self.description,
enabled: self.enabled,
target: self.target,
triggers: self.triggers,
};
automation.validate()?;
Ok(automation)
}
}
impl AutomationPatch {
pub(crate) fn apply_to(self, current: &Automation) -> AutomationReplace {
AutomationReplace {
name: self.name.unwrap_or_else(|| current.name.clone()),
description: self
.description
.unwrap_or_else(|| current.description.clone()),
enabled: self.enabled.unwrap_or(current.enabled),
target: self.target.unwrap_or_else(|| current.target.clone()),
triggers: self.triggers.unwrap_or_else(|| current.triggers.clone()),
}
}
}
impl PersistedAutomation {
pub(crate) fn into_automation(
self,
id: AutomationId,
revision: AutomationRevision,
) -> Result<Automation, AutomationValidationError> {
let automation = Automation {
id,
revision,
name: self.name,
description: self.description,
enabled: self.enabled,
target: self.target,
triggers: self.triggers,
};
automation.validate()?;
Ok(automation)
}
}
impl From<&Automation> for PersistedAutomation {
fn from(value: &Automation) -> Self {
Self {
name: value.name.clone(),
description: value.description.clone(),
enabled: value.enabled,
target: value.target.clone(),
triggers: value.triggers.clone(),
}
}
}
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,
}
}
#[must_use]
pub fn is_api(&self) -> bool {
matches!(self, Self::Api(_))
}
pub fn validate(&self) -> Result<(), AutomationValidationError> {
match self {
Self::Api(_) => Ok(()),
Self::Schedule(trigger) => validate_schedule_expression(&trigger.expression),
}
}
}
fn validate_common(
name: &str,
triggers: &[AutomationTrigger],
) -> Result<(), AutomationValidationError> {
if name.trim().is_empty() {
return Err(AutomationValidationError::EmptyName);
}
let mut ids = HashSet::new();
let mut api_count = 0_usize;
for trigger in triggers {
if !ids.insert(trigger.id().clone()) {
return Err(AutomationValidationError::DuplicateTriggerId(
trigger.id().to_string(),
));
}
if trigger.is_api() {
api_count += 1;
}
trigger.validate()?;
}
if api_count > 1 {
return Err(AutomationValidationError::MultipleApiTriggers);
}
Ok(())
}
fn validate_schedule_expression(expression: &str) -> Result<(), AutomationValidationError> {
if expression.trim().is_empty() || expression.split_whitespace().count() != 5 {
return Err(AutomationValidationError::InvalidScheduleExpression(
expression.to_string(),
));
}
CronParser::builder()
.seconds(Seconds::Disallowed)
.year(Year::Disallowed)
.build()
.parse(expression)
.map(|_| ())
.map_err(|_| AutomationValidationError::InvalidScheduleExpression(expression.to_string()))
}
impl TryFrom<String> for RepositorySlug {
type Error = AutomationValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
let Some((owner, repo)) = value.split_once('/') else {
return Err(AutomationValidationError::InvalidRepositorySlug(value));
};
if repo.contains('/')
|| !valid_github_slug_segment(owner, 39)
|| !valid_github_slug_segment(repo, 100)
{
return Err(AutomationValidationError::InvalidRepositorySlug(value));
}
Ok(Self(value))
}
}
impl TryFrom<String> for GitRefSelector {
type Error = AutomationValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if valid_git_ref_selector(&value) {
Ok(Self(value))
} else {
Err(AutomationValidationError::InvalidGitRefSelector(value))
}
}
}
impl TryFrom<String> for WorkflowSlug {
type Error = AutomationValidationError;
fn try_from(value: String) -> Result<Self, Self::Error> {
if valid_workflow_selector(&value) {
Ok(Self(value))
} else {
Err(AutomationValidationError::InvalidWorkflowSelector(value))
}
}
}
macro_rules! impl_string_newtype {
($type:ty) => {
impl AsRef<str> for $type {
fn as_ref(&self) -> &str {
self.as_str()
}
}
impl fmt::Display for $type {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl FromStr for $type {
type Err = AutomationValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Self::try_from(value.to_string())
}
}
impl Serialize for $type {
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 $type {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::try_from(value).map_err(D::Error::custom)
}
}
};
}
impl_string_newtype!(RepositorySlug);
impl_string_newtype!(GitRefSelector);
impl_string_newtype!(WorkflowSlug);
impl AsRef<str> for AutomationRevision {
fn as_ref(&self) -> &str {
self.as_str()
}
}
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 = AutomationValidationError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
Ok(Self(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>,
{
Ok(Self(String::deserialize(deserializer)?))
}
}
fn valid_github_slug_segment(value: &str, max_len: usize) -> bool {
!value.is_empty()
&& value.len() <= max_len
&& !matches!(value, "." | "..")
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
}
fn valid_git_ref_selector(value: &str) -> bool {
let value = value.trim();
!value.is_empty()
&& !value.starts_with('-')
&& !value.contains("..")
&& !value.contains("@{")
&& !has_lock_suffix(value)
&& !value.ends_with('/')
&& !value.starts_with('/')
&& !value.bytes().any(|b| {
b.is_ascii_control()
|| b.is_ascii_whitespace()
|| matches!(
b,
b'\\'
| b'^'
| b'~'
| b':'
| b'?'
| b'*'
| b'['
| b';'
| b'&'
| b'|'
| b'$'
| b'`'
| b'\''
| b'"'
| b'<'
| b'>'
)
})
}
fn has_lock_suffix(value: &str) -> bool {
value.rsplit('/').any(|component| {
component
.get(component.len().saturating_sub(".lock".len())..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case(".lock"))
})
}
fn valid_workflow_selector(value: &str) -> bool {
let value = value.trim();
if value.is_empty()
|| value == "."
|| value.contains('\\')
|| value.bytes().any(|b| b.is_ascii_control())
{
return false;
}
let path = Path::new(value);
!path.is_absolute()
&& path.components().all(|component| {
matches!(component, Component::Normal(_) | Component::CurDir)
&& !matches!(component, Component::ParentDir)
})
}
fn default_true() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::{
Automation, AutomationDraft, AutomationReplace, AutomationRevision, AutomationTrigger,
GitRefSelector, RepositorySlug, WorkflowSlug,
};
use crate::AutomationId;
fn valid_toml() -> &'static str {
r#"
name = "Nightly dependency update"
description = "Open a PR for dependency updates."
enabled = true
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "dependency-update"
[[triggers]]
id = "api"
type = "api"
enabled = false
[[triggers]]
id = "nightly"
type = "schedule"
enabled = true
expression = "0 3 * * *"
"#
}
fn valid_draft_toml(id: &str, triggers: &str) -> String {
format!(
r#"
id = "{id}"
name = "Nightly"
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "deps"
{triggers}
"#
)
}
#[test]
fn valid_toml_deserializes_and_computes_revision() {
let id = AutomationId::try_from("nightly-deps".to_string())
.expect("automation id should be valid");
let automation = Automation::from_toml_bytes(id, valid_toml().as_bytes())
.expect("automation TOML should parse");
assert_eq!(automation.name, "Nightly dependency update");
assert!(automation.enabled);
assert_eq!(automation.triggers.len(), 2);
assert_eq!(
automation.revision,
AutomationRevision::from_bytes(valid_toml().as_bytes())
);
}
#[test]
fn toml_defaults_enabled_and_description() {
let source = r#"
name = "Defaulted"
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "dependency-update"
[[triggers]]
id = "api"
type = "api"
"#;
let id =
AutomationId::try_from("defaulted".to_string()).expect("automation id should be valid");
let automation = Automation::from_toml_bytes(id, source.as_bytes())
.expect("automation TOML should parse");
assert!(automation.enabled);
assert_eq!(automation.description, None);
assert!(automation.triggers[0].enabled());
}
#[test]
fn invalid_automation_ids_are_rejected() {
for value in ["", "-bad", "Bad", "bad_", &"a".repeat(64)] {
assert!(AutomationId::try_from(value.to_string()).is_err());
}
}
#[test]
fn invalid_trigger_ids_are_rejected() {
let result: Result<AutomationDraft, _> = toml::from_str(&valid_draft_toml(
"nightly",
r#"
[[triggers]]
id = "_api"
type = "api"
"#,
));
assert!(result.is_err());
}
#[test]
fn duplicate_trigger_ids_are_rejected() {
let draft: AutomationDraft = toml::from_str(&valid_draft_toml(
"nightly",
r#"
[[triggers]]
id = "api"
type = "api"
[[triggers]]
id = "api"
type = "schedule"
expression = "0 3 * * *"
"#,
))
.expect("draft should deserialize");
assert!(Automation::from_draft(draft, AutomationRevision::from_bytes(b"")).is_err());
}
#[test]
fn two_api_triggers_are_rejected() {
let draft: AutomationDraft = toml::from_str(&valid_draft_toml(
"nightly",
r#"
[[triggers]]
id = "api"
type = "api"
[[triggers]]
id = "api2"
type = "api"
"#,
))
.expect("draft should deserialize");
assert!(Automation::from_draft(draft, AutomationRevision::from_bytes(b"")).is_err());
}
#[test]
fn invalid_repository_slug_is_rejected() {
for value in [
"fabro-sh",
"fabro-sh/fabro/extra",
"../fabro",
"owner/repo/name",
] {
assert!(RepositorySlug::try_from(value.to_string()).is_err());
}
}
#[test]
fn invalid_schedule_expression_is_rejected() {
let draft: AutomationDraft = toml::from_str(&valid_draft_toml(
"nightly",
r#"
[[triggers]]
id = "nightly"
type = "schedule"
expression = "* * * * * *"
"#,
))
.expect("draft should deserialize");
assert!(Automation::from_draft(draft, AutomationRevision::from_bytes(b"")).is_err());
}
#[test]
fn newtypes_have_toml_string_shape() {
let replace: AutomationReplace = toml::from_str(
r#"
name = "Nightly"
enabled = true
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "deps"
[[triggers]]
id = "api"
type = "api"
enabled = true
"#,
)
.expect("replace should deserialize");
let target_toml = toml::to_string(&replace.target).expect("target should serialize");
assert!(target_toml.contains("repository = \"fabro-sh/fabro\""));
assert!(target_toml.contains("ref = \"main\""));
assert!(target_toml.contains("workflow = \"deps\""));
}
#[test]
fn invalid_ref_and_workflow_selectors_are_rejected() {
assert!(GitRefSelector::try_from("-main".to_string()).is_err());
assert!(GitRefSelector::try_from("feature..main".to_string()).is_err());
assert!(WorkflowSlug::try_from("/tmp/workflow".to_string()).is_err());
assert!(WorkflowSlug::try_from("../workflow".to_string()).is_err());
}
#[test]
fn trigger_variant_type_is_api() {
let trigger: AutomationTrigger = toml::from_str(
r#"
id = "api"
type = "api"
enabled = true
"#,
)
.expect("api trigger should deserialize");
assert!(matches!(trigger, AutomationTrigger::Api(_)));
}
}

View file

@ -0,0 +1,490 @@
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio::fs::{self, OpenOptions};
use tokio::io::AsyncWriteExt as _;
use tokio::sync::RwLock;
use crate::error::{AutomationStoreError, AutomationValidationError};
use crate::id::AutomationId;
use crate::model::{
Automation, AutomationDraft, AutomationPatch, AutomationReplace, AutomationRevision,
};
#[derive(Debug)]
pub struct AutomationStore {
dir: PathBuf,
items: RwLock<BTreeMap<AutomationId, Automation>>,
}
impl AutomationStore {
pub async fn load(dir: impl Into<PathBuf>) -> Result<Self, AutomationStoreError> {
let dir = dir.into();
let mut items = BTreeMap::new();
match fs::read_dir(&dir).await {
Ok(mut entries) => {
while let Some(entry) = entries
.next_entry()
.await
.map_err(|err| AutomationStoreError::io(&dir, err))?
{
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
continue;
}
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
return Err(AutomationValidationError::InvalidAutomationId(
path.display().to_string(),
)
.into());
};
let id = AutomationId::try_from(stem.to_string())?;
let bytes = fs::read(&path)
.await
.map_err(|err| AutomationStoreError::io(&path, err))?;
let automation = Automation::from_toml_bytes(id.clone(), &bytes)
.map_err(|err| AutomationStoreError::parse(&path, err))?;
items.insert(id, automation);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(AutomationStoreError::io(&dir, err)),
}
Ok(Self {
dir,
items: RwLock::new(items),
})
}
#[expect(
clippy::disallowed_methods,
reason = "Server startup loads automations before a Tokio runtime may be available."
)]
pub fn load_blocking(dir: impl Into<PathBuf>) -> Result<Self, AutomationStoreError> {
let dir = dir.into();
let mut items = BTreeMap::new();
match std::fs::read_dir(&dir) {
Ok(entries) => {
for entry in entries {
let entry = entry.map_err(|err| AutomationStoreError::io(&dir, err))?;
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
continue;
}
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
return Err(AutomationValidationError::InvalidAutomationId(
path.display().to_string(),
)
.into());
};
let id = AutomationId::try_from(stem.to_string())?;
let bytes =
std::fs::read(&path).map_err(|err| AutomationStoreError::io(&path, err))?;
let automation = Automation::from_toml_bytes(id.clone(), &bytes)
.map_err(|err| AutomationStoreError::parse(&path, err))?;
items.insert(id, automation);
}
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(AutomationStoreError::io(&dir, err)),
}
Ok(Self {
dir,
items: RwLock::new(items),
})
}
pub async fn list(&self) -> Vec<Automation> {
self.items.read().await.values().cloned().collect()
}
pub async fn get(&self, id: &AutomationId) -> Option<Automation> {
self.items.read().await.get(id).cloned()
}
pub async fn create(&self, draft: AutomationDraft) -> Result<Automation, AutomationStoreError> {
let id = draft.id.clone();
let mut items = self.items.write().await;
if items.contains_key(&id) {
return Err(AutomationStoreError::AlreadyExists(id));
}
let automation = Automation::from_draft(draft, AutomationRevision::from_bytes(b""))?;
let automation = self.persist_with_revision(automation).await?;
items.insert(id, automation.clone());
Ok(automation)
}
pub async fn replace(
&self,
id: &AutomationId,
expected: &AutomationRevision,
draft: AutomationReplace,
) -> Result<Automation, AutomationStoreError> {
let mut items = self.items.write().await;
let current = items
.get(id)
.ok_or_else(|| AutomationStoreError::NotFound(id.clone()))?;
ensure_revision(current, expected)?;
let automation = draft.into_automation(id.clone(), AutomationRevision::from_bytes(b""))?;
let automation = self.persist_with_revision(automation).await?;
items.insert(id.clone(), automation.clone());
Ok(automation)
}
pub async fn patch(
&self,
id: &AutomationId,
expected: &AutomationRevision,
patch: AutomationPatch,
) -> Result<Automation, AutomationStoreError> {
let mut items = self.items.write().await;
let current = items
.get(id)
.ok_or_else(|| AutomationStoreError::NotFound(id.clone()))?;
ensure_revision(current, expected)?;
let draft = patch.apply_to(current);
let automation = draft.into_automation(id.clone(), AutomationRevision::from_bytes(b""))?;
let automation = self.persist_with_revision(automation).await?;
items.insert(id.clone(), automation.clone());
Ok(automation)
}
pub async fn delete(
&self,
id: &AutomationId,
expected: &AutomationRevision,
) -> Result<(), AutomationStoreError> {
let mut items = self.items.write().await;
let current = items
.get(id)
.ok_or_else(|| AutomationStoreError::NotFound(id.clone()))?;
ensure_revision(current, expected)?;
let path = self.path_for(id);
match fs::remove_file(&path).await {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(AutomationStoreError::io(&path, err)),
}
items.remove(id);
Ok(())
}
async fn persist_with_revision(
&self,
automation: Automation,
) -> Result<Automation, AutomationStoreError> {
let bytes = automation
.to_toml_bytes()
.map_err(|err| AutomationValidationError::InvalidWorkflowSelector(err.to_string()))?;
atomic_write(&self.dir, &self.path_for(&automation.id), &bytes).await?;
let revision = AutomationRevision::from_bytes(&bytes);
Ok(Automation {
revision,
..automation
})
}
fn path_for(&self, id: &AutomationId) -> PathBuf {
self.dir.join(format!("{id}.toml"))
}
}
fn ensure_revision(
current: &Automation,
expected: &AutomationRevision,
) -> Result<(), AutomationStoreError> {
if &current.revision == expected {
Ok(())
} else {
Err(AutomationStoreError::RevisionMismatch {
expected: expected.clone(),
actual: current.revision.clone(),
})
}
}
async fn atomic_write(
dir: &Path,
final_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(dir, final_path);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp_path)
.await
.map_err(|err| AutomationStoreError::io(&temp_path, err))?;
let write_result = async {
file.write_all(bytes).await?;
file.flush().await?;
file.sync_all().await
}
.await;
if let Err(err) = write_result {
let _ = fs::remove_file(&temp_path).await;
return Err(AutomationStoreError::io(&temp_path, err));
}
drop(file);
if let Err(err) = fs::rename(&temp_path, final_path).await {
let _ = fs::remove_file(&temp_path).await;
return Err(AutomationStoreError::io(final_path, err));
}
Ok(())
}
fn temp_path_for(dir: &Path, final_path: &Path) -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let stem = final_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());
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
dir.join(format!(".{stem}.{now}.{counter}.tmp"))
}
#[cfg(test)]
mod tests {
use tokio::fs;
use super::AutomationStore;
use crate::{
AutomationDraft, AutomationId, AutomationPatch, AutomationReplace, AutomationRevision,
};
fn draft(id: &str) -> AutomationDraft {
toml::from_str(&format!(
r#"
id = "{id}"
name = "Nightly"
description = "Runs nightly"
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "deps"
[[triggers]]
id = "api"
type = "api"
"#
))
.expect("draft should deserialize")
}
fn replacement(name: &str) -> AutomationReplace {
toml::from_str(&format!(
r#"
name = "{name}"
enabled = true
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "deps"
[[triggers]]
id = "api"
type = "api"
"#
))
.expect("replacement should deserialize")
}
#[tokio::test]
async fn missing_directory_loads_empty_store() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let store = AutomationStore::load(dir.path().join("automations"))
.await
.expect("store should load");
assert!(store.list().await.is_empty());
}
#[tokio::test]
async fn create_writes_file() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let automation_dir = dir.path().join("automations");
let store = AutomationStore::load(&automation_dir)
.await
.expect("store should load");
let automation = store
.create(draft("nightly"))
.await
.expect("automation should be created");
let path = automation_dir.join("nightly.toml");
let bytes = fs::read(&path).await.expect("file should exist");
assert_eq!(automation.revision, AutomationRevision::from_bytes(&bytes));
assert!(String::from_utf8_lossy(&bytes).contains("name = \"Nightly\""));
}
#[tokio::test]
async fn replace_changes_revision() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let store = AutomationStore::load(dir.path())
.await
.expect("store should load");
let first = store
.create(draft("nightly"))
.await
.expect("automation should be created");
let second = store
.replace(&first.id, &first.revision, replacement("Updated"))
.await
.expect("automation should be replaced");
assert_ne!(first.revision, second.revision);
assert_eq!(second.name, "Updated");
}
#[tokio::test]
async fn patch_keeps_unchanged_fields() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let store = AutomationStore::load(dir.path())
.await
.expect("store should load");
let first = store
.create(draft("nightly"))
.await
.expect("automation should be created");
let patch = AutomationPatch {
name: Some("Patched".to_string()),
..AutomationPatch::default()
};
let patched = store
.patch(&first.id, &first.revision, patch)
.await
.expect("automation should be patched");
assert_eq!(patched.name, "Patched");
assert_eq!(patched.description.as_deref(), Some("Runs nightly"));
assert_eq!(patched.target, first.target);
assert_eq!(patched.triggers, first.triggers);
}
#[tokio::test]
async fn stale_revision_fails() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let store = AutomationStore::load(dir.path())
.await
.expect("store should load");
let first = store
.create(draft("nightly"))
.await
.expect("automation should be created");
let result = store
.replace(
&first.id,
&AutomationRevision::from_bytes(b"stale"),
replacement("Updated"),
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn delete_removes_file() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let store = AutomationStore::load(dir.path())
.await
.expect("store should load");
let automation = store
.create(draft("nightly"))
.await
.expect("automation should be created");
let path = dir.path().join("nightly.toml");
store
.delete(&automation.id, &automation.revision)
.await
.expect("automation should be deleted");
assert!(!path.exists());
assert!(store.get(&automation.id).await.is_none());
}
#[tokio::test]
async fn startup_fails_on_malformed_toml() {
let dir = tempfile::tempdir().expect("tempdir should be created");
fs::write(dir.path().join("nightly.toml"), "not = [toml")
.await
.expect("malformed file should be writable");
let result = AutomationStore::load(dir.path()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn invalid_filename_fails_load() {
let dir = tempfile::tempdir().expect("tempdir should be created");
fs::write(
dir.path().join("Bad.toml"),
r#"
name = "Bad"
[target]
repository = "fabro-sh/fabro"
ref = "main"
workflow = "deps"
"#,
)
.await
.expect("file should be writable");
let result = AutomationStore::load(dir.path()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn non_toml_files_are_ignored() {
let dir = tempfile::tempdir().expect("tempdir should be created");
fs::write(dir.path().join("README.md"), "ignored")
.await
.expect("file should be writable");
let store = AutomationStore::load(dir.path())
.await
.expect("store should load");
assert!(store.list().await.is_empty());
}
#[tokio::test]
async fn get_returns_created_automation_by_id() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let store = AutomationStore::load(dir.path())
.await
.expect("store should load");
let created = store
.create(draft("nightly"))
.await
.expect("automation should be created");
let id = AutomationId::try_from("nightly".to_string()).expect("id should be valid");
assert_eq!(store.get(&id).await, Some(created));
}
}

View file

@ -844,6 +844,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
git: None,
automation: None,
fork_source_ref: None,
};
serde_json::json!({

View file

@ -52,6 +52,7 @@ pub(crate) fn run_projection_json(run_id: &str, status: &serde_json::Value) -> s
manifest_blob: None,
definition_blob: None,
git: None,
automation: None,
fork_source_ref: None,
};

View file

@ -500,6 +500,7 @@ mod tests {
provenance: None,
manifest_blob: None,
definition_blob: None,
automation: None,
fork_source_ref: None,
}
}

View file

@ -34,6 +34,7 @@ fabro-validate = { path = "../fabro-validate" }
fabro-sandbox = { path = "../fabro-sandbox", features = ["daytona", "docker"] }
fabro-github = { path = "../fabro-github" }
fabro-agent = { path = "../fabro-agent" }
fabro-automation = { path = "../fabro-automation" }
fabro-llm = { path = "../fabro-llm" }
fabro-manifest = { path = "../fabro-manifest" }
fabro-model = { path = "../fabro-model" }
@ -67,6 +68,7 @@ serde.workspace = true
serde_json.workspace = true
serde_yaml = "0.9"
anyhow.workspace = true
async-trait.workspace = true
clap.workspace = true
toml.workspace = true
toml_edit.workspace = true

View file

@ -0,0 +1,384 @@
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::time::Duration;
use async_trait::async_trait;
use fabro_api::types::RunManifest;
use fabro_automation::{AutomationId, AutomationTarget};
use fabro_config::Storage;
use fabro_types::RunId;
use tokio::process::Command;
use tokio::time::timeout;
use tokio::{fs, task};
pub(crate) struct AutomationRunMaterializeInput {
pub automation_id: AutomationId,
pub target: AutomationTarget,
pub run_id: RunId,
pub user_settings_path: PathBuf,
pub temp_root: PathBuf,
}
#[derive(Clone)]
pub(crate) struct AutomationRunMaterialized {
pub manifest: RunManifest,
pub submitted_manifest_bytes: Vec<u8>,
}
#[derive(thiserror::Error, Debug, Clone)]
pub(crate) enum AutomationRunMaterializeError {
#[error("invalid automation target: {0}")]
InvalidTarget(String),
#[error("failed to clone automation repository: {0}")]
CloneFailed(String),
#[error("failed to resolve automation workflow: {0}")]
WorkflowNotFound(String),
#[error("failed to build run manifest: {0}")]
Manifest(String),
}
#[async_trait]
pub(crate) trait AutomationRunMaterializer: Send + Sync {
async fn materialize(
&self,
input: AutomationRunMaterializeInput,
) -> Result<AutomationRunMaterialized, AutomationRunMaterializeError>;
}
pub(crate) struct GitAutomationRunMaterializer {
github_credentials: Option<fabro_github::GitHubCredentials>,
github_api_base_url: String,
http_client: Option<fabro_http::HttpClient>,
git_timeout: Duration,
}
impl GitAutomationRunMaterializer {
pub(crate) fn new(
github_credentials: Option<fabro_github::GitHubCredentials>,
github_api_base_url: String,
http_client: Option<fabro_http::HttpClient>,
) -> Self {
Self {
github_credentials,
github_api_base_url,
http_client,
git_timeout: Duration::from_mins(2),
}
}
}
#[async_trait]
impl AutomationRunMaterializer for GitAutomationRunMaterializer {
async fn materialize(
&self,
input: AutomationRunMaterializeInput,
) -> Result<AutomationRunMaterialized, AutomationRunMaterializeError> {
let (owner, repo) = input.target.repository.owner_repo();
if owner.is_empty() || repo.is_empty() {
return Err(AutomationRunMaterializeError::InvalidTarget(
input.target.repository.to_string(),
));
}
let sanitized_clone_url = github_clone_url(owner, repo);
let clone_url = self
.authenticated_clone_url(owner, repo, &sanitized_clone_url)
.await?;
fs::create_dir_all(&input.temp_root).await.map_err(|err| {
AutomationRunMaterializeError::CloneFailed(format!(
"failed to create temp root {}: {err}",
input.temp_root.display()
))
})?;
let checkout_dir = input.temp_root.join(input.run_id.to_string());
run_git(
git_clone_args(&clone_url, &checkout_dir),
self.git_timeout,
"git clone",
)
.await?;
run_git(
git_remote_set_url_args(&checkout_dir, &sanitized_clone_url),
self.git_timeout,
"git remote set-url origin",
)
.await?;
run_git(
git_checkout_args(&checkout_dir, input.target.ref_.as_str()),
self.git_timeout,
"git checkout",
)
.await?;
build_manifest_from_checkout(input, checkout_dir).await
}
}
impl GitAutomationRunMaterializer {
async fn authenticated_clone_url(
&self,
owner: &str,
repo: &str,
sanitized_clone_url: &str,
) -> Result<String, AutomationRunMaterializeError> {
let Some(credentials) = self.github_credentials.as_ref() else {
return Ok(sanitized_clone_url.to_string());
};
let ctx = match self.http_client.clone() {
Some(client) => fabro_github::GitHubContext::with_http_client(
credentials,
&self.github_api_base_url,
client,
),
None => fabro_github::GitHubContext::new(credentials, &self.github_api_base_url),
};
let (_username, token) = fabro_github::resolve_clone_credentials(&ctx, owner, repo)
.await
.map_err(|err| AutomationRunMaterializeError::CloneFailed(err.to_string()))?;
match token {
Some(token) => fabro_github::embed_token_in_url(sanitized_clone_url, &token)
.map(|url| url.raw_string())
.map_err(|err| AutomationRunMaterializeError::CloneFailed(err.to_string())),
None => Ok(sanitized_clone_url.to_string()),
}
}
}
pub(crate) fn automation_temp_root(storage_root: impl Into<PathBuf>) -> PathBuf {
Storage::new(storage_root).scratch_dir().join("automations")
}
fn github_clone_url(owner: &str, repo: &str) -> String {
format!("https://github.com/{owner}/{repo}.git")
}
fn git_clone_args(clone_url: &str, checkout_path: &Path) -> Vec<OsString> {
vec![
"clone".into(),
"--no-tags".into(),
"--".into(),
clone_url.into(),
checkout_path.as_os_str().to_owned(),
]
}
fn git_remote_set_url_args(repo_dir: &Path, sanitized_clone_url: &str) -> Vec<OsString> {
vec![
"-C".into(),
repo_dir.as_os_str().to_owned(),
"remote".into(),
"set-url".into(),
"origin".into(),
sanitized_clone_url.into(),
]
}
fn git_checkout_args(repo_dir: &Path, ref_: &str) -> Vec<OsString> {
vec![
"-C".into(),
repo_dir.as_os_str().to_owned(),
"checkout".into(),
"--force".into(),
ref_.into(),
]
}
async fn run_git(
args: Vec<OsString>,
git_timeout: Duration,
label: &'static str,
) -> Result<(), AutomationRunMaterializeError> {
let mut command = Command::new("git");
command.args(&args);
command.env("GIT_TERMINAL_PROMPT", "0");
command.kill_on_drop(true);
let output = timeout(git_timeout, command.output())
.await
.map_err(|_| {
AutomationRunMaterializeError::CloneFailed(format!(
"{label} timed out after {}s",
git_timeout.as_secs()
))
})?
.map_err(|err| AutomationRunMaterializeError::CloneFailed(format!("{label}: {err}")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let detail = if stderr.is_empty() { stdout } else { stderr };
Err(AutomationRunMaterializeError::CloneFailed(format!(
"{label} exited with status {}: {}",
output.status,
redact_command_output(&detail)
)))
}
fn redact_command_output(value: &str) -> String {
value
.split_whitespace()
.map(redact_url_token)
.collect::<Vec<_>>()
.join(" ")
}
fn redact_url_token(value: &str) -> String {
fabro_redact::DisplaySafeUrl::parse(value)
.map_or_else(|_| value.to_string(), |url| url.redacted_string())
}
async fn build_manifest_from_checkout(
input: AutomationRunMaterializeInput,
checkout_dir: PathBuf,
) -> Result<AutomationRunMaterialized, AutomationRunMaterializeError> {
let workflow = PathBuf::from(input.target.workflow.as_str());
let user_settings_path = input.user_settings_path;
let run_id = input.run_id;
let automation_id = input.automation_id.to_string();
let built = task::spawn_blocking(move || {
fabro_manifest::build_run_manifest(fabro_manifest::ManifestBuildInput {
workflow,
cwd: checkout_dir,
run_id: Some(run_id),
user_settings_path: Some(user_settings_path),
..fabro_manifest::ManifestBuildInput::default()
})
})
.await
.map_err(|err| AutomationRunMaterializeError::Manifest(err.to_string()))?
.map_err(|err| classify_manifest_error(&automation_id, &err))?;
let submitted_manifest_bytes = serde_json::to_vec(&built.manifest)
.map_err(|err| AutomationRunMaterializeError::Manifest(err.to_string()))?;
Ok(AutomationRunMaterialized {
manifest: built.manifest,
submitted_manifest_bytes,
})
}
fn classify_manifest_error(
automation_id: &str,
err: &anyhow::Error,
) -> AutomationRunMaterializeError {
let message = err.to_string();
if err
.chain()
.any(|cause| cause.to_string().contains("workflow") && cause.to_string().contains("not"))
{
AutomationRunMaterializeError::WorkflowNotFound(format!("{automation_id}: {message}"))
} else {
AutomationRunMaterializeError::Manifest(message)
}
}
#[cfg(any(test, feature = "test-support"))]
pub(crate) struct StaticAutomationRunMaterializer {
result: Result<AutomationRunMaterialized, AutomationRunMaterializeError>,
}
#[cfg(any(test, feature = "test-support"))]
impl StaticAutomationRunMaterializer {
pub(crate) fn ok(
manifest: RunManifest,
submitted_manifest_bytes: Vec<u8>,
) -> std::sync::Arc<Self> {
std::sync::Arc::new(Self {
result: Ok(AutomationRunMaterialized {
manifest,
submitted_manifest_bytes,
}),
})
}
}
#[cfg(any(test, feature = "test-support"))]
#[async_trait]
impl AutomationRunMaterializer for StaticAutomationRunMaterializer {
async fn materialize(
&self,
_input: AutomationRunMaterializeInput,
) -> Result<AutomationRunMaterialized, AutomationRunMaterializeError> {
self.result.clone()
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr as _;
use fabro_automation::{AutomationId, GitRefSelector, RepositorySlug, WorkflowSlug};
use super::*;
#[test]
fn github_clone_url_uses_sanitized_https_origin() {
assert_eq!(
github_clone_url("fabro-sh", "fabro"),
"https://github.com/fabro-sh/fabro.git"
);
}
#[test]
fn redact_command_output_strips_credentials() {
let redacted = redact_command_output(
"fatal: https://x-access-token:ghs_secret@github.com/acme/widgets.git failed",
);
assert!(redacted.contains("https://x-access-token:***@github.com/acme/widgets.git"));
assert!(!redacted.contains("ghs_secret"));
}
#[test]
fn checkout_args_pass_ref_as_argv() {
let args = git_checkout_args(Path::new("/tmp/repo"), "feature/main");
assert_eq!(args[0], OsString::from("-C"));
assert_eq!(args[2], OsString::from("checkout"));
assert_eq!(args[4], OsString::from("feature/main"));
}
#[tokio::test]
async fn build_manifest_from_checkout_resolves_workflow_path() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let workflow_dir = dir.path().join("flows");
fs::create_dir_all(&workflow_dir)
.await
.expect("workflow dir should be created");
fs::write(
workflow_dir.join("deps.fabro"),
r#"digraph Test {
graph [goal="Test"]
start [shape=Mdiamond]
exit [shape=Msquare]
start -> exit
}"#,
)
.await
.expect("workflow should be written");
let target = AutomationTarget {
repository: RepositorySlug::from_str("fabro-sh/fabro").unwrap(),
ref_: GitRefSelector::from_str("main").unwrap(),
workflow: WorkflowSlug::from_str("flows/deps").unwrap(),
};
let run_id = RunId::new();
let input = AutomationRunMaterializeInput {
automation_id: AutomationId::from_str("nightly").unwrap(),
target,
run_id,
user_settings_path: dir.path().join("settings.toml"),
temp_root: dir.path().join("tmp"),
};
let materialized = build_manifest_from_checkout(input, dir.path().to_path_buf())
.await
.expect("manifest should build");
assert_eq!(
materialized.manifest.run_id.as_deref(),
Some(run_id.to_string().as_str())
);
assert_eq!(materialized.manifest.target.path, "flows/deps.fabro");
assert!(
std::str::from_utf8(&materialized.submitted_manifest_bytes)
.unwrap()
.contains("flows/deps.fabro")
);
}
}

View file

@ -9,6 +9,7 @@
)]
pub mod auth;
mod automation_materializer;
mod canonical_host;
mod canonical_origin;
pub mod csp;

View file

@ -2379,6 +2379,7 @@ index 1111111..2222222 160000
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
},
chrono::Utc::now(),
);

View file

@ -215,6 +215,7 @@ pub(crate) fn create_run_input(
title: prepared.title,
git: prepared.git,
fork_source_ref: None,
automation: None,
parent_id: prepared.parent_id,
provenance: None,
configured_providers,

View file

@ -805,6 +805,7 @@ where
github_api_base_url: None,
active_config_path,
http_client: None,
automation_materializer: None,
shutdown: shutdown.clone(),
})?;
let reconciled = reconcile_incomplete_runs_on_startup(&state).await?;

View file

@ -1,5 +1,5 @@
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::path::{Path as StdPath, PathBuf};
use std::process::Stdio;
use std::str::FromStr;
use std::sync::atomic::{AtomicBool, Ordering};
@ -45,6 +45,7 @@ pub use fabro_api::types::{
SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse,
};
use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message};
use fabro_automation::AutomationStore;
#[cfg(test)]
use fabro_config::RunSettingsBuilder;
use fabro_config::daemon::ServerDaemon;
@ -130,6 +131,7 @@ use tracing::{Instrument, debug, error, info, warn};
use ulid::Ulid;
use crate::auth::{self, GithubEndpoints, auth_translation_middleware, demo_routing_middleware};
use crate::automation_materializer::{AutomationRunMaterializer, GitAutomationRunMaterializer};
use crate::canonical_origin::resolve_canonical_origin;
use crate::error::ApiError;
use crate::github_webhooks::{
@ -933,6 +935,8 @@ pub struct AppState {
runs: Mutex<HashMap<RunId, ManagedRun>>,
aggregate_billing: Mutex<BillingAccumulator>,
store: Arc<Database>,
automation_store: Arc<AutomationStore>,
automation_materializer: Arc<dyn AutomationRunMaterializer>,
session_runtimes: SessionRuntimeManager,
artifact_store: ArtifactStore,
worker_tokens: WorkerTokenKeys,
@ -1059,6 +1063,7 @@ pub(crate) struct AppStateConfig {
pub(crate) github_api_base_url: Option<String>,
pub(crate) active_config_path: PathBuf,
pub(crate) http_client: Option<fabro_http::HttpClient>,
pub(crate) automation_materializer: Option<Arc<dyn AutomationRunMaterializer>>,
pub(crate) shutdown: CancellationToken,
}
@ -1263,6 +1268,14 @@ impl AppState {
&self.store
}
pub(crate) fn automation_store(&self) -> Arc<AutomationStore> {
Arc::clone(&self.automation_store)
}
pub(crate) fn automation_materializer(&self) -> Arc<dyn AutomationRunMaterializer> {
Arc::clone(&self.automation_materializer)
}
pub(crate) fn session_runtimes(&self) -> &SessionRuntimeManager {
&self.session_runtimes
}
@ -1408,6 +1421,53 @@ impl AppState {
}
}
fn resolve_github_credentials_for_startup(
settings: &GithubIntegrationSettings,
server_secrets: &ServerSecrets,
vault: &Vault,
) -> Result<Option<fabro_github::GitHubCredentials>, String> {
match settings.strategy {
GithubIntegrationStrategy::App => {
let Some(app_id) = settings.app_id.as_ref().map(InterpString::as_source) else {
return Ok(None);
};
let raw = server_secrets.get(EnvVars::GITHUB_APP_PRIVATE_KEY);
let Some(raw) = raw else {
return Ok(None);
};
let private_key_pem = decode_secret_pem(EnvVars::GITHUB_APP_PRIVATE_KEY, &raw)?;
Ok(Some(fabro_github::GitHubCredentials::App(
fabro_github::GitHubAppCredentials {
app_id,
private_key_pem,
slug: settings.slug.as_ref().map(InterpString::as_source),
},
)))
}
GithubIntegrationStrategy::Token => {
let token = process_env_var(EnvVars::GITHUB_TOKEN)
.or_else(|| process_env_var(EnvVars::GH_TOKEN))
.or_else(|| vault.get(EnvVars::GITHUB_TOKEN).map(str::to_string))
.or_else(|| vault.get(EnvVars::GH_TOKEN).map(str::to_string))
.as_deref()
.map(str::trim)
.filter(|token| !token.is_empty())
.map(str::to_string);
match token {
Some(token) => {
fabro_github::validate_static_github_token(&token)
.map_err(|err| err.to_string())?;
Ok(Some(fabro_github::GitHubCredentials::Pat(token)))
}
None => Err(
"GITHUB_TOKEN not configured — run fabro install or set GITHUB_TOKEN"
.to_string(),
),
}
}
}
}
async fn resolve_llm_client_from_source(
source: &dyn CredentialSource,
catalog: Arc<Catalog>,
@ -2071,6 +2131,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
github_api_base_url,
active_config_path,
http_client,
automation_materializer,
shutdown,
} = config;
@ -2097,9 +2158,26 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
);
}
}
let vault = Vault::load(vault_path.clone())
let current_server_settings = Arc::new(resolved_settings.server_settings);
let loaded_vault = Vault::load(vault_path.clone())
.with_context(|| format!("load vault {}", vault_path.display()))?;
let vault = Arc::new(AsyncRwLock::new(vault));
let startup_github_credentials = if automation_materializer.is_none() {
resolve_github_credentials_for_startup(
&current_server_settings.server.integrations.github,
&server_secrets,
&loaded_vault,
)
.unwrap_or_else(|err| {
warn!(
error = %err,
"GitHub credentials unavailable for automation materializer; public clones may still work"
);
None
})
} else {
None
};
let vault = Arc::new(AsyncRwLock::new(loaded_vault));
let llm_source: Arc<dyn CredentialSource> = Arc::new(VaultCredentialSource::with_env_lookup(
Arc::clone(&vault),
{
@ -2108,7 +2186,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
},
));
let (global_event_tx, _) = broadcast::channel(4096);
let current_server_settings = Arc::new(resolved_settings.server_settings);
let current_manifest_run_defaults = Arc::new(resolved_settings.manifest_run_defaults);
let current_manifest_environment_defaults =
Arc::new(resolved_settings.manifest_environment_defaults);
@ -2117,6 +2194,14 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
Catalog::from_builtin_with_overrides(&resolved_settings.llm_catalog_settings)
.context("building LLM model catalog")?,
);
let automation_dir = active_config_path
.parent()
.unwrap_or_else(|| StdPath::new("."))
.join("automations");
let automation_store = Arc::new(
AutomationStore::load_blocking(&automation_dir)
.with_context(|| format!("load automation store {}", automation_dir.display()))?,
);
let slack_service = {
let default_channel = current_server_settings
.server
@ -2154,10 +2239,19 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result<Arc<AppS
};
let worker_tokens = worker_token_keys_from_server_secrets(&server_secrets)?;
let github_api_base_url = github_api_base_url.unwrap_or_else(fabro_github::github_api_base_url);
let automation_materializer = automation_materializer.unwrap_or_else(|| {
Arc::new(GitAutomationRunMaterializer::new(
startup_github_credentials,
github_api_base_url.clone(),
http_client.clone(),
))
});
Ok(Arc::new(AppState {
runs: Mutex::new(HashMap::new()),
aggregate_billing: Mutex::new(BillingAccumulator::default()),
store,
automation_store,
automation_materializer,
session_runtimes: SessionRuntimeManager::new(),
artifact_store,
worker_tokens,

View file

@ -0,0 +1,595 @@
use std::collections::BTreeMap;
use std::str::FromStr as _;
use std::sync::Arc;
use axum::body::Bytes;
use axum::extract::{Path, State};
use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use axum_extra::extract::Query as ExtraQuery;
use chrono::Utc;
use fabro_automation::{
ApiTrigger, Automation, AutomationDraft, AutomationId, AutomationPatch, AutomationReplace,
AutomationRevision, AutomationStoreError, AutomationTarget, AutomationTrigger,
AutomationTriggerId, AutomationValidationError, GitRefSelector, RepositorySlug,
ScheduleTrigger, WorkflowSlug,
};
use fabro_types::{AutomationRef, RunId};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use super::super::{AppState, PaginationParams, paginate_items, resolve_interp_string};
use super::runs::{CreateRunFromManifestRequest, create_run_from_manifest};
use crate::automation_materializer::{
AutomationRunMaterializeError, AutomationRunMaterializeInput, automation_temp_root,
};
use crate::error::ApiError;
use crate::principal_middleware::{RequiredRunManagementActor, RequiredUser};
pub(super) fn routes() -> Router<Arc<AppState>> {
Router::new()
.route(
"/automations",
get(list_automations).post(create_automation),
)
.route(
"/automations/{id}",
get(get_automation)
.put(replace_automation)
.patch(patch_automation)
.delete(delete_automation),
)
.route(
"/automations/{id}/runs",
get(list_automation_runs).post(create_automation_run),
)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawAutomationTarget {
repository: String,
#[serde(rename = "ref")]
ref_: String,
workflow: String,
}
#[derive(Debug, Deserialize)]
struct RawAutomationTrigger {
id: String,
#[serde(rename = "type")]
type_: String,
#[serde(default = "default_true")]
enabled: bool,
#[serde(default)]
expression: Option<String>,
#[serde(flatten)]
extra: BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCreateAutomationRequest {
id: String,
name: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
enabled: Option<bool>,
target: RawAutomationTarget,
triggers: Vec<RawAutomationTrigger>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawReplaceAutomationRequest {
name: String,
#[serde(default)]
description: Option<String>,
enabled: bool,
target: RawAutomationTarget,
triggers: Vec<RawAutomationTrigger>,
}
#[derive(Debug, Deserialize, Default)]
#[serde(deny_unknown_fields)]
struct RawPatchAutomationRequest {
#[serde(default)]
name: Option<String>,
#[serde(default, deserialize_with = "deserialize_nullable_string_patch")]
description: NullableStringPatch,
#[serde(default)]
enabled: Option<bool>,
#[serde(default)]
target: Option<RawAutomationTarget>,
#[serde(default)]
triggers: Option<Vec<RawAutomationTrigger>>,
}
#[derive(Debug, Default)]
enum NullableStringPatch {
#[default]
Omitted,
Explicit(Option<String>),
}
impl NullableStringPatch {
fn apply_to(self, patch: &mut AutomationPatch) {
match self {
Self::Omitted => {}
Self::Explicit(value) => patch.description = Some(value),
}
}
}
fn deserialize_nullable_string_patch<'de, D>(
deserializer: D,
) -> Result<NullableStringPatch, D::Error>
where
D: serde::Deserializer<'de>,
{
Option::<String>::deserialize(deserializer).map(NullableStringPatch::Explicit)
}
#[derive(serde::Serialize)]
struct AutomationListResponse {
data: Vec<Automation>,
meta: AutomationListMeta,
}
#[derive(serde::Serialize)]
struct AutomationListMeta {
total: u64,
}
fn default_true() -> bool {
true
}
async fn list_automations(_auth: RequiredUser, State(state): State<Arc<AppState>>) -> Response {
let mut automations = state.automation_store().list().await;
automations.sort_by(|left, right| left.id.cmp(&right.id));
let total = automations.len() as u64;
(
StatusCode::OK,
Json(AutomationListResponse {
data: automations,
meta: AutomationListMeta { total },
}),
)
.into_response()
}
async fn create_automation(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
body: Bytes,
) -> Response {
let request = match parse_json::<RawCreateAutomationRequest>(&body) {
Ok(request) => request,
Err(err) => return err.into_response(),
};
let draft = match request.try_into() {
Ok(draft) => draft,
Err(err) => return validation_error(&err).into_response(),
};
match state.automation_store().create(draft).await {
Ok(automation) => (StatusCode::CREATED, Json(automation)).into_response(),
Err(err) => store_error(err).into_response(),
}
}
async fn get_automation(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
) -> Response {
let id = match parse_automation_id(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
match state.automation_store().get(&id).await {
Some(automation) => with_etag(StatusCode::OK, automation),
None => ApiError::not_found("Automation not found.").into_response(),
}
}
async fn replace_automation(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let id = match parse_automation_id(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
let expected = match parse_if_match(&headers) {
Ok(revision) => revision,
Err(err) => return err.into_response(),
};
let request = match parse_json::<RawReplaceAutomationRequest>(&body) {
Ok(request) => request,
Err(err) => return err.into_response(),
};
let draft = match request.try_into() {
Ok(draft) => draft,
Err(err) => return validation_error(&err).into_response(),
};
match state
.automation_store()
.replace(&id, &expected, draft)
.await
{
Ok(automation) => with_etag(StatusCode::OK, automation),
Err(err) => store_error(err).into_response(),
}
}
async fn patch_automation(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let id = match parse_automation_id(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
let expected = match parse_if_match(&headers) {
Ok(revision) => revision,
Err(err) => return err.into_response(),
};
let request = match parse_json::<RawPatchAutomationRequest>(&body) {
Ok(request) => request,
Err(err) => return err.into_response(),
};
let patch = match request.try_into() {
Ok(patch) => patch,
Err(err) => return validation_error(&err).into_response(),
};
match state.automation_store().patch(&id, &expected, patch).await {
Ok(automation) => with_etag(StatusCode::OK, automation),
Err(err) => store_error(err).into_response(),
}
}
async fn delete_automation(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
let id = match parse_automation_id(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
let expected = match parse_if_match(&headers) {
Ok(revision) => revision,
Err(err) => return err.into_response(),
};
match state.automation_store().delete(&id, &expected).await {
Ok(()) => StatusCode::NO_CONTENT.into_response(),
Err(err) => store_error(err).into_response(),
}
}
async fn list_automation_runs(
_auth: RequiredUser,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
ExtraQuery(pagination): ExtraQuery<PaginationParams>,
) -> Response {
let id = match parse_automation_id(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
if state.automation_store().get(&id).await.is_none() {
return ApiError::not_found("Automation not found.").into_response();
}
let entries = match state
.store_ref()
.list_cached_runs(&fabro_store::ListRunsQuery::default(), Utc::now())
.await
{
Ok(entries) => entries,
Err(err) => {
return ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
.into_response();
}
};
let mut runs = entries
.into_iter()
.map(|entry| entry.summary)
.filter(|run| {
run.automation
.as_ref()
.is_some_and(|automation| automation.id == id.as_str())
})
.collect::<Vec<_>>();
runs.sort_by(|left, right| {
right
.timestamps
.created_at
.cmp(&left.timestamps.created_at)
.then_with(|| right.id.cmp(&left.id))
});
let total = runs.len() as u64;
let decorated = state.decorate_run_summaries(runs).await;
let (data, has_more) = paginate_items(decorated, &pagination);
(
StatusCode::OK,
Json(serde_json::json!({
"data": data,
"meta": { "has_more": has_more, "total": total }
})),
)
.into_response()
}
async fn create_automation_run(
RequiredRunManagementActor(actor): RequiredRunManagementActor,
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
headers: HeaderMap,
) -> Response {
let id = match parse_automation_id(&id) {
Ok(id) => id,
Err(err) => return err.into_response(),
};
let Some(automation) = state.automation_store().get(&id).await else {
return ApiError::not_found("Automation not found.").into_response();
};
let Some(api_trigger) = startable_api_trigger(&automation) else {
return ApiError::with_code(
StatusCode::CONFLICT,
"Automation has no enabled API trigger.",
"automation_api_trigger_disabled",
)
.into_response();
};
let run_id = RunId::new();
let storage_root = match resolve_interp_string(&state.server_settings().server.storage.root) {
Ok(path) => path,
Err(err) => {
return ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to resolve server storage root: {err}"),
)
.into_response();
}
};
let materialized = match state
.automation_materializer()
.materialize(AutomationRunMaterializeInput {
automation_id: id.clone(),
target: automation.target.clone(),
run_id,
user_settings_path: state.active_config_path().to_path_buf(),
temp_root: automation_temp_root(storage_root),
})
.await
{
Ok(materialized) => materialized,
Err(err) => return materialize_error(&err).into_response(),
};
let automation_ref = AutomationRef {
id: id.to_string(),
name: Some(automation.name.clone()),
trigger_id: Some(api_trigger.id.to_string()),
};
Box::pin(create_run_from_manifest(
state,
CreateRunFromManifestRequest {
explicit_title_supplied: materialized.manifest.title.is_some(),
manifest: materialized.manifest,
submitted_manifest_bytes: materialized.submitted_manifest_bytes,
explicit_run_id: Some(run_id),
actor,
headers,
automation: Some(automation_ref),
},
))
.await
}
fn startable_api_trigger(automation: &Automation) -> Option<&ApiTrigger> {
if !automation.enabled {
return None;
}
automation
.triggers
.iter()
.find_map(|trigger| match trigger {
AutomationTrigger::Api(trigger) if trigger.enabled => Some(trigger),
AutomationTrigger::Api(_) | AutomationTrigger::Schedule(_) => None,
})
}
fn parse_json<T: DeserializeOwned>(body: &[u8]) -> Result<T, ApiError> {
serde_json::from_slice(body).map_err(|err| ApiError::bad_request(err.to_string()))
}
fn parse_automation_id(value: &str) -> Result<AutomationId, ApiError> {
AutomationId::try_from(value.to_string()).map_err(|err| ApiError::bad_request(err.to_string()))
}
fn parse_if_match(headers: &HeaderMap) -> Result<AutomationRevision, ApiError> {
let Some(value) = headers.get(header::IF_MATCH) else {
return Err(ApiError::new(
StatusCode::PRECONDITION_REQUIRED,
"If-Match header is required.",
));
};
let value = value
.to_str()
.map_err(|err| ApiError::bad_request(format!("Invalid If-Match header: {err}")))?
.trim();
let revision = value
.strip_prefix('"')
.and_then(|value| value.strip_suffix('"'))
.unwrap_or(value)
.trim();
if revision.is_empty() {
return Err(ApiError::bad_request(
"If-Match revision must not be empty.",
));
}
Ok(AutomationRevision::from_str(revision)
.expect("AutomationRevision accepts any non-empty string"))
}
fn with_etag(status: StatusCode, automation: Automation) -> Response {
let etag = format!("\"{}\"", automation.revision);
let etag = HeaderValue::from_str(&etag).expect("revision etag should be a valid header value");
(status, [(header::ETAG, etag)], Json(automation)).into_response()
}
fn validation_error(err: &AutomationValidationError) -> ApiError {
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, err.to_string())
}
fn store_error(err: AutomationStoreError) -> ApiError {
match err {
AutomationStoreError::NotFound(_) => ApiError::not_found("Automation not found."),
AutomationStoreError::AlreadyExists(_) => {
ApiError::new(StatusCode::CONFLICT, "Automation already exists.")
}
AutomationStoreError::MissingRevision => ApiError::new(
StatusCode::PRECONDITION_REQUIRED,
"If-Match header is required.",
),
AutomationStoreError::RevisionMismatch { .. } => {
ApiError::new(StatusCode::CONFLICT, "Automation revision mismatch.")
}
AutomationStoreError::Validation(err) => validation_error(&err),
AutomationStoreError::Parse { .. } | AutomationStoreError::Io { .. } => {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
}
}
fn materialize_error(err: &AutomationRunMaterializeError) -> ApiError {
match err {
AutomationRunMaterializeError::InvalidTarget(_)
| AutomationRunMaterializeError::CloneFailed(_)
| AutomationRunMaterializeError::WorkflowNotFound(_)
| AutomationRunMaterializeError::Manifest(_) => {
ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, err.to_string())
}
}
}
impl TryFrom<RawAutomationTarget> for AutomationTarget {
type Error = AutomationValidationError;
fn try_from(value: RawAutomationTarget) -> Result<Self, Self::Error> {
Ok(Self {
repository: RepositorySlug::try_from(value.repository)?,
ref_: GitRefSelector::try_from(value.ref_)?,
workflow: WorkflowSlug::try_from(value.workflow)?,
})
}
}
impl TryFrom<RawAutomationTrigger> for AutomationTrigger {
type Error = AutomationValidationError;
fn try_from(value: RawAutomationTrigger) -> Result<Self, Self::Error> {
let id = AutomationTriggerId::try_from(value.id)?;
match value.type_.as_str() {
"api" => {
reject_trigger_shape(
value.expression.is_some() || !value.extra.is_empty(),
"api trigger only supports id, type, and enabled",
)?;
Ok(Self::Api(ApiTrigger {
id,
enabled: value.enabled,
}))
}
"schedule" => {
reject_trigger_shape(
!value.extra.is_empty(),
"schedule trigger only supports id, type, enabled, and expression",
)?;
Ok(Self::Schedule(ScheduleTrigger {
id,
enabled: value.enabled,
expression: value.expression.unwrap_or_default(),
}))
}
_ => Err(AutomationValidationError::UnknownTriggerType(value.type_)),
}
}
}
fn reject_trigger_shape(
invalid: bool,
message: &'static str,
) -> Result<(), AutomationValidationError> {
if invalid {
Err(AutomationValidationError::InvalidTriggerShape(
message.to_string(),
))
} else {
Ok(())
}
}
impl TryFrom<RawCreateAutomationRequest> for AutomationDraft {
type Error = AutomationValidationError;
fn try_from(value: RawCreateAutomationRequest) -> Result<Self, Self::Error> {
Ok(Self {
id: AutomationId::try_from(value.id)?,
name: value.name,
description: value.description,
enabled: value.enabled,
target: value.target.try_into()?,
triggers: convert_triggers(value.triggers)?,
})
}
}
impl TryFrom<RawReplaceAutomationRequest> for AutomationReplace {
type Error = AutomationValidationError;
fn try_from(value: RawReplaceAutomationRequest) -> Result<Self, Self::Error> {
Ok(Self {
name: value.name,
description: value.description,
enabled: value.enabled,
target: value.target.try_into()?,
triggers: convert_triggers(value.triggers)?,
})
}
}
impl TryFrom<RawPatchAutomationRequest> for AutomationPatch {
type Error = AutomationValidationError;
fn try_from(value: RawPatchAutomationRequest) -> Result<Self, Self::Error> {
let mut patch = Self {
name: value.name,
description: None,
enabled: value.enabled,
target: value.target.map(TryInto::try_into).transpose()?,
triggers: value.triggers.map(convert_triggers).transpose()?,
};
value.description.apply_to(&mut patch);
Ok(patch)
}
}
fn convert_triggers(
triggers: Vec<RawAutomationTrigger>,
) -> Result<Vec<AutomationTrigger>, AutomationValidationError> {
triggers.into_iter().map(TryInto::try_into).collect()
}

View file

@ -573,6 +573,7 @@ mod stage_events_tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -6,6 +6,7 @@ use axum::routing::{get, post};
use super::{ApiError, AppState, IntoResponse, Response, StatusCode, demo};
mod artifacts;
mod automations;
mod billing;
mod completions;
pub(in crate::server) mod events;
@ -148,6 +149,7 @@ pub(super) fn real_routes() -> Router<Arc<AppState>> {
.route("/insights/execute", post(not_implemented))
.route("/insights/history", get(not_implemented))
.merge(runs::routes())
.merge(automations::routes())
.merge(events::routes())
.merge(billing::routes())
.merge(pull_requests::routes())

View file

@ -1027,6 +1027,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -20,9 +20,9 @@ use fabro_config::Storage;
use fabro_interview::AnswerSubmission;
use fabro_llm::client::Client as LlmClient;
use fabro_types::{
Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance, StageContextWindow,
StageContextWindowStaleness, StageContextWindowUnavailableReason, StageHandler,
StageModelUsage, StageProjection, SystemActorKind, parse_blob_ref,
AutomationRef, Principal, RunClientProvenance, RunId, RunProvenance, RunServerProvenance,
StageContextWindow, StageContextWindowStaleness, StageContextWindowUnavailableReason,
StageHandler, StageModelUsage, StageProjection, SystemActorKind, parse_blob_ref,
};
use fabro_util::version::FABRO_VERSION;
use fabro_workflow::command_log::{command_log_path, read_json_string_blob, read_log_slice};
@ -584,28 +584,68 @@ async fn update_run(
}
}
pub(super) struct CreateRunFromManifestRequest {
pub(super) manifest: RunManifest,
pub(super) submitted_manifest_bytes: Vec<u8>,
pub(super) explicit_run_id: Option<RunId>,
pub(super) explicit_title_supplied: bool,
pub(super) actor: Principal,
pub(super) headers: HeaderMap,
pub(super) automation: Option<AutomationRef>,
}
async fn create_run(
RequiredRunManagementActor(actor): RequiredRunManagementActor,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let req = match serde_json::from_slice::<RunManifest>(&body) {
Ok(req) => req,
let manifest = match serde_json::from_slice::<RunManifest>(&body) {
Ok(manifest) => manifest,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let explicit_title_supplied = req.title.is_some();
let explicit_title_supplied = manifest.title.is_some();
Box::pin(create_run_from_manifest(
state,
CreateRunFromManifestRequest {
manifest,
submitted_manifest_bytes: body.to_vec(),
explicit_run_id: None,
explicit_title_supplied,
actor,
headers,
automation: None,
},
))
.await
}
pub(super) async fn create_run_from_manifest(
state: Arc<AppState>,
request: CreateRunFromManifestRequest,
) -> Response {
let CreateRunFromManifestRequest {
manifest,
submitted_manifest_bytes,
explicit_run_id,
explicit_title_supplied,
actor,
headers,
automation,
} = request;
let manifest_run_defaults = state.manifest_run_defaults();
let manifest_environment_defaults = state.manifest_environment_defaults();
let prepared = match run_manifest::prepare_manifest_with_environment_defaults(
manifest_run_defaults.as_ref(),
manifest_environment_defaults.as_ref(),
&req,
&manifest,
) {
Ok(prepared) => prepared,
Err(err) => return ApiError::bad_request(err.to_string()).into_response(),
};
let run_id = prepared.run_id.unwrap_or_else(RunId::new);
let run_id = explicit_run_id
.or(prepared.run_id)
.unwrap_or_else(RunId::new);
let provider = run_manifest::effective_sandbox_provider(&prepared.settings.run);
if let Some(error) =
run_manifest::sandbox_provider_policy_error(&state.server_settings(), provider)
@ -646,7 +686,8 @@ async fn create_run(
);
create_input.run_id = Some(run_id);
create_input.provenance = Some(run_provenance(&headers, &actor));
create_input.submitted_manifest_bytes = Some(body.to_vec());
create_input.submitted_manifest_bytes = Some(submitted_manifest_bytes);
create_input.automation = automation;
let storage_root = match resolve_interp_string(&state.server_settings().server.storage.root) {
Ok(path) => PathBuf::from(path),

View file

@ -1702,6 +1702,7 @@ mod tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
};
let mut projection = fabro_types::RunProjection::new(String::new(), spec, now);
for (index, node_id) in ["start", "plan", "code", "test", "review", "deploy"]

View file

@ -1842,6 +1842,7 @@ methods = ["dev-token"]
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
automation_materializer: None,
shutdown: tokio_util::sync::CancellationToken::new(),
}) else {
panic!("build_app_state should require SESSION_SECRET")
@ -1852,6 +1853,17 @@ methods = ["dev-token"]
));
}
#[tokio::test]
async fn automation_store_empty_without_directory() {
let dir = tempfile::tempdir().expect("tempdir should be created");
let state = TestAppStateBuilder::new()
.active_config_path(dir.path().join("settings.toml"))
.build();
assert!(!dir.path().join("automations").exists());
assert!(state.automation_store().list().await.is_empty());
}
#[test]
fn build_app_state_migrates_legacy_vault_file_on_boot() {
let vault_path = test_secret_store_path();
@ -1966,6 +1978,7 @@ fn build_test_app_state_with_vault_path(vault_path: &Path) -> anyhow::Result<Arc
github_api_base_url: None,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
automation_materializer: None,
shutdown: tokio_util::sync::CancellationToken::new(),
})
}
@ -3150,6 +3163,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -3195,6 +3209,7 @@ async fn create_slack_notification_run(
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -4200,6 +4215,7 @@ async fn list_run_stages_distinguishes_visits() {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -5056,6 +5072,7 @@ fn create_github_token_app_state_with_env_lookup_and_llm_catalog_settings(
github_api_base_url,
active_config_path: tempfile::tempdir().unwrap().path().join("settings.toml"),
http_client: Some(fabro_http::test_http_client().expect("test HTTP client should build")),
automation_materializer: None,
shutdown: tokio_util::sync::CancellationToken::new(),
};
let state = build_app_state(config).expect("test app state should build");
@ -5188,6 +5205,7 @@ async fn create_completed_run_ready_for_pull_request(
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
};
create_durable_run_with_events(state, run_id, &[
@ -5207,6 +5225,7 @@ async fn create_completed_run_ready_for_pull_request(
manifest_blob: None,
git,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -10914,6 +10933,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc<AppState>, run_id: RunId
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -11663,6 +11683,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -13,6 +13,7 @@ use axum::middleware::Next;
use axum::response::Response;
use axum::{Router, middleware};
use chrono::Duration as ChronoDuration;
use fabro_api::types::RunManifest;
use fabro_config::{RunLayer, RunSettingsBuilder, ServerSettingsBuilder, envfile};
use fabro_interview::Interviewer;
use fabro_model::catalog::{LlmCatalogSettings, ProviderCatalogSettings};
@ -27,6 +28,7 @@ use tokio_util::sync::CancellationToken;
use ulid::Ulid;
use crate::auth;
use crate::automation_materializer::{AutomationRunMaterializer, StaticAutomationRunMaterializer};
use crate::ip_allowlist::IpAllowlistConfig;
use crate::jwt_auth::{AuthMode, ConfiguredAuth};
#[cfg(test)]
@ -64,6 +66,7 @@ pub struct TestAppStateBuilder {
vault_path: Option<PathBuf>,
server_env_path: Option<PathBuf>,
active_config_path: Option<PathBuf>,
automation_materializer: Option<Arc<dyn AutomationRunMaterializer>>,
server_secret_env: HashMap<String, String>,
env_lookup: EnvLookup,
llm_catalog_settings: LlmCatalogSettings,
@ -80,6 +83,7 @@ impl Default for TestAppStateBuilder {
vault_path: None,
server_env_path: None,
active_config_path: None,
automation_materializer: None,
server_secret_env: HashMap::new(),
env_lookup: default_env_lookup(),
llm_catalog_settings: LlmCatalogSettings::default(),
@ -170,6 +174,16 @@ impl TestAppStateBuilder {
self
}
pub fn automation_materializer_manifest(mut self, manifest: RunManifest) -> Self {
let submitted_manifest_bytes =
serde_json::to_vec(&manifest).expect("test manifest should serialize");
self.automation_materializer = Some(StaticAutomationRunMaterializer::ok(
manifest,
submitted_manifest_bytes,
));
self
}
pub fn build(self) -> Arc<AppState> {
let (store, artifact_store) = self.store_bundle.unwrap_or_else(test_store_bundle);
let vault_path = self.vault_path.unwrap_or_else(test_secret_store_path);
@ -177,7 +191,9 @@ impl TestAppStateBuilder {
.server_env_path
.unwrap_or_else(|| vault_path.with_file_name("server.env"));
let active_config_path = self.active_config_path.unwrap_or_else(|| {
std::env::temp_dir().join(format!("fabro-test-settings-{}.toml", Ulid::new()))
std::env::temp_dir()
.join(format!("fabro-test-settings-{}", Ulid::new()))
.join("settings.toml")
});
build_app_state(AppStateConfig {
resolved_settings: resolved_runtime_settings_for_tests(
@ -197,6 +213,7 @@ impl TestAppStateBuilder {
http_client: Some(
fabro_http::test_http_client().expect("test HTTP client should build"),
),
automation_materializer: self.automation_materializer,
shutdown: CancellationToken::new(),
})
.expect("test app state should build")

View file

@ -0,0 +1,365 @@
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use fabro_server::test_support::{TestAppStateBuilder, build_test_router};
use serde_json::{Value, json};
use tower::ServiceExt;
use crate::helpers::{MINIMAL_DOT, api, minimal_manifest_json, response_json, response_status};
fn automation_body(id: &str) -> Value {
json!({
"id": id,
"name": "Nightly dependency update",
"description": "Open a PR for dependency updates.",
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
"workflow": "dependency-update"
},
"triggers": [
{ "id": "api", "type": "api", "enabled": true },
{ "id": "nightly", "type": "schedule", "enabled": true, "expression": "0 3 * * *" }
]
})
}
fn request_json(method: Method, path: &str, body: &Value) -> Request<Body> {
Request::builder()
.method(method)
.uri(api(path))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body.to_string()))
.expect("request should build")
}
async fn create_automation(app: &axum::Router, id: &str) -> Value {
let response = app
.clone()
.oneshot(request_json(
Method::POST,
"/automations",
&automation_body(id),
))
.await
.unwrap();
response_json(response, StatusCode::CREATED, "POST /automations").await
}
#[tokio::test]
async fn empty_list_returns_total_zero() {
let app = build_test_router(TestAppStateBuilder::new().build());
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(api("/automations"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response, StatusCode::OK, "GET /automations").await;
assert_eq!(body, json!({ "data": [], "meta": { "total": 0 } }));
}
#[tokio::test]
async fn create_writes_toml_and_duplicate_conflicts() {
let dir = tempfile::tempdir().unwrap();
let state = TestAppStateBuilder::new()
.active_config_path(dir.path().join("settings.toml"))
.build();
let app = build_test_router(state);
let body = create_automation(&app, "nightly-deps").await;
assert_eq!(body["id"], "nightly-deps");
assert_eq!(body["enabled"], true);
assert!(dir.path().join("automations/nightly-deps.toml").exists());
let response = app
.clone()
.oneshot(request_json(
Method::POST,
"/automations",
&automation_body("nightly-deps"),
))
.await
.unwrap();
response_status(response, StatusCode::CONFLICT, "duplicate automation").await;
}
#[tokio::test]
async fn get_replace_patch_and_delete_use_etags() {
let app = build_test_router(TestAppStateBuilder::new().build());
let created = create_automation(&app, "nightly-deps").await;
let revision = created["revision"].as_str().unwrap().to_string();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(api("/automations/nightly-deps"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.headers()[header::ETAG], format!("\"{revision}\""));
let got = response_json(response, StatusCode::OK, "GET automation").await;
assert_eq!(got["id"], "nightly-deps");
let replace = json!({
"name": "Updated automation",
"description": "updated",
"enabled": true,
"target": automation_body("ignored")["target"].clone(),
"triggers": [{ "id": "api", "type": "api", "enabled": true }]
});
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::PUT)
.uri(api("/automations/nightly-deps"))
.header(header::CONTENT_TYPE, "application/json")
.header(header::IF_MATCH, revision.clone())
.body(Body::from(replace.to_string()))
.unwrap(),
)
.await
.unwrap();
let replaced = response_json(response, StatusCode::OK, "PUT automation").await;
assert_eq!(replaced["name"], "Updated automation");
let new_revision = replaced["revision"].as_str().unwrap().to_string();
let stale_response = app
.clone()
.oneshot(
Request::builder()
.method(Method::PUT)
.uri(api("/automations/nightly-deps"))
.header(header::CONTENT_TYPE, "application/json")
.header(header::IF_MATCH, revision)
.body(Body::from(replace.to_string()))
.unwrap(),
)
.await
.unwrap();
response_status(stale_response, StatusCode::CONFLICT, "stale replace").await;
let missing_if_match = app
.clone()
.oneshot(request_json(
Method::PATCH,
"/automations/nightly-deps",
&json!({ "description": null }),
))
.await
.unwrap();
response_status(
missing_if_match,
StatusCode::PRECONDITION_REQUIRED,
"missing if-match",
)
.await;
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::PATCH)
.uri(api("/automations/nightly-deps"))
.header(header::CONTENT_TYPE, "application/json")
.header(header::IF_MATCH, format!("\"{new_revision}\""))
.body(Body::from(json!({ "description": null }).to_string()))
.unwrap(),
)
.await
.unwrap();
let patched = response_json(response, StatusCode::OK, "PATCH automation").await;
assert_eq!(patched["description"], Value::Null);
let patched_revision = patched["revision"].as_str().unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::DELETE)
.uri(api("/automations/nightly-deps"))
.header(header::IF_MATCH, patched_revision)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(response, StatusCode::NO_CONTENT, "DELETE automation").await;
}
#[tokio::test]
async fn validation_errors_return_422() {
let app = build_test_router(TestAppStateBuilder::new().build());
for (label, triggers) in [
(
"invalid trigger id",
json!([{ "id": "_api", "type": "api", "enabled": true }]),
),
(
"duplicate trigger id",
json!([
{ "id": "api", "type": "api", "enabled": true },
{ "id": "api", "type": "schedule", "enabled": true, "expression": "0 3 * * *" }
]),
),
(
"second api trigger",
json!([
{ "id": "api", "type": "api", "enabled": true },
{ "id": "api2", "type": "api", "enabled": true }
]),
),
(
"invalid schedule",
json!([{ "id": "nightly", "type": "schedule", "enabled": true, "expression": "* * * * * *" }]),
),
(
"unknown trigger",
json!([{ "id": "event", "type": "event", "enabled": true }]),
),
(
"unknown trigger future shape",
json!([{ "id": "event", "type": "event", "enabled": true, "pattern": "push" }]),
),
] {
let mut body = automation_body(label.replace(' ', "-").as_str());
body["triggers"] = triggers;
let response = app
.clone()
.oneshot(request_json(Method::POST, "/automations", &body))
.await
.unwrap();
response_status(response, StatusCode::UNPROCESSABLE_ENTITY, label).await;
}
}
#[tokio::test]
async fn disabled_or_missing_enabled_api_trigger_cannot_start() {
let app = build_test_router(TestAppStateBuilder::new().build());
let mut disabled_automation = automation_body("disabled");
disabled_automation["enabled"] = json!(false);
response_json(
app.clone()
.oneshot(request_json(
Method::POST,
"/automations",
&disabled_automation,
))
.await
.unwrap(),
StatusCode::CREATED,
"create disabled automation",
)
.await;
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(api("/automations/disabled/runs"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response_json(response, StatusCode::CONFLICT, "start disabled automation").await;
assert_eq!(body["errors"][0]["code"], "automation_api_trigger_disabled");
let mut disabled_trigger = automation_body("disabled-trigger");
disabled_trigger["triggers"] = json!([{ "id": "api", "type": "api", "enabled": false }]);
response_json(
app.clone()
.oneshot(request_json(
Method::POST,
"/automations",
&disabled_trigger,
))
.await
.unwrap(),
StatusCode::CREATED,
"create disabled trigger automation",
)
.await;
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri(api("/automations/disabled-trigger/runs"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
response_status(response, StatusCode::CONFLICT, "start disabled trigger").await;
}
#[tokio::test]
async fn api_triggered_run_persists_automation_and_lists_runs() {
let manifest: fabro_api::types::RunManifest =
serde_json::from_value(minimal_manifest_json(MINIMAL_DOT))
.expect("minimal manifest should deserialize");
let state = TestAppStateBuilder::new()
.automation_materializer_manifest(manifest)
.build();
let app = build_test_router(state);
create_automation(&app, "nightly-deps").await;
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(api("/automations/nightly-deps/runs"))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let run = response_json(response, StatusCode::CREATED, "POST automation run").await;
assert_eq!(run["automation"]["id"], "nightly-deps");
assert_eq!(run["automation"]["name"], "Nightly dependency update");
assert_eq!(run["automation"]["trigger_id"], "api");
let run_id = run["id"].as_str().unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(api(&format!("/runs/{run_id}")))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let persisted = response_json(response, StatusCode::OK, "GET run").await;
assert_eq!(persisted["automation"], run["automation"]);
let response = app
.oneshot(
Request::builder()
.method(Method::GET)
.uri(api(
"/automations/nightly-deps/runs?page[limit]=10&page[offset]=0",
))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let runs = response_json(response, StatusCode::OK, "GET automation runs").await;
assert_eq!(runs["meta"], json!({ "has_more": false, "total": 1 }));
assert_eq!(runs["data"][0]["id"], run_id);
}

View file

@ -1,4 +1,5 @@
mod auth_sessions;
mod automations;
mod cli_auth_token;
mod docs;
mod events;

View file

@ -72,6 +72,7 @@ async fn append_completed_run_with_final_patch(
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -784,6 +784,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result<RunProjection> {
definition_blob: None,
git: props.git.clone(),
fork_source_ref: props.fork_source_ref.clone(),
automation: props.automation.clone(),
};
let mut projection = RunProjection::new(title, spec, stored.ts);
@ -935,7 +936,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(),
@ -1246,11 +1247,11 @@ mod tests {
StagePromptProps, StageRetryingProps, StageStartedProps,
};
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,
@ -1340,6 +1341,7 @@ mod tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
}
}
@ -1392,6 +1394,38 @@ mod tests {
);
}
#[test]
fn run_created_projects_automation_into_summary() {
let event = test_raw_event(
1,
"run.created",
&json!({
"settings": WorkflowSettings::default(),
"graph": Graph::new("test"),
"labels": {},
"run_dir": "/tmp/run",
"automation": {
"id": "nightly-deps",
"name": "Nightly dependency update",
"trigger_id": "api"
}
}),
None,
);
let projection = RunProjection::apply_events(&[event]).unwrap();
let expected = Some(AutomationRef {
id: "nightly-deps".to_string(),
name: Some("Nightly dependency update".to_string()),
trigger_id: Some("api".to_string()),
});
assert_eq!(projection.spec.automation, expected);
assert_eq!(
build_summary(&projection, &fixtures::RUN_1).automation,
expected
);
}
fn test_raw_event(
seq: u32,
event: &str,
@ -2608,6 +2642,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
};
let summary_json = serde_json::to_value(build_summary(&state, &fixtures::RUN_1)).unwrap();
@ -2633,6 +2668,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
};
let summary = build_summary(&state, &fixtures::RUN_1);

View file

@ -552,6 +552,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
fork_source_ref: None,
automation: None,
}
}

View file

@ -32,6 +32,7 @@ fn sample_run_spec() -> RunSpec {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
fork_source_ref: None,
automation: None,
}
}

View file

@ -2,11 +2,11 @@ use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::WorkflowSettings;
use crate::graph::Graph;
use crate::principal::Principal;
use crate::run_blob_id::RunBlobId;
use crate::run_id::RunId;
use crate::{AutomationRef, WorkflowSettings};
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunServerProvenance {
@ -100,6 +100,8 @@ pub struct RunSpec {
pub git: Option<GitContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_source_ref: Option<ForkSourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
}
impl RunSpec {

View file

@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};
use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel};
use crate::status::{BlockedReason, PendingReason, SuccessReason};
use crate::{
DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId, RunControlAction,
RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings,
AutomationRef, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId,
RunControlAction, RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
@ -37,6 +37,8 @@ pub struct RunCreatedProps {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_source_ref: Option<ForkSourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retried_from: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_id: Option<RunId>,

View file

@ -703,6 +703,7 @@ mod title_tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
};
RunProjection::new(String::new(), spec, Utc::now())
}
@ -772,6 +773,7 @@ mod iter_stages_tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
},
Utc::now(),
)

View file

@ -104,9 +104,11 @@ pub struct WorkflowRef {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AutomationRef {
pub id: String,
pub id: String,
#[serde(default)]
pub name: Option<String>,
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trigger_id: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]

View file

@ -40,6 +40,7 @@ fn run_created_props_round_trip_templated_settings() {
source_run_id: fixtures::RUN_2,
checkpoint_sha: "def456".to_string(),
}),
automation: None,
retried_from: Some(fixtures::RUN_1),
parent_id: Some(fixtures::RUN_2),
web_url: Some("http://localhost:3000/runs/01JNQVR7M0EJ5GKAT2SC4ERS1Z".to_string()),
@ -93,6 +94,7 @@ fn run_created_props_omits_web_url_when_absent() {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -40,6 +40,7 @@ fn sample_run_spec() -> RunSpec {
},
}),
fork_source_ref: None,
automation: None,
}
}

View file

@ -39,6 +39,7 @@ fn run_spec_round_trips_templated_settings() {
source_run_id: fixtures::RUN_2,
checkpoint_sha: "def456".to_string(),
}),
automation: None,
};
let json = serde_json::to_value(&record).expect("record should serialize");

View file

@ -377,6 +377,7 @@ mod tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
}
}
}

View file

@ -39,6 +39,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
manifest_blob,
git,
fork_source_ref,
automation,
retried_from,
parent_id,
web_url,
@ -59,6 +60,7 @@ fn event_body_from_event(event: &Event) -> EventBody {
manifest_blob: *manifest_blob,
git: git.clone(),
fork_source_ref: fork_source_ref.clone(),
automation: automation.clone(),
retried_from: *retried_from,
parent_id: *parent_id,
web_url: web_url.clone(),
@ -2439,6 +2441,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -1,12 +1,12 @@
use std::collections::BTreeMap;
use ::fabro_types::{
BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason,
ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget,
ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, RunBlobId,
RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance,
RunRunnableSource, RunTiming, SandboxProvider, 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, SandboxProvider, StageId, StageTiming,
SuccessReason, run_event as fabro_types,
};
use fabro_agent::{AgentEvent, SandboxEvent};
use fabro_model::{ReasoningEffort, Speed};
@ -48,6 +48,8 @@ pub enum Event {
#[serde(default, skip_serializing_if = "Option::is_none")]
fork_source_ref: Option<ForkSourceRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
automation: Option<AutomationRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
retried_from: Option<RunId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
parent_id: Option<RunId>,

View file

@ -247,6 +247,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -472,6 +472,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -482,6 +482,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -258,6 +258,7 @@ mod tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
},
chrono::Utc::now(),
))
@ -357,6 +358,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -731,6 +731,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -286,6 +286,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -730,6 +730,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -229,6 +229,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -13,7 +13,7 @@ use fabro_graphviz::graph::{AttrValue, Graph};
use fabro_model::{Catalog, ProviderId};
use fabro_store::Database;
use fabro_types::{
ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,
AutomationRef, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,
};
use fabro_util::json::normalize_json_value;
use tokio::task::spawn_blocking;
@ -43,6 +43,7 @@ pub struct CreateRunInput {
pub title: Option<String>,
pub git: Option<GitContext>,
pub fork_source_ref: Option<ForkSourceRef>,
pub automation: Option<AutomationRef>,
pub parent_id: Option<RunId>,
pub provenance: Option<RunProvenance>,
pub configured_providers: Vec<ProviderId>,
@ -70,6 +71,7 @@ struct PersistCreateOptions {
source_directory: Option<String>,
git: Option<GitContext>,
fork_source_ref: Option<ForkSourceRef>,
automation: Option<AutomationRef>,
provenance: Option<RunProvenance>,
configured_providers: Vec<ProviderId>,
catalog: Arc<Catalog>,
@ -104,6 +106,7 @@ pub async fn create(
title,
git,
fork_source_ref,
automation,
parent_id,
provenance,
configured_providers,
@ -146,6 +149,7 @@ pub async fn create(
source_directory,
git,
fork_source_ref,
automation,
provenance,
configured_providers,
catalog,
@ -162,6 +166,7 @@ pub async fn create(
.workflow_toml_path
.as_deref()
.and_then(|path| std::fs::read_to_string(path).ok());
let automation = persisted.run_spec().automation.clone();
persist_created_run(
store,
&persisted,
@ -171,6 +176,7 @@ pub async fn create(
accepted_definition.as_ref(),
title,
parent_id,
automation,
web_url,
)
.await?;
@ -192,6 +198,7 @@ async fn persist_created_run(
accepted_definition: Option<&RunDefinition>,
explicit_title: Option<String>,
parent_id: Option<RunId>,
automation: Option<AutomationRef>,
web_url: Option<String>,
) -> Result<(), Error> {
let record = persisted.run_spec();
@ -245,6 +252,7 @@ async fn persist_created_run(
manifest_blob,
git: record.git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
automation,
retried_from: None,
parent_id,
web_url,
@ -358,6 +366,7 @@ fn persist_validated(
source_directory,
git,
fork_source_ref,
automation,
provenance,
configured_providers,
catalog,
@ -386,6 +395,7 @@ fn persist_validated(
definition_blob: None,
git,
fork_source_ref,
automation,
};
pipeline::persist(validated, PersistOptions { run_dir, run_spec })
@ -1098,6 +1108,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
automation: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
@ -1165,6 +1176,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
fork_source_ref: None,
automation: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
@ -1276,6 +1288,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
automation: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
@ -1321,6 +1334,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
fork_source_ref: None,
automation: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
@ -1388,6 +1402,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
automation: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
@ -1434,6 +1449,7 @@ mod tests {
title: None,
git: None,
fork_source_ref: None,
automation: None,
parent_id: None,
provenance: Some(fabro_types::RunProvenance {
server: Some(fabro_types::RunServerProvenance {

View file

@ -166,6 +166,7 @@ async fn persist_forked_run(
manifest_blob: spec.manifest_blob,
git: spec.git.clone(),
fork_source_ref: spec.fork_source_ref.clone(),
automation: spec.automation.clone(),
retried_from: None,
parent_id: None,
web_url: None,
@ -391,6 +392,7 @@ mod tests {
push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,
}),
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -55,6 +55,7 @@ pub async fn retry_run(
definition_blob,
git,
fork_source_ref,
automation,
} = source.spec;
let settings = serde_json::to_value(&settings).map_err(|err| Error::engine(err.to_string()))?;
@ -81,6 +82,7 @@ pub async fn retry_run(
manifest_blob,
git,
fork_source_ref,
automation,
retried_from: Some(source_run_id),
parent_id,
web_url: input.web_url.clone(),
@ -191,6 +193,7 @@ mod tests {
manifest_blob,
git: Some(git_context()),
fork_source_ref,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -1333,6 +1333,7 @@ reasoning = false
title: None,
git: None,
fork_source_ref: None,
automation: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),
@ -1526,6 +1527,7 @@ reasoning = false
title: None,
git: None,
fork_source_ref: None,
automation: None,
parent_id: None,
provenance: None,
configured_providers: Vec::new(),

View file

@ -252,6 +252,7 @@ mod tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
},
Utc::now(),
)

View file

@ -168,6 +168,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
},
)
}
@ -211,6 +212,7 @@ async fn seed_created_and_starting(
manifest_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -742,6 +742,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -859,6 +860,7 @@ mod tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
},
chrono::Utc::now(),
)

View file

@ -867,6 +867,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
},
)
}

View file

@ -151,6 +151,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
}
}
@ -173,6 +174,7 @@ mod tests {
manifest_blob: None,
git: record.git.clone(),
fork_source_ref: record.fork_source_ref.clone(),
automation: record.automation.clone(),
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -827,6 +827,7 @@ mod tests {
definition_blob: None,
git: None,
fork_source_ref: None,
automation: None,
},
Utc::now(),
)
@ -1150,6 +1151,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
@ -1167,6 +1169,7 @@ mod tests {
manifest_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -1219,6 +1222,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
@ -1236,6 +1240,7 @@ mod tests {
manifest_blob: None,
git: run_spec.git.clone(),
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -1573,6 +1578,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
@ -1590,6 +1596,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -1700,6 +1707,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
@ -1717,6 +1725,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,
@ -1869,6 +1878,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
};
append_event(&run_store, &fixtures::RUN_1, &Event::RunCreated {
run_id: fixtures::RUN_1,
@ -1886,6 +1896,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -494,6 +494,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
}
}
@ -522,6 +523,7 @@ mod tests {
manifest_blob: None,
git: run_spec.git.clone(),
fork_source_ref: run_spec.fork_source_ref.clone(),
automation: run_spec.automation.clone(),
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -642,6 +642,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
},
chrono::Utc::now(),
);

View file

@ -151,6 +151,7 @@ mod tests {
manifest_blob: None,
definition_blob: None,
fork_source_ref: None,
automation: None,
}
}
@ -172,6 +173,7 @@ mod tests {
manifest_blob: None,
git: None,
fork_source_ref: None,
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -128,6 +128,7 @@ async fn initialized(
manifest_blob: None,
git: run_options.pre_run_git.clone(),
fork_source_ref: run_options.fork_source_ref.clone(),
automation: None,
retried_from: None,
parent_id: None,
web_url: None,

View file

@ -1,5 +1,6 @@
api.ts
api/auth-api.ts
api/automations-api.ts
api/billing-api.ts
api/completions-api.ts
api/discovery-api.ts
@ -52,7 +53,14 @@ models/auth-method.ts
models/auth-session-user.ts
models/auth-session.ts
models/auth-sessions-response.ts
models/automation-api-trigger.ts
models/automation-list-response-meta.ts
models/automation-list-response.ts
models/automation-ref.ts
models/automation-schedule-trigger.ts
models/automation-target.ts
models/automation-trigger.ts
models/automation.ts
models/batch-delete-runs-request.ts
models/batch-delete-runs-response.ts
models/batch-delete-runs-result.ts
@ -82,6 +90,7 @@ models/completion-tool-choice.ts
models/completion-tool-definition.ts
models/completion-usage.ts
models/conclusion.ts
models/create-automation-request.ts
models/create-completion-request.ts
models/create-run-pull-request-request.ts
models/create-run-session-request.ts
@ -234,6 +243,7 @@ models/pair-transcript-system-message.ts
models/pair-transcript-tool-call.ts
models/pair-transcript-user-message.ts
models/pair-transcript-warning.ts
models/patch-automation-request.ts
models/pending-interview-record.ts
models/pending-reason.ts
models/permission-level.ts
@ -283,6 +293,7 @@ models/related-workflow-diagnostic.ts
models/render-workflow-graph-direction.ts
models/render-workflow-graph-format.ts
models/render-workflow-graph-request.ts
models/replace-automation-request.ts
models/repo-check-response-permissions.ts
models/repo-check-response.ts
models/repository-ref.ts

View file

@ -15,6 +15,7 @@
export * from './api/auth-api';
export * from './api/automations-api';
export * from './api/billing-api';
export * from './api/completions-api';
export * from './api/discovery-api';

View file

@ -0,0 +1,714 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from '../configuration';
import type { AxiosPromise, AxiosInstance, RawAxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction, replaceWithSerializableTypeIfNeeded } from '../common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, type RequestArgs, BaseAPI, RequiredError, operationServerMap } from '../base';
// @ts-ignore
import type { Automation } from '../models';
// @ts-ignore
import type { AutomationListResponse } from '../models';
// @ts-ignore
import type { CreateAutomationRequest } from '../models';
// @ts-ignore
import type { ErrorResponse } from '../models';
// @ts-ignore
import type { PaginatedRunList } from '../models';
// @ts-ignore
import type { PatchAutomationRequest } from '../models';
// @ts-ignore
import type { ReplaceAutomationRequest } from '../models';
// @ts-ignore
import type { Run } from '../models';
/**
* AutomationsApi - axios parameter creator
*/
export const AutomationsApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
* Creates an automation and persists it as canonical TOML.
* @summary Create Automation
* @param {CreateAutomationRequest} createAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAutomation: async (createAutomationRequest: CreateAutomationRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'createAutomationRequest' is not null or undefined
assertParamExists('createAutomation', 'createAutomationRequest', createAutomationRequest)
const localVarPath = `/api/v1/automations`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(createAutomationRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Materializes the automation target and creates a run when an enabled `api` trigger is present.
* @summary Start Automation Run
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAutomationRun: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('createAutomationRun', 'id', id)
const localVarPath = `/api/v1/automations/{id}/runs`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'POST', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Delete Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteAutomation: async (ifMatch: string, id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'ifMatch' is not null or undefined
assertParamExists('deleteAutomation', 'ifMatch', ifMatch)
// verify required parameter 'id' is not null or undefined
assertParamExists('deleteAutomation', 'id', id)
const localVarPath = `/api/v1/automations/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
if (ifMatch != null) {
localVarHeaderParameter['If-Match'] = String(ifMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Get Automation
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAutomation: async (id: string, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('getAutomation', 'id', id)
const localVarPath = `/api/v1/automations/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary List Automation Runs
* @param {string} id Automation ID.
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listAutomationRuns: async (id: string, pageLimit?: number, pageOffset?: number, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('listAutomationRuns', 'id', id)
const localVarPath = `/api/v1/automations/{id}/runs`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (pageLimit !== undefined) {
localVarQueryParameter['page[limit]'] = pageLimit;
}
if (pageOffset !== undefined) {
localVarQueryParameter['page[offset]'] = pageOffset;
}
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Returns automation definitions sorted by automation ID.
* @summary List Automations
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listAutomations: async (options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/api/v1/automations`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Accept'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Patch Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {PatchAutomationRequest} patchAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
patchAutomation: async (ifMatch: string, id: string, patchAutomationRequest: PatchAutomationRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'ifMatch' is not null or undefined
assertParamExists('patchAutomation', 'ifMatch', ifMatch)
// verify required parameter 'id' is not null or undefined
assertParamExists('patchAutomation', 'id', id)
// verify required parameter 'patchAutomationRequest' is not null or undefined
assertParamExists('patchAutomation', 'patchAutomationRequest', patchAutomationRequest)
const localVarPath = `/api/v1/automations/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PATCH', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
if (ifMatch != null) {
localVarHeaderParameter['If-Match'] = String(ifMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(patchAutomationRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @summary Replace Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {ReplaceAutomationRequest} replaceAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
replaceAutomation: async (ifMatch: string, id: string, replaceAutomationRequest: ReplaceAutomationRequest, options: RawAxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'ifMatch' is not null or undefined
assertParamExists('replaceAutomation', 'ifMatch', ifMatch)
// verify required parameter 'id' is not null or undefined
assertParamExists('replaceAutomation', 'id', id)
// verify required parameter 'replaceAutomationRequest' is not null or undefined
assertParamExists('replaceAutomation', 'replaceAutomationRequest', replaceAutomationRequest)
const localVarPath = `/api/v1/automations/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'PUT', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication SessionCookie required
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
localVarHeaderParameter['Content-Type'] = 'application/json';
localVarHeaderParameter['Accept'] = 'application/json';
if (ifMatch != null) {
localVarHeaderParameter['If-Match'] = String(ifMatch);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(replaceAutomationRequest, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* AutomationsApi - functional programming interface
*/
export const AutomationsApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = AutomationsApiAxiosParamCreator(configuration)
return {
/**
* Creates an automation and persists it as canonical TOML.
* @summary Create Automation
* @param {CreateAutomationRequest} createAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createAutomation(createAutomationRequest: CreateAutomationRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Automation>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createAutomation(createAutomationRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AutomationsApi.createAutomation']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Materializes the automation target and creates a run when an enabled `api` trigger is present.
* @summary Start Automation Run
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createAutomationRun(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Run>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createAutomationRun(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AutomationsApi.createAutomationRun']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Delete Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteAutomation(ifMatch: string, id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteAutomation(ifMatch, id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AutomationsApi.deleteAutomation']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Get Automation
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAutomation(id: string, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Automation>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAutomation(id, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AutomationsApi.getAutomation']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary List Automation Runs
* @param {string} id Automation ID.
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listAutomationRuns(id: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PaginatedRunList>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listAutomationRuns(id, pageLimit, pageOffset, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AutomationsApi.listAutomationRuns']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Returns automation definitions sorted by automation ID.
* @summary List Automations
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listAutomations(options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AutomationListResponse>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.listAutomations(options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AutomationsApi.listAutomations']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Patch Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {PatchAutomationRequest} patchAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async patchAutomation(ifMatch: string, id: string, patchAutomationRequest: PatchAutomationRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Automation>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.patchAutomation(ifMatch, id, patchAutomationRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AutomationsApi.patchAutomation']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
*
* @summary Replace Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {ReplaceAutomationRequest} replaceAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async replaceAutomation(ifMatch: string, id: string, replaceAutomationRequest: ReplaceAutomationRequest, options?: RawAxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Automation>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.replaceAutomation(ifMatch, id, replaceAutomationRequest, options);
const localVarOperationServerIndex = configuration?.serverIndex ?? 0;
const localVarOperationServerBasePath = operationServerMap['AutomationsApi.replaceAutomation']?.[localVarOperationServerIndex]?.url;
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
}
};
/**
* AutomationsApi - factory interface
*/
export const AutomationsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = AutomationsApiFp(configuration)
return {
/**
* Creates an automation and persists it as canonical TOML.
* @summary Create Automation
* @param {CreateAutomationRequest} createAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAutomation(createAutomationRequest: CreateAutomationRequest, options?: RawAxiosRequestConfig): AxiosPromise<Automation> {
return localVarFp.createAutomation(createAutomationRequest, options).then((request) => request(axios, basePath));
},
/**
* Materializes the automation target and creates a run when an enabled `api` trigger is present.
* @summary Start Automation Run
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createAutomationRun(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Run> {
return localVarFp.createAutomationRun(id, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Delete Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteAutomation(ifMatch: string, id: string, options?: RawAxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteAutomation(ifMatch, id, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Get Automation
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAutomation(id: string, options?: RawAxiosRequestConfig): AxiosPromise<Automation> {
return localVarFp.getAutomation(id, options).then((request) => request(axios, basePath));
},
/**
*
* @summary List Automation Runs
* @param {string} id Automation ID.
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listAutomationRuns(id: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig): AxiosPromise<PaginatedRunList> {
return localVarFp.listAutomationRuns(id, pageLimit, pageOffset, options).then((request) => request(axios, basePath));
},
/**
* Returns automation definitions sorted by automation ID.
* @summary List Automations
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listAutomations(options?: RawAxiosRequestConfig): AxiosPromise<AutomationListResponse> {
return localVarFp.listAutomations(options).then((request) => request(axios, basePath));
},
/**
*
* @summary Patch Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {PatchAutomationRequest} patchAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
patchAutomation(ifMatch: string, id: string, patchAutomationRequest: PatchAutomationRequest, options?: RawAxiosRequestConfig): AxiosPromise<Automation> {
return localVarFp.patchAutomation(ifMatch, id, patchAutomationRequest, options).then((request) => request(axios, basePath));
},
/**
*
* @summary Replace Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {ReplaceAutomationRequest} replaceAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
replaceAutomation(ifMatch: string, id: string, replaceAutomationRequest: ReplaceAutomationRequest, options?: RawAxiosRequestConfig): AxiosPromise<Automation> {
return localVarFp.replaceAutomation(ifMatch, id, replaceAutomationRequest, options).then((request) => request(axios, basePath));
},
};
};
/**
* AutomationsApi - object-oriented interface
*/
export class AutomationsApi extends BaseAPI {
/**
* Creates an automation and persists it as canonical TOML.
* @summary Create Automation
* @param {CreateAutomationRequest} createAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public createAutomation(createAutomationRequest: CreateAutomationRequest, options?: RawAxiosRequestConfig) {
return AutomationsApiFp(this.configuration).createAutomation(createAutomationRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
* Materializes the automation target and creates a run when an enabled `api` trigger is present.
* @summary Start Automation Run
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public createAutomationRun(id: string, options?: RawAxiosRequestConfig) {
return AutomationsApiFp(this.configuration).createAutomationRun(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Delete Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public deleteAutomation(ifMatch: string, id: string, options?: RawAxiosRequestConfig) {
return AutomationsApiFp(this.configuration).deleteAutomation(ifMatch, id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Get Automation
* @param {string} id Automation ID.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public getAutomation(id: string, options?: RawAxiosRequestConfig) {
return AutomationsApiFp(this.configuration).getAutomation(id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary List Automation Runs
* @param {string} id Automation ID.
* @param {number} [pageLimit] Maximum number of items to return per page.
* @param {number} [pageOffset] Number of items to skip before returning results.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listAutomationRuns(id: string, pageLimit?: number, pageOffset?: number, options?: RawAxiosRequestConfig) {
return AutomationsApiFp(this.configuration).listAutomationRuns(id, pageLimit, pageOffset, options).then((request) => request(this.axios, this.basePath));
}
/**
* Returns automation definitions sorted by automation ID.
* @summary List Automations
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public listAutomations(options?: RawAxiosRequestConfig) {
return AutomationsApiFp(this.configuration).listAutomations(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Patch Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {PatchAutomationRequest} patchAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public patchAutomation(ifMatch: string, id: string, patchAutomationRequest: PatchAutomationRequest, options?: RawAxiosRequestConfig) {
return AutomationsApiFp(this.configuration).patchAutomation(ifMatch, id, patchAutomationRequest, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @summary Replace Automation
* @param {string} ifMatch Current automation revision, quoted or unquoted.
* @param {string} id Automation ID.
* @param {ReplaceAutomationRequest} replaceAutomationRequest
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
public replaceAutomation(ifMatch: string, id: string, replaceAutomationRequest: ReplaceAutomationRequest, options?: RawAxiosRequestConfig) {
return AutomationsApiFp(this.configuration).replaceAutomation(ifMatch, id, replaceAutomationRequest, options).then((request) => request(this.axios, this.basePath));
}
}

View file

@ -1120,7 +1120,7 @@ export const RunsApiAxiosParamCreator = function (configuration?: Configuration)
};
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -1868,7 +1868,7 @@ export const RunsApiFp = function(configuration?: Configuration) {
return (axios, basePath) => createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration)(axios, localVarOperationServerBasePath || basePath);
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -2264,7 +2264,7 @@ export const RunsApiFactory = function (configuration?: Configuration, basePath?
return localVarFp.retrieveRunGraphSource(id, options).then((request) => request(axios, basePath));
},
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.
@ -2652,7 +2652,7 @@ export class RunsApi extends BaseAPI {
}
/**
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Cancelled, active, succeeded, and archived runs are not retryable.
* Creates a fresh run from the failed or dead source run\'s captured durable definition, records `retried_from` on the new run, and schedules it for execution. The source run is left unchanged. Active, succeeded, and archived runs are not retryable.
* @summary Retry Run
* @param {string} id Unique run identifier (ULID).
* @param {*} [options] Override http request option.

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface AutomationApiTrigger {
'id': string;
'type': AutomationApiTriggerTypeEnum;
'enabled'?: boolean;
}
export const AutomationApiTriggerTypeEnum = {
API: 'api'
} as const;
export type AutomationApiTriggerTypeEnum = typeof AutomationApiTriggerTypeEnum[keyof typeof AutomationApiTriggerTypeEnum];

View file

@ -0,0 +1,19 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface AutomationListResponseMeta {
'total': number;
}

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { Automation } from './automation';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationListResponseMeta } from './automation-list-response-meta';
/**
* List of automation definitions.
*/
export interface AutomationListResponse {
'data': Array<Automation>;
'meta': AutomationListResponseMeta;
}

View file

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

View file

@ -0,0 +1,31 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface AutomationScheduleTrigger {
'id': string;
'type': AutomationScheduleTriggerTypeEnum;
'enabled'?: boolean;
/**
* Five-field cron expression accepted by croner.
*/
'expression': string;
}
export const AutomationScheduleTriggerTypeEnum = {
SCHEDULE: 'schedule'
} as const;
export type AutomationScheduleTriggerTypeEnum = typeof AutomationScheduleTriggerTypeEnum[keyof typeof AutomationScheduleTriggerTypeEnum];

View file

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Repository, ref, and Fabro workflow selector materialized when the automation starts.
*/
export interface AutomationTarget {
/**
* GitHub owner/repo slug.
*/
'repository': string;
/**
* Branch, tag, or SHA selector to check out.
*/
'ref': string;
/**
* Fabro workflow slug or relative workflow path.
*/
'workflow': string;
}

View file

@ -0,0 +1,27 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationApiTrigger } from './automation-api-trigger';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationScheduleTrigger } from './automation-schedule-trigger';
/**
* @type AutomationTrigger
* Automation trigger definition.
*/
export type AutomationTrigger = { type: 'api' } & AutomationApiTrigger | { type: 'schedule' } & AutomationScheduleTrigger;

View file

@ -0,0 +1,37 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTarget } from './automation-target';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTrigger } from './automation-trigger';
/**
* Server-owned runnable automation binding.
*/
export interface Automation {
'id': string;
/**
* Lowercase hex SHA-256 revision of the canonical TOML bytes.
*/
'revision': string;
'name': string;
'description': string | null;
'enabled': boolean;
'target': AutomationTarget;
'triggers': Array<AutomationTrigger>;
}

View file

@ -0,0 +1,30 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTarget } from './automation-target';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTrigger } from './automation-trigger';
export interface CreateAutomationRequest {
'id': string;
'name': string;
'description'?: string | null;
'enabled'?: boolean;
'target': AutomationTarget;
'triggers': Array<AutomationTrigger>;
}

View file

@ -29,7 +29,14 @@ export * from './auth-method';
export * from './auth-session';
export * from './auth-session-user';
export * from './auth-sessions-response';
export * from './automation';
export * from './automation-api-trigger';
export * from './automation-list-response';
export * from './automation-list-response-meta';
export * from './automation-ref';
export * from './automation-schedule-trigger';
export * from './automation-target';
export * from './automation-trigger';
export * from './batch-delete-runs-request';
export * from './batch-delete-runs-response';
export * from './batch-delete-runs-result';
@ -59,6 +66,7 @@ export * from './completion-tool-choice';
export * from './completion-tool-definition';
export * from './completion-usage';
export * from './conclusion';
export * from './create-automation-request';
export * from './create-completion-request';
export * from './create-run-pull-request-request';
export * from './create-run-session-request';
@ -210,6 +218,7 @@ export * from './pair-transcript-system-message';
export * from './pair-transcript-tool-call';
export * from './pair-transcript-user-message';
export * from './pair-transcript-warning';
export * from './patch-automation-request';
export * from './pending-interview-record';
export * from './pending-reason';
export * from './permission-level';
@ -259,6 +268,7 @@ export * from './related-workflow-diagnostic';
export * from './render-workflow-graph-direction';
export * from './render-workflow-graph-format';
export * from './render-workflow-graph-request';
export * from './replace-automation-request';
export * from './repo-check-response';
export * from './repo-check-response-permissions';
export * from './repository-ref';

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTarget } from './automation-target';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTrigger } from './automation-trigger';
export interface PatchAutomationRequest {
'name'?: string;
'description'?: string | null;
'enabled'?: boolean;
'target'?: AutomationTarget;
'triggers'?: Array<AutomationTrigger>;
}

View file

@ -0,0 +1,29 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTarget } from './automation-target';
// May contain unused imports in some cases
// @ts-ignore
import type { AutomationTrigger } from './automation-trigger';
export interface ReplaceAutomationRequest {
'name': string;
'description'?: string | null;
'enabled': boolean;
'target': AutomationTarget;
'triggers': Array<AutomationTrigger>;
}