-
{automation.target.repository}
· {automation.target.ref}
@@ -165,13 +165,7 @@ function AutomationHeader({ automation }: { automation: Automation }) {
type="button"
onClick={onRun}
disabled={!canRun || running}
- title={
- !automation.enabled
- ? "Enable the automation to run it"
- : !apiTrigger?.enabled
- ? "Enable the API trigger to run it"
- : undefined
- }
+ title={canRun ? undefined : "Enable the API trigger to run it"}
className={PRIMARY_BUTTON_CLASS}
>
@@ -183,19 +177,6 @@ function AutomationHeader({ automation }: { automation: Automation }) {
);
}
-function StatusChip({ enabled }: { enabled: boolean }) {
- return (
-
-
-
- {enabled ? "Enabled" : "Disabled"}
-
-
- );
-}
-
function Chip({
icon: Icon,
children,
diff --git a/apps/fabro-web/app/routes/automations-edit.tsx b/apps/fabro-web/app/routes/automations-edit.tsx
index cb8e10324..e77a1e159 100644
--- a/apps/fabro-web/app/routes/automations-edit.tsx
+++ b/apps/fabro-web/app/routes/automations-edit.tsx
@@ -85,7 +85,6 @@ function EditAutomationForm({ automation }: { automation: Automation }) {
automationsApi.replaceAutomation(automation.id, automation.revision, {
name: trimmedName,
description: values.description.trim() || null,
- enabled: values.enabled,
target: {
repository: values.repository.trim(),
ref: values.ref.trim(),
diff --git a/apps/fabro-web/app/routes/automations-new.tsx b/apps/fabro-web/app/routes/automations-new.tsx
index dffc1465d..0c713da6b 100644
--- a/apps/fabro-web/app/routes/automations-new.tsx
+++ b/apps/fabro-web/app/routes/automations-new.tsx
@@ -108,7 +108,6 @@ function AutomationCreateForm({
id: values.id.trim(),
name: trimmedName,
description: values.description.trim() || null,
- enabled: values.enabled,
target: {
repository: values.repository.trim(),
ref: values.ref.trim(),
diff --git a/apps/fabro-web/app/routes/automations.tsx b/apps/fabro-web/app/routes/automations.tsx
index 7cb32b59f..dd388a690 100644
--- a/apps/fabro-web/app/routes/automations.tsx
+++ b/apps/fabro-web/app/routes/automations.tsx
@@ -18,6 +18,7 @@ import { FilterButton } from "../components/runs-list/filter-button";
import type { Automation, AutomationListResponse } from "@qltysh/fabro-api-client";
import { Link, useNavigate } from "react-router";
import { ApiError, apiData, automationsApi } from "../lib/api-client";
+import { findScheduleTrigger, hasEnabledApiTrigger } from "../lib/automation";
import { useAutomations } from "../lib/queries";
import { queryKeys } from "../lib/query-keys";
import { ConfirmDialog } from "../components/ui";
@@ -48,6 +49,7 @@ interface AutomationRow {
workflow: string;
repository: string;
schedule?: string;
+ apiEnabled: boolean;
icon: ComponentType<{ className?: string }>;
color: string;
}
@@ -76,11 +78,6 @@ const MENU_ITEM_CLASS =
const MENU_ITEM_DANGER_CLASS =
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-coral transition-colors data-focus:bg-coral/10 data-focus:text-coral data-focus:outline-hidden disabled:cursor-not-allowed disabled:opacity-60";
-function scheduleFor(automation: Automation): string | undefined {
- const schedule = automation.triggers.find((t) => t.type === "schedule");
- return schedule?.expression;
-}
-
function mapAutomations(result: AutomationListResponse | undefined): AutomationRow[] {
const automations = result?.data ?? [];
return automations.map((a) => ({
@@ -89,7 +86,8 @@ function mapAutomations(result: AutomationListResponse | undefined): AutomationR
name: a.name,
workflow: a.target.workflow,
repository: a.target.repository,
- schedule: scheduleFor(a),
+ schedule: findScheduleTrigger(a)?.expression,
+ apiEnabled: hasEnabledApiTrigger(a),
icon: slugIconMap[a.target.workflow] ?? CodeBracketIcon,
color: slugColorMap[a.target.workflow] ?? "var(--color-teal-500)",
}));
@@ -105,18 +103,19 @@ function PlayIcon({ className }: { className?: string }) {
function AutomationCard({
automation,
- disabled,
+ busy,
running,
onRun,
onDelete,
}: {
automation: AutomationRow;
- disabled: boolean;
+ busy: boolean;
running: boolean;
onRun: () => void;
onDelete: () => void;
}) {
const Icon = automation.icon;
+ const runDisabled = busy || !automation.apiEnabled;
return (
@@ -154,9 +153,15 @@ function AutomationCard({
)}
-
+
);
}
@@ -260,14 +265,15 @@ export default function Automations() {
}
}
+ const lowerQuery = query.toLowerCase();
const filtered = automations.filter(
(a) =>
(triggerFilter === "all" ||
(triggerFilter === "scheduled" && a.schedule != null) ||
(triggerFilter === "manual" && a.schedule == null)) &&
- (a.name.toLowerCase().includes(query.toLowerCase()) ||
- a.workflow.toLowerCase().includes(query.toLowerCase()) ||
- a.repository.toLowerCase().includes(query.toLowerCase())),
+ (a.name.toLowerCase().includes(lowerQuery) ||
+ a.workflow.toLowerCase().includes(lowerQuery) ||
+ a.repository.toLowerCase().includes(lowerQuery)),
);
async function confirmDelete() {
@@ -322,7 +328,7 @@ export default function Automations() {
runAutomation(automation)}
onDelete={() => setPendingDelete(automation)}
diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml
index cb2659bb5..d1b9f346e 100644
--- a/docs/public/api-reference/fabro-api.yaml
+++ b/docs/public/api-reference/fabro-api.yaml
@@ -4208,7 +4208,7 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
"409":
- description: Automation is disabled or has no enabled API trigger
+ description: Automation has no enabled API trigger
headers:
x-request-id:
$ref: "#/components/headers/XRequestId"
@@ -6119,7 +6119,6 @@ components:
- revision
- name
- description
- - enabled
- target
- triggers
properties:
@@ -6138,9 +6137,6 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
- enabled:
- type: boolean
- example: true
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
@@ -6248,9 +6244,6 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
- enabled:
- type: boolean
- default: true
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
@@ -6264,7 +6257,6 @@ components:
additionalProperties: false
required:
- name
- - enabled
- target
- triggers
properties:
@@ -6274,8 +6266,6 @@ components:
description:
type: ["string", "null"]
example: Keeps dependencies fresh.
- enabled:
- type: boolean
target:
$ref: "#/components/schemas/AutomationTarget"
triggers:
diff --git a/lib/crates/fabro-api/tests/automation_round_trip.rs b/lib/crates/fabro-api/tests/automation_round_trip.rs
index 9984fbb39..db4a66d03 100644
--- a/lib/crates/fabro-api/tests/automation_round_trip.rs
+++ b/lib/crates/fabro-api/tests/automation_round_trip.rs
@@ -26,7 +26,6 @@ fn automation_response_round_trips_public_json_shape() {
"revision": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
"name": "Nightly dependency update",
"description": null,
- "enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
@@ -57,7 +56,6 @@ fn create_automation_request_round_trips_public_json_shape() {
"id": "nightly-deps",
"name": "Nightly dependency update",
"description": "Keep dependencies fresh",
- "enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
@@ -81,7 +79,6 @@ fn replace_automation_request_round_trips_public_json_shape() {
let value = json!({
"name": "Nightly dependency update",
"description": "Keep dependencies fresh",
- "enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
diff --git a/lib/crates/fabro-automation/src/model.rs b/lib/crates/fabro-automation/src/model.rs
index 6e5cda0c1..90dc24eb7 100644
--- a/lib/crates/fabro-automation/src/model.rs
+++ b/lib/crates/fabro-automation/src/model.rs
@@ -15,7 +15,6 @@ pub struct Automation {
pub revision: AutomationRevision,
pub name: String,
pub description: Option,
- pub enabled: bool,
pub target: AutomationTarget,
pub triggers: Vec,
}
@@ -54,7 +53,6 @@ impl Automation {
PersistedAutomation {
name: self.name.clone(),
description: self.description.clone(),
- enabled: self.enabled,
target: self.target.clone(),
triggers: self.triggers.clone(),
}
@@ -64,14 +62,10 @@ impl Automation {
toml::to_string_pretty(&self.to_persisted()).map_err(AutomationStoreError::from)
}
- /// Returns the enabled API trigger if the automation itself is enabled and
- /// has one. Returns `None` when the automation is disabled or has no
- /// enabled API trigger.
+ /// Returns the enabled API trigger if the automation has one.
+ /// Returns `None` when the automation has no enabled API trigger.
#[must_use]
pub fn enabled_api_trigger(&self) -> Option<&ApiTrigger> {
- if !self.enabled {
- return None;
- }
self.triggers.iter().find_map(|trigger| match trigger {
AutomationTrigger::Api(trigger) if trigger.enabled => Some(trigger),
_ => None,
@@ -98,7 +92,6 @@ impl Automation {
revision,
name: replace.name,
description: replace.description,
- enabled: replace.enabled,
target: replace.target,
triggers: replace.triggers,
}
@@ -161,8 +154,6 @@ pub struct AutomationDraft {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option,
- #[serde(default = "default_true")]
- pub enabled: bool,
pub target: AutomationTarget,
pub triggers: Vec,
}
@@ -172,7 +163,6 @@ impl From for (AutomationId, AutomationReplace) {
(value.id, AutomationReplace {
name: value.name,
description: value.description,
- enabled: value.enabled,
target: value.target,
triggers: value.triggers,
})
@@ -185,7 +175,6 @@ pub struct AutomationReplace {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option,
- pub enabled: bool,
pub target: AutomationTarget,
pub triggers: Vec,
}
@@ -196,8 +185,6 @@ pub(crate) struct PersistedAutomation {
name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
description: Option,
- #[serde(default = "default_true")]
- enabled: bool,
target: AutomationTarget,
#[serde(default)]
triggers: Vec,
@@ -208,7 +195,6 @@ impl From for PersistedAutomation {
Self {
name: value.name,
description: value.description,
- enabled: value.enabled,
target: value.target,
triggers: value.triggers,
}
@@ -220,7 +206,6 @@ impl From for AutomationReplace {
Self {
name: value.name,
description: value.description,
- enabled: value.enabled,
target: value.target,
triggers: value.triggers,
}
@@ -390,10 +375,6 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
Ok(())
}
-fn default_true() -> bool {
- true
-}
-
#[cfg(test)]
mod tests {
use crate::{
@@ -450,30 +431,49 @@ expression = "0 0 * * *"
Automation::from_toml_bytes(AutomationId::new("nightly").unwrap(), bytes).unwrap();
assert_eq!(automation.description, None);
- assert!(automation.enabled);
assert!(automation.triggers.iter().all(AutomationTrigger::enabled));
let toml = automation.to_toml_string().unwrap();
assert!(!top_level_lines(&toml).any(|line| line.starts_with("id = ")));
assert!(!top_level_lines(&toml).any(|line| line.starts_with("revision = ")));
- assert!(toml.contains("enabled = true"));
+ assert!(!top_level_lines(&toml).any(|line| line.starts_with("enabled = ")));
assert!(toml.contains("type = \"api\""));
}
+ #[test]
+ fn persisted_toml_rejects_legacy_top_level_enabled() {
+ let bytes = br#"
+name = "Legacy"
+enabled = false
+
+[target]
+repository = "fabro-sh/fabro"
+ref = "main"
+workflow = "release"
+
+[[triggers]]
+type = "api"
+id = "manual"
+enabled = true
+"#;
+
+ let result = Automation::from_toml_bytes(AutomationId::new("legacy").unwrap(), bytes);
+
+ assert!(result.is_err());
+ }
+
#[test]
fn validation_rejects_invalid_inputs() {
let cases = [
AutomationReplace {
name: " ".to_string(),
description: None,
- enabled: true,
target: target(),
triggers: vec![api_trigger("manual")],
},
AutomationReplace {
name: "Bad repo".to_string(),
description: None,
- enabled: true,
target: AutomationTarget {
repository: "not/github/slug".to_string(),
ref_selector: "main".to_string(),
@@ -484,7 +484,6 @@ expression = "0 0 * * *"
AutomationReplace {
name: "Bad ref".to_string(),
description: None,
- enabled: true,
target: AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main;rm".to_string(),
@@ -495,7 +494,6 @@ expression = "0 0 * * *"
AutomationReplace {
name: "Bad workflow".to_string(),
description: None,
- enabled: true,
target: AutomationTarget {
repository: "fabro-sh/fabro".to_string(),
ref_selector: "main".to_string(),
@@ -506,7 +504,6 @@ expression = "0 0 * * *"
AutomationReplace {
name: "Duplicate trigger".to_string(),
description: None,
- enabled: true,
target: target(),
triggers: vec![
api_trigger("manual"),
@@ -516,21 +513,18 @@ expression = "0 0 * * *"
AutomationReplace {
name: "Two API triggers".to_string(),
description: None,
- enabled: true,
target: target(),
triggers: vec![api_trigger("one"), api_trigger("two")],
},
AutomationReplace {
name: "Six field cron".to_string(),
description: None,
- enabled: true,
target: target(),
triggers: vec![schedule_trigger("nightly", "0 0 0 * * *")],
},
AutomationReplace {
name: "Bad cron".to_string(),
description: None,
- enabled: true,
target: target(),
triggers: vec![schedule_trigger("nightly", "99 0 * * *")],
},
diff --git a/lib/crates/fabro-automation/src/store.rs b/lib/crates/fabro-automation/src/store.rs
index 6c0541441..ccd6ea7a4 100644
--- a/lib/crates/fabro-automation/src/store.rs
+++ b/lib/crates/fabro-automation/src/store.rs
@@ -290,7 +290,6 @@ mod tests {
id: AutomationId::new(id).unwrap(),
name: name.to_string(),
description: None,
- enabled: true,
target: target(),
triggers: vec![
AutomationTrigger::Api(ApiTrigger {
@@ -310,7 +309,6 @@ mod tests {
AutomationReplace {
name: name.to_string(),
description: Some("updated".to_string()),
- enabled: false,
target: target(),
triggers: vec![AutomationTrigger::Api(ApiTrigger {
id: AutomationTriggerId::new("manual").unwrap(),
diff --git a/lib/crates/fabro-server/src/server/handler/automations.rs b/lib/crates/fabro-server/src/server/handler/automations.rs
index 51e09a980..4f9a6cdab 100644
--- a/lib/crates/fabro-server/src/server/handler/automations.rs
+++ b/lib/crates/fabro-server/src/server/handler/automations.rs
@@ -132,7 +132,7 @@ async fn create_automation_run(
let Some(api_trigger) = automation.enabled_api_trigger() else {
return ApiError::with_code(
StatusCode::CONFLICT,
- "automation is disabled or has no enabled API trigger",
+ "automation has no enabled API trigger",
"automation_api_trigger_disabled",
)
.into_response();
diff --git a/lib/crates/fabro-server/tests/it/api/automations.rs b/lib/crates/fabro-server/tests/it/api/automations.rs
index f09f94f38..11ad21a28 100644
--- a/lib/crates/fabro-server/tests/it/api/automations.rs
+++ b/lib/crates/fabro-server/tests/it/api/automations.rs
@@ -19,7 +19,6 @@ fn automation_body(id: &str, name: &str) -> Value {
"id": id,
"name": name,
"description": "Runs on a schedule.",
- "enabled": true,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
@@ -45,7 +44,6 @@ fn replacement_body(name: &str) -> Value {
json!({
"name": name,
"description": null,
- "enabled": false,
"target": {
"repository": "fabro-sh/fabro",
"ref": "main",
@@ -289,6 +287,7 @@ async fn schedule_trigger_round_trips_through_create_list_get_and_toml() {
assert_persisted_schedule_trigger(&persisted, "0 3 * * *", true);
assert!(persisted.get("id").is_none());
assert!(persisted.get("revision").is_none());
+ assert!(persisted.get("enabled").is_none());
}
#[tokio::test]
@@ -692,21 +691,6 @@ async fn automations_routes_require_authenticated_user() {
.await;
}
-#[tokio::test]
-async fn disabled_automation_run_endpoint_returns_conflict_code() {
- let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
- let mut body = automation_body("nightly", "Nightly");
- body["enabled"] = json!(false);
- create_automation_with_body(&app, &body).await;
-
- let error = create_automation_run(&app, "nightly", StatusCode::CONFLICT).await;
-
- assert_eq!(
- error["errors"][0]["code"],
- "automation_api_trigger_disabled"
- );
-}
-
#[tokio::test]
async fn missing_automation_run_endpoint_returns_not_found() {
let (app, _temp_dir, _automation_dir) = automation_app_with_fake_materializer();
diff --git a/lib/packages/fabro-api-client/src/models/automation.ts b/lib/packages/fabro-api-client/src/models/automation.ts
index 575759e19..5c1c20bb1 100644
--- a/lib/packages/fabro-api-client/src/models/automation.ts
+++ b/lib/packages/fabro-api-client/src/models/automation.ts
@@ -31,7 +31,6 @@ export interface Automation {
'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
index 5f21f795b..ce99d34db 100644
--- a/lib/packages/fabro-api-client/src/models/create-automation-request.ts
+++ b/lib/packages/fabro-api-client/src/models/create-automation-request.ts
@@ -27,7 +27,6 @@ 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/replace-automation-request.ts b/lib/packages/fabro-api-client/src/models/replace-automation-request.ts
index 4edb9df6b..49a5533f4 100644
--- a/lib/packages/fabro-api-client/src/models/replace-automation-request.ts
+++ b/lib/packages/fabro-api-client/src/models/replace-automation-request.ts
@@ -26,7 +26,6 @@ import type { AutomationTrigger } from './automation-trigger';
export interface ReplaceAutomationRequest {
'name': string;
'description'?: string | null;
- 'enabled': boolean;
'target': AutomationTarget;
'triggers': Array;
}
From 17cae07e5a7afd5585921e5e6408c870ff0c628a Mon Sep 17 00:00:00 2001
From: "fabro-sh-0530[bot]"
<281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Date: Fri, 29 May 2026 22:19:47 -0400
Subject: [PATCH 4/5] Add scheduled automation executor with in-memory cron
planner (#457)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Automation schedule triggers now fire automatically at their UTC cron
times. A new long-lived server task (`spawn_automation_scheduler`) owns
an in-memory planner that tracks one cursor per enabled schedule trigger
and creates/starts a normal Fabro run whenever a cursor comes due —
reusing the same materialization and run-creation path as API-triggered
runs.
### Plan Summary
- **Planner** (`AutomationSchedulePlanner`) — pure in-memory, no
persistent state. Reconciles cursors from the live automation list on
every tick; advances each due cursor _before_ spawning the fire task so
a failed materialization never hot-loops.
- **Executor loop** (`spawn_automation_scheduler`) — wakes on the
nearest cursor due time (capped at 30 s), on automation store mutations
(via `Notify`), or on shutdown. Spawns one Tokio task per due trigger so
slow materializations don't block other triggers.
- **Run firing** (`fire_scheduled_automation_run`) — materializes, calls
`create_run_from_manifest` with `Principal::System { Engine }`, then
calls `queue_run_start`. Warnings on any failure; next attempt waits for
the next cron occurrence.
- **Wiring** — `automation_scheduler_notify: Notify` added to
`AppState`; `create_automation`, `replace_automation`, and
`delete_automation` handlers call `notify_automation_scheduler()` so the
planner reacts immediately to changes.
- **Visibility widening** — `handler/lifecycle.rs` (`queue_run_start`)
and `handler/mod.rs` (`lifecycle`) promoted from `pub(super)` to `pub(in
crate::server)` so the scheduler (a sibling of `handler`) can call the
same start path.
- **Shared cron parser** — `parse_schedule_expression` extracted to
`fabro-automation` and re-exported so both validation and the scheduler
use the same parser configuration (no seconds, no year).
### Key design decisions
| Decision | Rationale |
|---|---|
| Cursor advances before fire task spawns | Guarantees at-most-one
attempt per occurrence even if materialization panics |
| No backfill on startup | Matches locked spec; `next_occurrence(expr,
now)` always starts from the current time |
| `Principal::System { Engine }` for actor | Avoids adding new public
enum variants or OpenAPI surface |
| `automation_temp_root()` extracted to `AppState` | Removes duplicated
`Storage::new(…).scratch_dir().join("automations")` from the automations
handler |
| Tests use `run_due_schedules_once` helper | Drives the planner
directly with fixed `DateTime` values; no wall-clock sleeps in
tests |
### Fabro Details
Ran 9 stages in 60m 12s for $22.11
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 1s | – | 0 |
| preflight_compile | 2m 9s | – | 0 |
| preflight_lint | 2m 26s | – | 0 |
| implement | 20m 14s | $12.44 | 0 |
| simplify_opus | 12m 29s | $6.34 | 0 |
| simplify_gpt | 3m 53s | $2.79 | 0 |
| verify | 11m 5s | – | 0 |
| fixup | 6m 46s | $0.54 | 0 |
| **Total** | **60m 12s** | **$22.11** | **0** |
Ran ImplementPlan.fabro (11 nodes and 14
edges)
```dot
digraph ImplementPlan {
graph [
goal="Implement and simplify",
model_stylesheet="
* { model: claude-opus-4-7; }
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check -q --workspace 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan. Use red/green TDD.", model="gpt-55", reasoning_effort="xhigh"]
simplify_opus [label="Simplify (Opus)", prompt="@prompts/simplify.md"]
simplify_gpt [label="Simplify (GPT-55)", prompt="@prompts/simplify.md", model="gpt-55"]
verify [label="Verify", shape=parallelogram, script="git fetch origin main 2>&1 && git merge --no-edit --no-stat origin/main 2>&1 && cargo +nightly-2026-04-14 fmt --all 2>&1 && cargo dev docs refresh 2>&1 && cargo +nightly-2026-04-14 fmt --check --all 2>&1 && { command -v rg >/dev/null 2>&1 || { echo 'rg is required for verify'; exit 127; }; } && ! rg -n 'AuthMode::Disabled|RunAuthMethod|RunSubjectProvenance|\bActorRef\b|\bActorKind\b|AuthenticatedSubject|AuthenticatedService|AuthorizeRunScoped|AuthorizeRunBlob|AuthorizeStageArtifact|AuthorizeCommandLog|auth_method\s*==\s*\"disabled\"' lib/crates apps lib/packages docs/public/api-reference/fabro-api.yaml 2>&1 && cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings 2>&1 && cargo nextest run --workspace --status-level slow --profile ci 2>&1 && cargo dev docs check 2>&1 && bun install --frozen-lockfile 2>&1 && (cd apps/fabro-web && bun run typecheck) 2>&1 && (cd apps/fabro-web && bun run test) 2>&1 && (cd lib/packages/fabro-api-client && bun run typecheck) 2>&1 && cargo dev build -- -p fabro-cli --release 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all format, clippy, Rust test, docs, TypeScript typecheck/test, and build failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=succeeded"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=succeeded"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=succeeded"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify_opus -> simplify_gpt -> verify
verify -> exit [condition="outcome=succeeded"]
verify -> fixup
fixup -> verify
}
```
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro
---
Cargo.lock | 1 +
lib/crates/fabro-automation/src/lib.rs | 2 +-
lib/crates/fabro-automation/src/model.rs | 38 +-
lib/crates/fabro-server/Cargo.toml | 1 +
lib/crates/fabro-server/src/serve.rs | 3 +-
lib/crates/fabro-server/src/server.rs | 23 +
.../src/server/automation_scheduler.rs | 730 ++++++++++++++++++
.../src/server/handler/automations.rs | 9 +-
.../src/server/handler/lifecycle.rs | 2 +-
.../fabro-server/src/server/handler/mod.rs | 2 +-
10 files changed, 797 insertions(+), 14 deletions(-)
create mode 100644 lib/crates/fabro-server/src/server/automation_scheduler.rs
diff --git a/Cargo.lock b/Cargo.lock
index dc705e2cc..19852f701 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2367,6 +2367,7 @@ dependencies = [
"chrono",
"clap",
"cookie",
+ "croner",
"dirs",
"fabro-agent",
"fabro-api",
diff --git a/lib/crates/fabro-automation/src/lib.rs b/lib/crates/fabro-automation/src/lib.rs
index 482f4c3fd..4dd253cbc 100644
--- a/lib/crates/fabro-automation/src/lib.rs
+++ b/lib/crates/fabro-automation/src/lib.rs
@@ -7,6 +7,6 @@ pub use error::{AutomationStoreError, AutomationValidationError};
pub use id::{AutomationId, AutomationRevision, AutomationRevisionParseError, AutomationTriggerId};
pub use model::{
ApiTrigger, Automation, AutomationDraft, AutomationReplace, AutomationTarget,
- AutomationTrigger, ScheduleTrigger,
+ AutomationTrigger, ScheduleTrigger, parse_schedule_expression,
};
pub use store::AutomationStore;
diff --git a/lib/crates/fabro-automation/src/model.rs b/lib/crates/fabro-automation/src/model.rs
index 90dc24eb7..92015c166 100644
--- a/lib/crates/fabro-automation/src/model.rs
+++ b/lib/crates/fabro-automation/src/model.rs
@@ -1,5 +1,8 @@
use std::collections::HashSet;
+use std::sync::LazyLock;
+use croner::Cron;
+use croner::errors::CronError;
use croner::parser::{CronParser, Seconds, Year};
use serde::{Deserialize, Serialize};
@@ -8,6 +11,23 @@ use crate::{
AutomationValidationError,
};
+/// Shared cron parser used to validate and evaluate automation schedule trigger
+/// expressions. Schedule triggers use the same five-field UTC cron grammar as
+/// validation, so both sites must share configuration.
+static SCHEDULE_CRON_PARSER: LazyLock = LazyLock::new(|| {
+ CronParser::builder()
+ .seconds(Seconds::Disallowed)
+ .year(Year::Disallowed)
+ .build()
+});
+
+/// Parse an automation schedule trigger expression with the canonical
+/// configuration (no seconds, no year). Returned `Cron` instances can be cached
+/// and used to find next occurrences.
+pub fn parse_schedule_expression(expression: &str) -> Result {
+ SCHEDULE_CRON_PARSER.parse(expression)
+}
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Automation {
@@ -72,6 +92,18 @@ impl Automation {
})
}
+ /// Iterate the enabled schedule triggers. Yields nothing when the
+ /// automation itself is disabled.
+ pub fn enabled_schedule_triggers(&self) -> impl Iterator- {
+ let enabled = self.enabled;
+ self.triggers
+ .iter()
+ .filter_map(move |trigger| match trigger {
+ AutomationTrigger::Schedule(trigger) if enabled && trigger.enabled => Some(trigger),
+ _ => None,
+ })
+ }
+
fn from_persisted(
id: AutomationId,
revision: AutomationRevision,
@@ -337,10 +369,6 @@ fn has_lock_suffix(value: &str) -> bool {
fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationValidationError> {
let mut seen = HashSet::new();
let mut has_api_trigger = false;
- let cron_parser = CronParser::builder()
- .seconds(Seconds::Disallowed)
- .year(Year::Disallowed)
- .build();
for trigger in triggers {
let id = trigger.id().as_str();
@@ -361,7 +389,7 @@ fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationVal
expression: trigger.expression.clone(),
});
}
- cron_parser.parse(&trigger.expression).map_err(|source| {
+ parse_schedule_expression(&trigger.expression).map_err(|source| {
AutomationValidationError::InvalidCronExpression {
trigger_id: id.to_string(),
expression: trigger.expression.clone(),
diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml
index 184154cb0..b1a380874 100644
--- a/lib/crates/fabro-server/Cargo.toml
+++ b/lib/crates/fabro-server/Cargo.toml
@@ -52,6 +52,7 @@ fabro-http.workspace = true
fabro-redact.workspace = true
fabro-static.workspace = true
chrono.workspace = true
+croner.workspace = true
futures-util.workspace = true
axum = { workspace = true, features = ["ws"] }
axum-extra.workspace = true
diff --git a/lib/crates/fabro-server/src/serve.rs b/lib/crates/fabro-server/src/serve.rs
index d0be79118..1178b009b 100644
--- a/lib/crates/fabro-server/src/serve.rs
+++ b/lib/crates/fabro-server/src/serve.rs
@@ -36,7 +36,7 @@ use crate::github_webhooks::{TailscaleFunnelManager, WEBHOOK_ROUTE, WEBHOOK_SECR
use crate::server::{
AppState, AppStateConfig, ResolvedAppStateSettings, RouterOptions, build_app_state,
build_router_with_options, reconcile_incomplete_runs_on_startup, shutdown_active_workers,
- spawn_scheduler,
+ spawn_automation_scheduler, spawn_scheduler,
};
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
use crate::startup::{prepare_startup_vault, resolve_startup, validate_startup_configuration};
@@ -789,6 +789,7 @@ where
);
}
spawn_scheduler(Arc::clone(&state));
+ spawn_automation_scheduler(Arc::clone(&state));
let router = build_router_with_options(Arc::clone(&state), &auth_mode, RouterOptions {
web_enabled,
#[cfg(debug_assertions)]
diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs
index 46a62c864..7bff4968f 100644
--- a/lib/crates/fabro-server/src/server.rs
+++ b/lib/crates/fabro-server/src/server.rs
@@ -168,10 +168,12 @@ use crate::{
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
};
+mod automation_scheduler;
mod handler;
mod resource_sampler;
mod session_runtime;
+pub(crate) use automation_scheduler::spawn_automation_scheduler;
pub(crate) use handler::events::EventListParams;
#[cfg(test)]
pub(in crate::server) use handler::events::filtered_global_events;
@@ -1073,6 +1075,7 @@ pub struct AppState {
pub(crate) worker_control_bus: Arc,
pub(crate) worker_runtime: Arc,
scheduler_notify: Notify,
+ automation_scheduler_notify: Notify,
global_event_tx: broadcast::Sender,
/// Per-run coalescing registry for `GET /runs/{id}/files`. Concurrent
/// callers for the same run share one materialization; different runs
@@ -1136,6 +1139,16 @@ impl AppState {
.materialize(input)
.await
}
+
+ pub(crate) fn notify_automation_scheduler(&self) {
+ self.automation_scheduler_notify.notify_one();
+ }
+
+ pub(crate) fn automation_scheduler_notified(
+ &self,
+ ) -> impl std::future::Future