diff --git a/Cargo.lock b/Cargo.lock index 9c2f56b80..5949b1eca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -105,7 +105,7 @@ dependencies = [ "serde", "serde_json", "serde_with", - "strum", + "strum 0.28.0", "tracing", ] @@ -1022,6 +1022,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "croner" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +dependencies = [ + "chrono", + "derive_builder", + "strum 0.27.2", +] + [[package]] name = "crossbeam" version = "0.8.4" @@ -1166,6 +1177,16 @@ dependencies = [ "darling_macro 0.14.4", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" @@ -1190,6 +1211,20 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", + "syn 2.0.117", +] + [[package]] name = "darling_core" version = "0.23.0" @@ -1214,6 +1249,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" @@ -1332,6 +1378,37 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1632,7 +1709,7 @@ dependencies = [ "serde_json", "sha2", "shell-escape", - "strum", + "strum 0.28.0", "tempfile", "thiserror 2.0.18", "tokio", @@ -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]] diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index 63ac4fc8c..d4d063618 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -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 diff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml index 8b347f032..ce21c0986 100644 --- a/lib/crates/fabro-api/Cargo.toml +++ b/lib/crates/fabro-api/Cargo.toml @@ -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" } diff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs index 14d13d70e..f2c810eb8 100644 --- a/lib/crates/fabro-api/build.rs +++ b/lib/crates/fabro-api/build.rs @@ -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", &[]), diff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs index 9b40a152c..b1b6f6250 100644 --- a/lib/crates/fabro-api/src/lib.rs +++ b/lib/crates/fabro-api/src/lib.rs @@ -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, diff --git a/lib/crates/fabro-api/tests/automation_round_trip.rs b/lib/crates/fabro-api/tests/automation_round_trip.rs new file mode 100644 index 000000000..0e8c738ca --- /dev/null +++ b/lib/crates/fabro-api/tests/automation_round_trip.rs @@ -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::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); + assert_same_type::(); +} + +#[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() { + assert_eq!( + TypeId::of::(), + TypeId::of::(), + "{} should be the same type as {}", + type_name::(), + type_name::() + ); +} diff --git a/lib/crates/fabro-api/tests/run_projection_round_trip.rs b/lib/crates/fabro-api/tests/run_projection_round_trip.rs index 64a00df91..ba80fa33d 100644 --- a/lib/crates/fabro-api/tests/run_projection_round_trip.rs +++ b/lib/crates/fabro-api/tests/run_projection_round_trip.rs @@ -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() diff --git a/lib/crates/fabro-automation/Cargo.toml b/lib/crates/fabro-automation/Cargo.toml new file mode 100644 index 000000000..b25a2c5a8 --- /dev/null +++ b/lib/crates/fabro-automation/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "fabro-automation" +edition.workspace = true +version.workspace = true +publish = false +license.workspace = true +description = "Automation domain 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" diff --git a/lib/crates/fabro-automation/src/error.rs b/lib/crates/fabro-automation/src/error.rs new file mode 100644 index 000000000..4fd45049e --- /dev/null +++ b/lib/crates/fabro-automation/src/error.rs @@ -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, source: std::io::Error) -> Self { + Self::Io { + path: path.into(), + source, + } + } + + pub(crate) fn parse(path: impl Into, source: TomlDeError) -> Self { + Self::Parse { + path: path.into(), + source, + } + } +} diff --git a/lib/crates/fabro-automation/src/id.rs b/lib/crates/fabro-automation/src/id.rs new file mode 100644 index 000000000..220fdd262 --- /dev/null +++ b/lib/crates/fabro-automation/src/id.rs @@ -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 for AutomationId { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl AsRef 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 for AutomationId { + type Error = AutomationValidationError; + + fn try_from(value: String) -> Result { + validate_id(&value, false) + .then_some(Self(value.clone())) + .ok_or(AutomationValidationError::InvalidAutomationId(value)) + } +} + +impl TryFrom for AutomationTriggerId { + type Error = AutomationValidationError; + + fn try_from(value: String) -> Result { + 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::try_from(value.to_string()) + } +} + +impl FromStr for AutomationTriggerId { + type Err = AutomationValidationError; + + fn from_str(value: &str) -> Result { + Self::try_from(value.to_string()) + } +} + +impl Serialize for AutomationId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl Serialize for AutomationTriggerId { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AutomationId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::try_from(value).map_err(D::Error::custom) + } +} + +impl<'de> Deserialize<'de> for AutomationTriggerId { + fn deserialize(deserializer: D) -> Result + 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::().is_ok()); + assert!("a0-b".parse::().is_ok()); + assert!("0".parse::().is_ok()); + } + + #[test] + fn trigger_id_accepts_underscore_after_first_character() { + assert!("api_1".parse::().is_ok()); + assert!("a-b_c".parse::().is_ok()); + } +} diff --git a/lib/crates/fabro-automation/src/lib.rs b/lib/crates/fabro-automation/src/lib.rs new file mode 100644 index 000000000..64c64111c --- /dev/null +++ b/lib/crates/fabro-automation/src/lib.rs @@ -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; diff --git a/lib/crates/fabro-automation/src/model.rs b/lib/crates/fabro-automation/src/model.rs new file mode 100644 index 000000000..0d40084aa --- /dev/null +++ b/lib/crates/fabro-automation/src/model.rs @@ -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, + pub enabled: bool, + pub target: AutomationTarget, + pub triggers: Vec, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + pub target: AutomationTarget, + pub triggers: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AutomationReplace { + pub name: String, + #[serde(default)] + pub description: Option, + pub enabled: bool, + pub target: AutomationTarget, + pub triggers: Vec, +} + +#[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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub triggers: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PersistedAutomation { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default = "default_true")] + pub enabled: bool, + pub target: AutomationTarget, + #[serde(default)] + pub triggers: Vec, +} + +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 { + let source = std::str::from_utf8(bytes).map_err(TomlDeError::custom)?; + let persisted = toml::from_str::(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 { + 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, 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 { + 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 { + 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 for RepositorySlug { + type Error = AutomationValidationError; + + fn try_from(value: String) -> Result { + 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 for GitRefSelector { + type Error = AutomationValidationError; + + fn try_from(value: String) -> Result { + if valid_git_ref_selector(&value) { + Ok(Self(value)) + } else { + Err(AutomationValidationError::InvalidGitRefSelector(value)) + } + } +} + +impl TryFrom for WorkflowSlug { + type Error = AutomationValidationError; + + fn try_from(value: String) -> Result { + if valid_workflow_selector(&value) { + Ok(Self(value)) + } else { + Err(AutomationValidationError::InvalidWorkflowSelector(value)) + } + } +} + +macro_rules! impl_string_newtype { + ($type:ty) => { + impl AsRef 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::try_from(value.to_string()) + } + } + + impl Serialize for $type { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } + } + + impl<'de> Deserialize<'de> for $type { + fn deserialize(deserializer: D) -> Result + 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 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 { + Ok(Self(value.to_string())) + } +} + +impl Serialize for AutomationRevision { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for AutomationRevision { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + 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 = 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(_))); + } +} diff --git a/lib/crates/fabro-automation/src/store.rs b/lib/crates/fabro-automation/src/store.rs new file mode 100644 index 000000000..90fac75e7 --- /dev/null +++ b/lib/crates/fabro-automation/src/store.rs @@ -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>, +} + +impl AutomationStore { + pub async fn load(dir: impl Into) -> Result { + 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) -> Result { + 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 { + self.items.read().await.values().cloned().collect() + } + + pub async fn get(&self, id: &AutomationId) -> Option { + self.items.read().await.get(id).cloned() + } + + pub async fn create(&self, draft: AutomationDraft) -> Result { + 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 { + 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 { + 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 { + 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 ¤t.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)); + } +} diff --git a/lib/crates/fabro-cli/src/commands/run/attach.rs b/lib/crates/fabro-cli/src/commands/run/attach.rs index c8ad2fff2..16e4c8dc7 100644 --- a/lib/crates/fabro-cli/src/commands/run/attach.rs +++ b/lib/crates/fabro-cli/src/commands/run/attach.rs @@ -844,6 +844,7 @@ mod tests { manifest_blob: None, definition_blob: None, git: None, + automation: None, fork_source_ref: None, }; serde_json::json!({ diff --git a/lib/crates/fabro-cli/tests/it/support/mod.rs b/lib/crates/fabro-cli/tests/it/support/mod.rs index 7c7c59a22..d489991b9 100644 --- a/lib/crates/fabro-cli/tests/it/support/mod.rs +++ b/lib/crates/fabro-cli/tests/it/support/mod.rs @@ -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, }; diff --git a/lib/crates/fabro-dump/src/lib.rs b/lib/crates/fabro-dump/src/lib.rs index 3c7b5a070..55d50b89c 100644 --- a/lib/crates/fabro-dump/src/lib.rs +++ b/lib/crates/fabro-dump/src/lib.rs @@ -500,6 +500,7 @@ mod tests { provenance: None, manifest_blob: None, definition_blob: None, + automation: None, fork_source_ref: None, } } diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml index f83c73981..e6a20b672 100644 --- a/lib/crates/fabro-server/Cargo.toml +++ b/lib/crates/fabro-server/Cargo.toml @@ -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 diff --git a/lib/crates/fabro-server/src/automation_materializer.rs b/lib/crates/fabro-server/src/automation_materializer.rs new file mode 100644 index 000000000..ad090632d --- /dev/null +++ b/lib/crates/fabro-server/src/automation_materializer.rs @@ -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, +} + +#[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; +} + +pub(crate) struct GitAutomationRunMaterializer { + github_credentials: Option, + github_api_base_url: String, + http_client: Option, + git_timeout: Duration, +} + +impl GitAutomationRunMaterializer { + pub(crate) fn new( + github_credentials: Option, + github_api_base_url: String, + http_client: Option, + ) -> 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + vec![ + "-C".into(), + repo_dir.as_os_str().to_owned(), + "checkout".into(), + "--force".into(), + ref_.into(), + ] +} + +async fn run_git( + args: Vec, + 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::>() + .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 { + 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, +} + +#[cfg(any(test, feature = "test-support"))] +impl StaticAutomationRunMaterializer { + pub(crate) fn ok( + manifest: RunManifest, + submitted_manifest_bytes: Vec, + ) -> std::sync::Arc { + 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 { + 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") + ); + } +} diff --git a/lib/crates/fabro-server/src/lib.rs b/lib/crates/fabro-server/src/lib.rs index 5bbad6ea5..acaf9dfe3 100644 --- a/lib/crates/fabro-server/src/lib.rs +++ b/lib/crates/fabro-server/src/lib.rs @@ -9,6 +9,7 @@ )] pub mod auth; +mod automation_materializer; mod canonical_host; mod canonical_origin; pub mod csp; diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs index e8416bfc1..493cd8200 100644 --- a/lib/crates/fabro-server/src/run_files.rs +++ b/lib/crates/fabro-server/src/run_files.rs @@ -2379,6 +2379,7 @@ index 1111111..2222222 160000 definition_blob: None, git: None, fork_source_ref: None, + automation: None, }, chrono::Utc::now(), ); diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs index 0aefe8f78..6b8d60256 100644 --- a/lib/crates/fabro-server/src/run_manifest.rs +++ b/lib/crates/fabro-server/src/run_manifest.rs @@ -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, diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs index 1fc8bae42..04bddbc0e 100644 --- a/lib/crates/fabro-server/src/serve.rs +++ b/lib/crates/fabro-server/src/serve.rs @@ -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?; diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs index bd2638729..5926c8391 100644 --- a/lib/crates/fabro-server/src/server.rs +++ b/lib/crates/fabro-server/src/server.rs @@ -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>, aggregate_billing: Mutex, store: Arc, + automation_store: Arc, + automation_materializer: Arc, session_runtimes: SessionRuntimeManager, artifact_store: ArtifactStore, worker_tokens: WorkerTokenKeys, @@ -1059,6 +1063,7 @@ pub(crate) struct AppStateConfig { pub(crate) github_api_base_url: Option, pub(crate) active_config_path: PathBuf, pub(crate) http_client: Option, + pub(crate) automation_materializer: Option>, pub(crate) shutdown: CancellationToken, } @@ -1263,6 +1268,14 @@ impl AppState { &self.store } + pub(crate) fn automation_store(&self) -> Arc { + Arc::clone(&self.automation_store) + } + + pub(crate) fn automation_materializer(&self) -> Arc { + 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, 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, @@ -2071,6 +2131,7 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result = Arc::new(VaultCredentialSource::with_env_lookup( Arc::clone(&vault), { @@ -2108,7 +2186,6 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result anyhow::Result anyhow::Result Router> { + 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, + #[serde(flatten)] + extra: BTreeMap, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCreateAutomationRequest { + id: String, + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + enabled: Option, + target: RawAutomationTarget, + triggers: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawReplaceAutomationRequest { + name: String, + #[serde(default)] + description: Option, + enabled: bool, + target: RawAutomationTarget, + triggers: Vec, +} + +#[derive(Debug, Deserialize, Default)] +#[serde(deny_unknown_fields)] +struct RawPatchAutomationRequest { + #[serde(default)] + name: Option, + #[serde(default, deserialize_with = "deserialize_nullable_string_patch")] + description: NullableStringPatch, + #[serde(default)] + enabled: Option, + #[serde(default)] + target: Option, + #[serde(default)] + triggers: Option>, +} + +#[derive(Debug, Default)] +enum NullableStringPatch { + #[default] + Omitted, + Explicit(Option), +} + +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 +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(NullableStringPatch::Explicit) +} + +#[derive(serde::Serialize)] +struct AutomationListResponse { + data: Vec, + meta: AutomationListMeta, +} + +#[derive(serde::Serialize)] +struct AutomationListMeta { + total: u64, +} + +fn default_true() -> bool { + true +} + +async fn list_automations(_auth: RequiredUser, State(state): State>) -> 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>, + body: Bytes, +) -> Response { + let request = match parse_json::(&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>, + Path(id): Path, +) -> 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>, + Path(id): Path, + 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::(&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>, + Path(id): Path, + 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::(&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>, + Path(id): Path, + 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>, + Path(id): Path, + ExtraQuery(pagination): ExtraQuery, +) -> 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::>(); + 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>, + Path(id): Path, + 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(body: &[u8]) -> Result { + serde_json::from_slice(body).map_err(|err| ApiError::bad_request(err.to_string())) +} + +fn parse_automation_id(value: &str) -> Result { + AutomationId::try_from(value.to_string()).map_err(|err| ApiError::bad_request(err.to_string())) +} + +fn parse_if_match(headers: &HeaderMap) -> Result { + 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 for AutomationTarget { + type Error = AutomationValidationError; + + fn try_from(value: RawAutomationTarget) -> Result { + Ok(Self { + repository: RepositorySlug::try_from(value.repository)?, + ref_: GitRefSelector::try_from(value.ref_)?, + workflow: WorkflowSlug::try_from(value.workflow)?, + }) + } +} + +impl TryFrom for AutomationTrigger { + type Error = AutomationValidationError; + + fn try_from(value: RawAutomationTrigger) -> Result { + 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 for AutomationDraft { + type Error = AutomationValidationError; + + fn try_from(value: RawCreateAutomationRequest) -> Result { + 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 for AutomationReplace { + type Error = AutomationValidationError; + + fn try_from(value: RawReplaceAutomationRequest) -> Result { + Ok(Self { + name: value.name, + description: value.description, + enabled: value.enabled, + target: value.target.try_into()?, + triggers: convert_triggers(value.triggers)?, + }) + } +} + +impl TryFrom for AutomationPatch { + type Error = AutomationValidationError; + + fn try_from(value: RawPatchAutomationRequest) -> Result { + 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, +) -> Result, AutomationValidationError> { + triggers.into_iter().map(TryInto::try_into).collect() +} diff --git a/lib/crates/fabro-server/src/server/handler/events.rs b/lib/crates/fabro-server/src/server/handler/events.rs index 8e7806522..8450f92b5 100644 --- a/lib/crates/fabro-server/src/server/handler/events.rs +++ b/lib/crates/fabro-server/src/server/handler/events.rs @@ -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, diff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/crates/fabro-server/src/server/handler/mod.rs index 2f07ff0bf..577935151 100644 --- a/lib/crates/fabro-server/src/server/handler/mod.rs +++ b/lib/crates/fabro-server/src/server/handler/mod.rs @@ -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> { .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()) diff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/crates/fabro-server/src/server/handler/pair.rs index e43f4e6f8..833f3f3e5 100644 --- a/lib/crates/fabro-server/src/server/handler/pair.rs +++ b/lib/crates/fabro-server/src/server/handler/pair.rs @@ -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, diff --git a/lib/crates/fabro-server/src/server/handler/runs.rs b/lib/crates/fabro-server/src/server/handler/runs.rs index c5e892379..15e4becc8 100644 --- a/lib/crates/fabro-server/src/server/handler/runs.rs +++ b/lib/crates/fabro-server/src/server/handler/runs.rs @@ -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, + pub(super) explicit_run_id: Option, + pub(super) explicit_title_supplied: bool, + pub(super) actor: Principal, + pub(super) headers: HeaderMap, + pub(super) automation: Option, +} + async fn create_run( RequiredRunManagementActor(actor): RequiredRunManagementActor, State(state): State>, headers: HeaderMap, body: Bytes, ) -> Response { - let req = match serde_json::from_slice::(&body) { - Ok(req) => req, + let manifest = match serde_json::from_slice::(&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, + 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), diff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs index f87184289..6f5bdca87 100644 --- a/lib/crates/fabro-server/src/server/handler/sessions.rs +++ b/lib/crates/fabro-server/src/server/handler/sessions.rs @@ -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"] diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs index 81513597d..d32b2df23 100644 --- a/lib/crates/fabro-server/src/server/tests.rs +++ b/lib/crates/fabro-server/src/server/tests.rs @@ -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, 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, diff --git a/lib/crates/fabro-server/src/test_support.rs b/lib/crates/fabro-server/src/test_support.rs index df65d247d..9b60bb26e 100644 --- a/lib/crates/fabro-server/src/test_support.rs +++ b/lib/crates/fabro-server/src/test_support.rs @@ -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, server_env_path: Option, active_config_path: Option, + automation_materializer: Option>, server_secret_env: HashMap, 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 { 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") diff --git a/lib/crates/fabro-server/tests/it/api/automations.rs b/lib/crates/fabro-server/tests/it/api/automations.rs new file mode 100644 index 000000000..2a937a68c --- /dev/null +++ b/lib/crates/fabro-server/tests/it/api/automations.rs @@ -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 { + 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); +} diff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs index 353b4ec95..a0207ccc7 100644 --- a/lib/crates/fabro-server/tests/it/api/mod.rs +++ b/lib/crates/fabro-server/tests/it/api/mod.rs @@ -1,4 +1,5 @@ mod auth_sessions; +mod automations; mod cli_auth_token; mod docs; mod events; diff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs index e820b00f9..10d163026 100644 --- a/lib/crates/fabro-server/tests/it/api/run_files.rs +++ b/lib/crates/fabro-server/tests/it/api/run_files.rs @@ -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, diff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs index 85052f8ce..4c7fb85ff 100644 --- a/lib/crates/fabro-store/src/run_state.rs +++ b/lib/crates/fabro-store/src/run_state.rs @@ -784,6 +784,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result { 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); diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs index 784868ace..d7ebbc3f1 100644 --- a/lib/crates/fabro-store/src/slate/mod.rs +++ b/lib/crates/fabro-store/src/slate/mod.rs @@ -552,6 +552,7 @@ mod tests { push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, }), fork_source_ref: None, + automation: None, } } diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs index 2707ca353..2967a20c0 100644 --- a/lib/crates/fabro-store/tests/serializable_projection.rs +++ b/lib/crates/fabro-store/tests/serializable_projection.rs @@ -32,6 +32,7 @@ fn sample_run_spec() -> RunSpec { push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, }), fork_source_ref: None, + automation: None, } } diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs index 269db43ee..4afa85532 100644 --- a/lib/crates/fabro-types/src/run.rs +++ b/lib/crates/fabro-types/src/run.rs @@ -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, #[serde(default, skip_serializing_if = "Option::is_none")] pub fork_source_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub automation: Option, } impl RunSpec { diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs index fa189171f..f37734783 100644 --- a/lib/crates/fabro-types/src/run_event/run.rs +++ b/lib/crates/fabro-types/src/run_event/run.rs @@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize}; use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel}; use crate::status::{BlockedReason, PendingReason, SuccessReason}; use crate::{ - DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId, RunControlAction, - RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings, + AutomationRef, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId, + RunControlAction, RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings, }; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -37,6 +37,8 @@ pub struct RunCreatedProps { #[serde(default, skip_serializing_if = "Option::is_none")] pub fork_source_ref: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub automation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub retried_from: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub parent_id: Option, diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs index 3bba6d43e..e174eaafb 100644 --- a/lib/crates/fabro-types/src/run_projection.rs +++ b/lib/crates/fabro-types/src/run_projection.rs @@ -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(), ) diff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs index fb5e7f576..a81f77099 100644 --- a/lib/crates/fabro-types/src/run_summary.rs +++ b/lib/crates/fabro-types/src/run_summary.rs @@ -104,9 +104,11 @@ pub struct WorkflowRef { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AutomationRef { - pub id: String, + pub id: String, #[serde(default)] - pub name: Option, + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs index 8f2df1972..1c1d20e6a 100644 --- a/lib/crates/fabro-types/tests/run_event_serde.rs +++ b/lib/crates/fabro-types/tests/run_event_serde.rs @@ -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, diff --git a/lib/crates/fabro-types/tests/run_spec_methods.rs b/lib/crates/fabro-types/tests/run_spec_methods.rs index f5e8cf25f..a8c9faf0c 100644 --- a/lib/crates/fabro-types/tests/run_spec_methods.rs +++ b/lib/crates/fabro-types/tests/run_spec_methods.rs @@ -40,6 +40,7 @@ fn sample_run_spec() -> RunSpec { }, }), fork_source_ref: None, + automation: None, } } diff --git a/lib/crates/fabro-types/tests/run_spec_serde.rs b/lib/crates/fabro-types/tests/run_spec_serde.rs index f6278ff34..15f85a12d 100644 --- a/lib/crates/fabro-types/tests/run_spec_serde.rs +++ b/lib/crates/fabro-types/tests/run_spec_serde.rs @@ -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"); diff --git a/lib/crates/fabro-workflow/src/billing_rollup.rs b/lib/crates/fabro-workflow/src/billing_rollup.rs index 0e909ce08..95dcfb1cc 100644 --- a/lib/crates/fabro-workflow/src/billing_rollup.rs +++ b/lib/crates/fabro-workflow/src/billing_rollup.rs @@ -377,6 +377,7 @@ mod tests { definition_blob: None, git: None, fork_source_ref: None, + automation: None, } } } diff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs index 2e2014dc1..4d4947af6 100644 --- a/lib/crates/fabro-workflow/src/event/convert.rs +++ b/lib/crates/fabro-workflow/src/event/convert.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs index a38b184bb..e53d4a1b6 100644 --- a/lib/crates/fabro-workflow/src/event/events.rs +++ b/lib/crates/fabro-workflow/src/event/events.rs @@ -1,12 +1,12 @@ use std::collections::BTreeMap; use ::fabro_types::{ - BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason, - ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget, - ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, RunBlobId, - RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, - RunRunnableSource, RunTiming, 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, #[serde(default, skip_serializing_if = "Option::is_none")] + automation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] retried_from: Option, #[serde(default, skip_serializing_if = "Option::is_none")] parent_id: Option, diff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs index 967f0570a..a84163171 100644 --- a/lib/crates/fabro-workflow/src/event/sink.rs +++ b/lib/crates/fabro-workflow/src/event/sink.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs index f48683dd0..4509e7e95 100644 --- a/lib/crates/fabro-workflow/src/git.rs +++ b/lib/crates/fabro-workflow/src/git.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs index 3290ca85a..3e124bca2 100644 --- a/lib/crates/fabro-workflow/src/handler/agent.rs +++ b/lib/crates/fabro-workflow/src/handler/agent.rs @@ -482,6 +482,7 @@ mod tests { manifest_blob: None, git: None, fork_source_ref: None, + automation: None, retried_from: None, parent_id: None, web_url: None, diff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs index 63922632a..437f87280 100644 --- a/lib/crates/fabro-workflow/src/handler/command.rs +++ b/lib/crates/fabro-workflow/src/handler/command.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs index 7c85042cc..da96e4d5e 100644 --- a/lib/crates/fabro-workflow/src/handler/parallel.rs +++ b/lib/crates/fabro-workflow/src/handler/parallel.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs index 27b9fa524..dcb7d2089 100644 --- a/lib/crates/fabro-workflow/src/handler/prompt.rs +++ b/lib/crates/fabro-workflow/src/handler/prompt.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs index bc34009d9..357d0e152 100644 --- a/lib/crates/fabro-workflow/src/lifecycle/git.rs +++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/crates/fabro-workflow/src/operations/archive.rs index bb3693398..10de7cb65 100644 --- a/lib/crates/fabro-workflow/src/operations/archive.rs +++ b/lib/crates/fabro-workflow/src/operations/archive.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs index 0460a1874..a97be23a0 100644 --- a/lib/crates/fabro-workflow/src/operations/create.rs +++ b/lib/crates/fabro-workflow/src/operations/create.rs @@ -13,7 +13,7 @@ use fabro_graphviz::graph::{AttrValue, Graph}; use fabro_model::{Catalog, ProviderId}; use fabro_store::Database; use fabro_types::{ - ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings, + AutomationRef, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings, }; use fabro_util::json::normalize_json_value; use tokio::task::spawn_blocking; @@ -43,6 +43,7 @@ pub struct CreateRunInput { pub title: Option, pub git: Option, pub fork_source_ref: Option, + pub automation: Option, pub parent_id: Option, pub provenance: Option, pub configured_providers: Vec, @@ -70,6 +71,7 @@ struct PersistCreateOptions { source_directory: Option, git: Option, fork_source_ref: Option, + automation: Option, provenance: Option, configured_providers: Vec, catalog: Arc, @@ -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, parent_id: Option, + automation: Option, web_url: Option, ) -> 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 { diff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs index 513975d71..4fba0fef2 100644 --- a/lib/crates/fabro-workflow/src/operations/fork.rs +++ b/lib/crates/fabro-workflow/src/operations/fork.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/crates/fabro-workflow/src/operations/retry.rs index 9a9d3c705..f35c80b62 100644 --- a/lib/crates/fabro-workflow/src/operations/retry.rs +++ b/lib/crates/fabro-workflow/src/operations/retry.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs index 66f1a5fc4..00a86b1ba 100644 --- a/lib/crates/fabro-workflow/src/operations/start.rs +++ b/lib/crates/fabro-workflow/src/operations/start.rs @@ -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(), diff --git a/lib/crates/fabro-workflow/src/operations/timeline.rs b/lib/crates/fabro-workflow/src/operations/timeline.rs index 2170dc28a..7d619b002 100644 --- a/lib/crates/fabro-workflow/src/operations/timeline.rs +++ b/lib/crates/fabro-workflow/src/operations/timeline.rs @@ -252,6 +252,7 @@ mod tests { definition_blob: None, git: None, fork_source_ref: None, + automation: None, }, Utc::now(), ) diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs index cef35cc39..365503af0 100644 --- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs index 53692daab..fcb26aa68 100644 --- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs @@ -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(), ) diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs index c620c0151..5243302a1 100644 --- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs +++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs @@ -867,6 +867,7 @@ mod tests { manifest_blob: None, definition_blob: None, fork_source_ref: None, + automation: None, }, ) } diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs index ee6150696..d24471789 100644 --- a/lib/crates/fabro-workflow/src/pipeline/persist.rs +++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs index 92f4a0bc3..3c8829cd4 100644 --- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs index 5d8cdeb3b..86b053e30 100644 --- a/lib/crates/fabro-workflow/src/run_lookup.rs +++ b/lib/crates/fabro-workflow/src/run_lookup.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/run_metadata.rs b/lib/crates/fabro-workflow/src/run_metadata.rs index 9d679d0b4..db79a017d 100644 --- a/lib/crates/fabro-workflow/src/run_metadata.rs +++ b/lib/crates/fabro-workflow/src/run_metadata.rs @@ -642,6 +642,7 @@ mod tests { manifest_blob: None, definition_blob: None, fork_source_ref: None, + automation: None, }, chrono::Utc::now(), ); diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs index 0f590c70f..cc3316e27 100644 --- a/lib/crates/fabro-workflow/src/runtime_store.rs +++ b/lib/crates/fabro-workflow/src/runtime_store.rs @@ -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, diff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs index 7db2e1ddd..3d039e425 100644 --- a/lib/crates/fabro-workflow/src/test_support.rs +++ b/lib/crates/fabro-workflow/src/test_support.rs @@ -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, diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index 5b231abf2..a1b0b7b47 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -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 diff --git a/lib/packages/fabro-api-client/src/api.ts b/lib/packages/fabro-api-client/src/api.ts index bda32eb1e..cd20e4134 100644 --- a/lib/packages/fabro-api-client/src/api.ts +++ b/lib/packages/fabro-api-client/src/api.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/api/automations-api.ts b/lib/packages/fabro-api-client/src/api/automations-api.ts new file mode 100644 index 000000000..bf8a1d210 --- /dev/null +++ b/lib/packages/fabro-api-client/src/api/automations-api.ts @@ -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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + // 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 => { + 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 => { + // 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 => { + // 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/lib/packages/fabro-api-client/src/api/runs-api.ts b/lib/packages/fabro-api-client/src/api/runs-api.ts index 9a074ce61..db69d48c9 100644 --- a/lib/packages/fabro-api-client/src/api/runs-api.ts +++ b/lib/packages/fabro-api-client/src/api/runs-api.ts @@ -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. diff --git a/lib/packages/fabro-api-client/src/models/automation-api-trigger.ts b/lib/packages/fabro-api-client/src/models/automation-api-trigger.ts new file mode 100644 index 000000000..350812119 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/automation-api-trigger.ts @@ -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]; diff --git a/lib/packages/fabro-api-client/src/models/automation-list-response-meta.ts b/lib/packages/fabro-api-client/src/models/automation-list-response-meta.ts new file mode 100644 index 000000000..268c75c2f --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/automation-list-response-meta.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/automation-list-response.ts b/lib/packages/fabro-api-client/src/models/automation-list-response.ts new file mode 100644 index 000000000..ff45920ff --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/automation-list-response.ts @@ -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; + 'meta': AutomationListResponseMeta; +} diff --git a/lib/packages/fabro-api-client/src/models/automation-ref.ts b/lib/packages/fabro-api-client/src/models/automation-ref.ts index 46465579c..91b779ca9 100644 --- a/lib/packages/fabro-api-client/src/models/automation-ref.ts +++ b/lib/packages/fabro-api-client/src/models/automation-ref.ts @@ -17,4 +17,5 @@ export interface AutomationRef { 'id': string; 'name': string | null; + 'trigger_id'?: string; } diff --git a/lib/packages/fabro-api-client/src/models/automation-schedule-trigger.ts b/lib/packages/fabro-api-client/src/models/automation-schedule-trigger.ts new file mode 100644 index 000000000..bdcf6418e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/automation-schedule-trigger.ts @@ -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]; diff --git a/lib/packages/fabro-api-client/src/models/automation-target.ts b/lib/packages/fabro-api-client/src/models/automation-target.ts new file mode 100644 index 000000000..03b0729ce --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/automation-target.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/automation-trigger.ts b/lib/packages/fabro-api-client/src/models/automation-trigger.ts new file mode 100644 index 000000000..4c1ce297e --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/automation-trigger.ts @@ -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; diff --git a/lib/packages/fabro-api-client/src/models/automation.ts b/lib/packages/fabro-api-client/src/models/automation.ts new file mode 100644 index 000000000..2b527b072 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/automation.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/create-automation-request.ts b/lib/packages/fabro-api-client/src/models/create-automation-request.ts new file mode 100644 index 000000000..7adc9b548 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/create-automation-request.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index f3b13b76e..4e6637343 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -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'; diff --git a/lib/packages/fabro-api-client/src/models/patch-automation-request.ts b/lib/packages/fabro-api-client/src/models/patch-automation-request.ts new file mode 100644 index 000000000..ffd902c68 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/patch-automation-request.ts @@ -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; +} diff --git a/lib/packages/fabro-api-client/src/models/replace-automation-request.ts b/lib/packages/fabro-api-client/src/models/replace-automation-request.ts new file mode 100644 index 000000000..59d2d9582 --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/replace-automation-request.ts @@ -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; +}