diff --git a/run.json b/run.json index b9bb8838e..e5c12950a 100644 --- a/run.json +++ b/run.json @@ -492,7 +492,7 @@ "kind": "running" }, "status_updated_at": "2026-05-24T21:17:45.770228Z", - "last_event_at": "2026-05-24T22:00:06.317679Z", + "last_event_at": "2026-05-24T22:01:17.479136Z", "pending_control": null, "checkpoints": [ { @@ -740,9 +740,9 @@ } }, { - "seq": 0, + "seq": 871, "checkpoint": { - "timestamp": "2026-05-24T22:00:06.325863Z", + "timestamp": "2026-05-24T22:00:10.534714Z", "current_node": "implement", "completed_nodes": [ "start", @@ -753,40 +753,41 @@ ], "node_retries": {}, "context_values": { - "internal.fidelity": "compact", - "thread.toolchain.current_node": "preflight_compile", - "internal.retry_count.preflight_lint": 0, + "current_node": "implement", "thread.preflight_lint.current_node": "implement", - "thread.preflight_compile.current_node": "preflight_lint", + "failure_class": "budget_exhausted", "internal.retry_count.toolchain": 0, - "graph.goal": "# Automations Backend API Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Build the backend data model and REST API for creating, editing, deleting, starting, and listing runs for Automations.\n\n**Architecture:** Automations are server-owned runnable bindings stored as one canonical TOML file per automation in `dirname(active_config_path)/automations/.toml`. The server loads those files into an in-memory store at startup, persists API mutations atomically, and attaches an automation reference to runs created through the automation API. Schedule triggers are stored and validated, but no cron scheduler or background trigger loop is added in this plan.\n\n**Tech Stack:** Rust, serde, toml, toml_edit, sha2, hex, croner for schedule validation only, Axum, OpenAPI/progenitor, existing Fabro run manifest and run creation pipeline.\n\n---\n\n## Locked Decisions\n\n- Backend only: do not add web UI routes/components and do not add CLI commands.\n- Storage root: `dirname(active_config_path)/automations`.\n- File layout: one automation per file, `automations/.toml`.\n- Canonical ID: the filename stem. The TOML file does not repeat `id`.\n- Automation ID format: `[a-z0-9][a-z0-9-]{0,62}`.\n- Trigger ID format: `[a-z0-9][a-z0-9_-]{0,62}`.\n- Trigger IDs are required, user-visible, editable, and unique within one automation.\n- Triggers are an array from v1.\n- The API trigger type is `api`, not `manual_api`. Trigger IDs remain user-visible and editable; examples use `id = \"api\"` but startability is based on `type = \"api\"`.\n- At most one trigger with `type = \"api\"` is allowed per automation.\n- Multiple `schedule` triggers are allowed.\n- Unknown trigger types, including future `event` shapes, return `422` in v1. Handlers must not let unknown trigger discriminators fail as JSON parse errors.\n- If an automation is disabled, or it has no enabled trigger with `type = \"api\"`, `POST /automations/{id}/runs` returns `409` and does not create a run.\n- API writes canonicalize TOML and may discard comments in automation files.\n- No runtime automation state store or derived automation status API is added in V1. Run history is available through `GET /automations/{id}/runs`; schedule expressions are validated but not evaluated for scheduling.\n\n## File Structure\n\nCreate:\n\n- `lib/crates/fabro-automation/Cargo.toml` - domain crate manifest.\n- `lib/crates/fabro-automation/src/lib.rs` - public exports.\n- `lib/crates/fabro-automation/src/error.rs` - validation and persistence errors.\n- `lib/crates/fabro-automation/src/id.rs` - `AutomationId` and `AutomationTriggerId`.\n- `lib/crates/fabro-automation/src/model.rs` - automation domain and serde/TOML model.\n- `lib/crates/fabro-automation/src/store.rs` - in-memory file-backed automation store.\n- `lib/crates/fabro-server/src/automation_materializer.rs` - GitHub target materialization and manifest building for automation runs.\n- `lib/crates/fabro-server/src/server/handler/automations.rs` - REST handlers and router.\n- `lib/crates/fabro-server/tests/it/api/automations.rs` - server API integration tests.\n- `lib/crates/fabro-server/tests/it/api/mod.rs` - wire the automations integration test module.\n\nModify:\n\n- `lib/crates/fabro-server/Cargo.toml` - add `fabro-automation`.\n- `lib/crates/fabro-api/Cargo.toml` - add `fabro-automation` so OpenAPI can reuse matching automation domain types.\n- `lib/crates/fabro-types/src/run_summary.rs` - extend `AutomationRef` with `trigger_id`.\n- `lib/crates/fabro-types/src/run.rs` - add `automation: Option` to `RunSpec`.\n- `lib/crates/fabro-types/src/run_event/run.rs` - add `automation: Option` to `RunCreatedProps`.\n- `lib/crates/fabro-workflow/src/operations/create.rs` - carry automation metadata through `CreateRunInput`, persistence options, `RunSpec`, and `run.created`.\n- `lib/crates/fabro-workflow/src/event/convert.rs` - preserve automation metadata in any legacy-to-current event conversion path that constructs `RunCreatedProps`.\n- `lib/crates/fabro-store/src/run_state.rs` - project `RunSpec.automation` into `Run.automation`.\n- `lib/crates/fabro-server/src/server.rs` - load the automation store into `AppState` and expose crate-private accessors.\n- `lib/crates/fabro-server/src/server/handler/mod.rs` - merge real automation routes.\n- `lib/crates/fabro-server/src/test_support.rs` - create temp automation storage by active config path and allow test-only materializer injection.\n- `docs/public/api-reference/fabro-api.yaml` - add automation paths and schemas.\n- `lib/crates/fabro-api/build.rs` - add replacement mappings only for domain types with identical wire shape.\n- `lib/crates/fabro-api/tests/*` - add JSON parity tests for reused automation types.\n- `lib/packages/fabro-api-client` - regenerate generated TypeScript client files only; do not import them from the web UI.\n\nDo not modify:\n\n- `apps/fabro-web/**`, except generated API package consumers are not touched.\n- CLI command modules.\n- Scheduler services or background run loops.\n\n## Public API Shape\n\nAdd these OpenAPI paths under `/api/v1`:\n\n```http\nGET /automations\nPOST /automations\nGET /automations/{id}\nPUT /automations/{id}\nPATCH /automations/{id}\nDELETE /automations/{id}\nGET /automations/{id}/runs\nPOST /automations/{id}/runs\n```\n\nUse this response model:\n\n```ts\ntype Automation = {\n id: string;\n revision: string;\n name: string;\n description: string | null;\n enabled: boolean;\n target: AutomationTarget;\n triggers: AutomationTrigger[];\n};\n\ntype AutomationTarget = {\n repository: string; // GitHub owner/repo\n ref: string;\n workflow: string;\n};\n\ntype AutomationTrigger =\n | { id: string; type: \"api\"; enabled: boolean }\n | { id: string; type: \"schedule\"; enabled: boolean; expression: string };\n\n```\n\nRequest models:\n\n```ts\ntype CreateAutomationRequest = {\n id: string;\n name: string;\n description?: string | null;\n enabled?: boolean;\n target: AutomationTarget;\n triggers: AutomationTrigger[];\n};\n\ntype ReplaceAutomationRequest = {\n name: string;\n description?: string | null;\n enabled: boolean;\n target: AutomationTarget;\n triggers: AutomationTrigger[];\n};\n\ntype PatchAutomationRequest = {\n name?: string;\n description?: string | null;\n enabled?: boolean;\n target?: AutomationTarget;\n triggers?: AutomationTrigger[];\n};\n```\n\n`GET /automations/{id}/runs` returns the existing paginated run list envelope:\n\n```json\n{\n \"data\": [],\n \"meta\": { \"has_more\": false, \"total\": 0 }\n}\n```\n\nIt accepts `page[limit]` and `page[offset]`, sorts newest first, filters by `Run.automation.id`, and returns `404` if the automation definition no longer exists.\n\n`POST /automations/{id}/runs` returns the existing `Run` response shape with `automation` populated:\n\n```json\n{\n \"automation\": {\n \"id\": \"nightly-deps\",\n \"name\": \"Nightly dependency update\",\n \"trigger_id\": \"api\"\n }\n}\n```\n\n## TOML Shape\n\nPersist this canonical TOML:\n\n```toml\nname = \"Nightly dependency update\"\ndescription = \"Open a PR for dependency updates.\"\nenabled = true\n\n[target]\nrepository = \"fabro-sh/fabro\"\nref = \"main\"\nworkflow = \"dependency-update\"\n\n[[triggers]]\nid = \"api\"\ntype = \"api\"\nenabled = false\n\n[[triggers]]\nid = \"nightly\"\ntype = \"schedule\"\nenabled = true\nexpression = \"0 3 * * *\"\n```\n\nDefaults:\n\n- `enabled` defaults to `true` when omitted in TOML or create requests.\n- `description` defaults to `null`.\n- Trigger `enabled` defaults to `true` when omitted in TOML or create requests.\n- `schedule.expression` must be a non-empty five-field cron expression accepted by `croner`.\n- `target.repository` must be a GitHub `owner/repo` slug using the existing server slug validation rules: owner max 39 chars, repo max 100 chars, no path traversal or separators inside either segment.\n- `target.ref` must be a non-empty branch, tag, or SHA selector and must not start with `-`, contain ASCII control characters, or contain shell/path traversal metacharacters that would make git argv ambiguous.\n- `target.workflow` is a Fabro workflow selector resolved inside the cloned repository with `WorkflowLocation::resolve`; it may be a workflow slug such as `dependency-update` or a relative workflow path, but absolute paths and `..` path traversal are invalid.\n\n## Task 1: Add Domain Crate And Model Tests\n\n**Files:**\n\n- Create: `lib/crates/fabro-automation/Cargo.toml`\n- Create: `lib/crates/fabro-automation/src/lib.rs`\n- Create: `lib/crates/fabro-automation/src/error.rs`\n- Create: `lib/crates/fabro-automation/src/id.rs`\n- Create: `lib/crates/fabro-automation/src/model.rs`\n\n- [ ] Read `docs/internal/testing-strategy.md` and `docs/internal/error-handling-strategy.md` before adding tests and error types.\n- [ ] Create the crate. Because the workspace uses `members = [\"lib/crates/*\"]`, no root workspace member edit is required.\n- [ ] Add dependencies in `lib/crates/fabro-automation/Cargo.toml`: `chrono`, `croner`, `hex`, `serde`, `sha2`, `thiserror`, `tokio`, `toml`, and `toml_edit`. Add dev-dependencies: `tempfile`.\n- [ ] Define `AutomationId` and `AutomationTriggerId` newtypes with `TryFrom`, `AsRef`, `Display`, `Serialize`, and `Deserialize`.\n- [ ] Define the domain model with this public shape:\n\n```rust\npub struct AutomationRevision(String);\n\npub struct RepositorySlug(String);\n\npub struct GitRefSelector(String);\n\npub struct WorkflowSlug(String);\n\npub struct Automation {\n pub id: AutomationId,\n pub revision: AutomationRevision,\n pub name: String,\n pub description: Option,\n pub enabled: bool,\n pub target: AutomationTarget,\n pub triggers: Vec,\n}\n\npub struct AutomationTarget {\n pub repository: RepositorySlug,\n pub ref_: GitRefSelector,\n pub workflow: WorkflowSlug,\n}\n\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum AutomationTrigger {\n Api(ApiTrigger),\n Schedule(ScheduleTrigger),\n}\n\npub struct ApiTrigger {\n pub id: AutomationTriggerId,\n pub enabled: bool,\n}\n\npub struct ScheduleTrigger {\n pub id: AutomationTriggerId,\n pub enabled: bool,\n pub expression: String,\n}\n\npub struct AutomationDraft {\n pub id: AutomationId,\n pub name: String,\n pub description: Option,\n pub enabled: Option,\n pub target: AutomationTarget,\n pub triggers: Vec,\n}\n\npub struct AutomationReplace {\n pub name: String,\n pub description: Option,\n pub enabled: bool,\n pub target: AutomationTarget,\n pub triggers: Vec,\n}\n\npub struct AutomationPatch {\n pub name: Option,\n pub description: Option>,\n pub enabled: Option,\n pub target: Option,\n pub triggers: Option>,\n}\n```\n\n- [ ] Use `#[serde(rename = \"ref\")]` for the Rust field `ref_`.\n- [ ] Keep `revision` out of the persisted TOML model; compute it from raw file bytes.\n- [ ] Reject empty names, invalid GitHub repository slugs, invalid refs, invalid workflow selectors, duplicate trigger IDs, and more than one trigger with `type = \"api\"`.\n- [ ] Add unit tests for valid TOML, defaults, invalid automation IDs, invalid trigger IDs, duplicate trigger IDs, two `api` triggers, invalid repository slug, and invalid schedule expression.\n- [ ] Run `cargo nextest run -p fabro-automation`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-automation\ngit commit -m \"feat: add automation domain model\"\n```\n\n## Task 2: Implement File-Backed Automation Store\n\n**Files:**\n\n- Create: `lib/crates/fabro-automation/src/store.rs`\n- Modify: `lib/crates/fabro-automation/src/lib.rs`\n\n- [ ] Implement `AutomationStore` as an in-memory map guarded by `tokio::sync::RwLock`.\n- [ ] Load files from a configured directory with this behavior:\n - Missing directory means an empty store.\n - Non-`.toml` files are ignored.\n - Invalid filenames fail load.\n - Invalid TOML or invalid automation data fails load.\n- [ ] Compute `AutomationRevision` as lowercase hex SHA-256 of the exact TOML bytes read from disk.\n- [ ] Expose these async methods:\n\n```rust\npub async fn load(dir: impl Into) -> Result;\npub async fn list(&self) -> Vec;\npub async fn get(&self, id: &AutomationId) -> Option;\npub async fn create(&self, draft: AutomationDraft) -> Result;\npub async fn replace(\n &self,\n id: &AutomationId,\n expected: &AutomationRevision,\n draft: AutomationReplace,\n) -> Result;\npub async fn patch(\n &self,\n id: &AutomationId,\n expected: &AutomationRevision,\n patch: AutomationPatch,\n) -> Result;\npub async fn delete(\n &self,\n id: &AutomationId,\n expected: &AutomationRevision,\n) -> Result<(), AutomationStoreError>;\n```\n\n- [ ] Make create/update writes atomic by serializing to canonical TOML, writing a temp file in the automation directory, flushing it, and renaming it over the final path.\n- [ ] Create the automation directory on first write.\n- [ ] Map store errors into precise variants: not found, already exists, missing revision, revision mismatch, validation, parse, and I/O.\n- [ ] Add tests using `tempfile` for empty load, create writes file, replace changes revision, patch keeps unchanged fields, stale revision fails, delete removes file, and startup fails on malformed TOML.\n- [ ] Run `cargo nextest run -p fabro-automation`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-automation\ngit commit -m \"feat: persist automations as TOML files\"\n```\n\n## Task 3: Carry Automation Metadata Through Runs\n\n**Files:**\n\n- Modify: `lib/crates/fabro-types/src/run_summary.rs`\n- Modify: `lib/crates/fabro-types/src/run.rs`\n- Modify: `lib/crates/fabro-types/src/run_event/run.rs`\n- Modify: `lib/crates/fabro-workflow/src/operations/create.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/convert.rs`\n- Modify: `lib/crates/fabro-store/src/run_state.rs`\n- Modify tests that construct `RunSpec` or `RunCreatedProps`\n\n- [ ] Extend `AutomationRef`:\n\n```rust\npub struct AutomationRef {\n pub id: String,\n #[serde(default)]\n pub name: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub trigger_id: Option,\n}\n```\n\n- [ ] Add `automation: Option` to `RunSpec` with `#[serde(default, skip_serializing_if = \"Option::is_none\")]`.\n- [ ] Add `automation: Option` to `RunCreatedProps` with the same serde behavior.\n- [ ] Add `automation: Option` to `fabro_workflow::operations::CreateRunInput`.\n- [ ] Thread the field through `PersistCreateOptions`, the `RunSpec` built in `persist_validated`, and the `Event::RunCreated` emitted in `persist_created_run`.\n- [ ] In `fabro-store/src/run_state.rs`, set `Run.automation` from `state.spec.automation.clone()` instead of always using `None`.\n- [ ] Preserve backward compatibility: old run specs and old `run.created` events without `automation` deserialize as `None`.\n- [ ] Update all test fixture constructors by setting `automation: None` unless the test specifically checks automation linkage.\n- [ ] Add a focused projection test proving `RunCreatedProps.automation` appears in cached `Run.automation`.\n- [ ] Run:\n\n```bash\ncargo nextest run -p fabro-types\ncargo nextest run -p fabro-workflow operations::create\ncargo nextest run -p fabro-store run_state\n```\n\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-types lib/crates/fabro-workflow lib/crates/fabro-store\ngit commit -m \"feat: associate runs with automations\"\n```\n\n## Task 4: Add OpenAPI Contract And Type Reuse\n\n**Files:**\n\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Modify: `lib/crates/fabro-api/Cargo.toml`\n- Modify: `lib/crates/fabro-api/build.rs`\n- Create: `lib/crates/fabro-api/tests/automation_round_trip.rs`\n\n- [ ] Add an `Automations` tag.\n- [ ] Add schemas for `Automation`, `AutomationTarget`, `AutomationTrigger`, `AutomationApiTrigger`, `AutomationScheduleTrigger`, `CreateAutomationRequest`, `ReplaceAutomationRequest`, `PatchAutomationRequest`, and `AutomationListResponse`.\n- [ ] Use OpenAPI discriminator `propertyName: type` for trigger variants.\n- [ ] Implement request-body parsing so unknown trigger discriminator values are reported as domain validation errors (`422`), not JSON parse errors (`400`). Use raw DTOs or custom deserialization before converting into `fabro-automation` domain types.\n- [ ] Reuse existing `Run` and paginated run envelope schemas for `POST /automations/{id}/runs` and `GET /automations/{id}/runs`.\n- [ ] Add response codes:\n - `200` for reads and replace/patch.\n - `201` for create automation and create run.\n - `204` for delete.\n - `400` for malformed JSON or invalid path syntax.\n - `404` for missing automation.\n - `409` for duplicate create, stale revision, disabled automation, or disabled/missing `api` trigger.\n - `422` for domain validation errors.\n - `428` for missing `If-Match` on `PUT`, `PATCH`, or `DELETE`.\n- [ ] Add `If-Match` header parameters for mutating path operations except `POST /automations`.\n- [ ] Add `ETag` response header on `GET /automations/{id}`, `PUT`, and `PATCH`.\n- [ ] Before adding generated duplicate Rust types, search for matching domain types. If `fabro-automation` serde shape matches a schema exactly, add a `with_replacement(...)` entry in `lib/crates/fabro-api/build.rs`.\n- [ ] Add JSON parity tests for every automation replacement type used by `fabro-api`.\n- [ ] Run `cargo build -p fabro-api`.\n- [ ] Commit:\n\n```bash\ngit add docs/public/api-reference/fabro-api.yaml lib/crates/fabro-api\ngit commit -m \"feat: define automations API contract\"\n```\n\n## Task 5: Wire Automation Store Into Server State\n\n**Files:**\n\n- Modify: `lib/crates/fabro-server/Cargo.toml`\n- Modify: `lib/crates/fabro-server/src/server.rs`\n- Modify: `lib/crates/fabro-server/src/test_support.rs`\n\n- [ ] Add `fabro-automation = { path = \"../fabro-automation\" }` to server dependencies.\n- [ ] Add `automation_store: Arc` to `AppState`.\n- [ ] In `build_app_state`, compute the automation directory as:\n\n```rust\nlet automation_dir = active_config_path\n .parent()\n .unwrap_or_else(|| std::path::Path::new(\".\"))\n .join(\"automations\");\n```\n\n- [ ] Load `AutomationStore::load(automation_dir)` before constructing `AppState`.\n- [ ] Fail server startup if an existing automation file is malformed.\n- [ ] Add `pub(crate) fn automation_store(&self) -> Arc`.\n- [ ] In test support, keep the existing temp `active_config_path` behavior so each test gets its own sibling `automations` directory.\n- [ ] Add a server unit test for empty automation store creation when no automation directory exists.\n- [ ] Run `cargo nextest run -p fabro-server automation_store`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-server\ngit commit -m \"feat: load automation store in server state\"\n```\n\n## Task 6: Add Automation CRUD Routes\n\n**Files:**\n\n- Create: `lib/crates/fabro-server/src/server/handler/automations.rs`\n- Modify: `lib/crates/fabro-server/src/server/handler/mod.rs`\n- Create: `lib/crates/fabro-server/tests/it/api/automations.rs`\n- Modify: `lib/crates/fabro-server/tests/it/api/mod.rs`\n\n- [ ] Read `docs/internal/logging-strategy.md` and `docs/internal/error-handling-strategy.md` before adding request errors or logs.\n- [ ] Implement `automations::routes()` and merge it into `handler::real_routes()`.\n- [ ] Use `RequiredUser` for CRUD routes.\n- [ ] Implement `GET /automations` by listing store entries, sorting by ID ascending, and returning `{ data, meta: { total } }`.\n- [ ] Implement `POST /automations` with `CreateAutomationRequest`; duplicate ID returns `409`.\n- [ ] Implement `GET /automations/{id}` with `ETag: \"\"`.\n- [ ] Implement `PUT /automations/{id}` with `ReplaceAutomationRequest` and required `If-Match`.\n- [ ] Implement `PATCH /automations/{id}` with `PatchAutomationRequest`, shallow patch semantics, and required `If-Match`.\n- [ ] Implement `DELETE /automations/{id}` with required `If-Match`.\n- [ ] Add a helper that parses a quoted or unquoted `If-Match` revision and rejects missing headers with `428`.\n- [ ] Map `AutomationStoreError` to `ApiError`:\n - not found to `404`\n - already exists to `409`\n - missing revision to `428`\n - revision mismatch to `409`\n - validation to `422`\n - parse/I/O to `500` except malformed request bodies, which stay `400`\n- [ ] Add route tests for empty list, create, duplicate create, get with ETag, replace, stale replace, missing `If-Match`, patch clearing description, delete, invalid trigger IDs, duplicate trigger IDs, second trigger with `type = \"api\"`, and invalid schedule expression.\n- [ ] Run `cargo nextest run -p fabro-server automations`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-server\ngit commit -m \"feat: add automation CRUD API\"\n```\n\n## Task 7: Add Automation Run Listing And API-Triggered Runs\n\n**Files:**\n\n- Create: `lib/crates/fabro-server/src/automation_materializer.rs`\n- Modify: `lib/crates/fabro-server/src/server.rs`\n- Modify: `lib/crates/fabro-server/src/server/handler/runs.rs`\n- Modify: `lib/crates/fabro-server/src/server/handler/automations.rs`\n- Modify: `lib/crates/fabro-server/src/test_support.rs`\n- Create: `lib/crates/fabro-server/tests/it/api/automations.rs`\n- Modify: `lib/crates/fabro-server/tests/it/api/mod.rs`\n\n- [ ] Extract the common run creation body from `handler/runs.rs::create_run` into a crate-private helper that accepts:\n\n```rust\nstruct CreateRunFromManifestRequest {\n manifest: fabro_api::types::RunManifest,\n submitted_manifest_bytes: Vec,\n explicit_run_id: Option,\n explicit_title_supplied: bool,\n actor: fabro_types::Principal,\n headers: axum::http::HeaderMap,\n automation: Option,\n}\n```\n\n- [ ] Keep `POST /runs` behavior unchanged by calling the helper with `automation: None`.\n- [ ] Define a crate-private materializer trait:\n\n```rust\npub(crate) struct AutomationRunMaterializeInput {\n pub automation_id: fabro_automation::AutomationId,\n pub target: fabro_automation::AutomationTarget,\n pub run_id: fabro_types::RunId,\n pub user_settings_path: std::path::PathBuf,\n pub temp_root: std::path::PathBuf,\n}\n\npub(crate) struct AutomationRunMaterialized {\n pub manifest: fabro_api::types::RunManifest,\n pub submitted_manifest_bytes: Vec,\n}\n\n#[derive(thiserror::Error, Debug)]\npub(crate) enum AutomationRunMaterializeError {\n #[error(\"invalid automation target: {0}\")]\n InvalidTarget(String),\n #[error(\"failed to clone automation repository: {0}\")]\n CloneFailed(String),\n #[error(\"failed to resolve automation workflow: {0}\")]\n WorkflowNotFound(String),\n #[error(\"failed to build run manifest: {0}\")]\n Manifest(String),\n}\n\n#[async_trait::async_trait]\npub(crate) trait AutomationRunMaterializer: Send + Sync {\n async fn materialize(\n &self,\n input: AutomationRunMaterializeInput,\n ) -> Result;\n}\n```\n\n- [ ] Use a production implementation that:\n - validates target repository as GitHub `owner/repo`\n - is constructed with the server GitHub credentials, GitHub API base URL, HTTP client, and cleanup policy needed for clone materialization\n - creates a per-run temp directory under `AutomationRunMaterializeInput.temp_root`\n - clones `https://github.com/{owner}/{repo}.git`\n - uses existing GitHub clone credential helpers when configured\n - checks out the configured `ref`\n - resolves the workflow selector using `fabro_config::project::WorkflowLocation::resolve`\n - builds a `RunManifest` with `fabro_manifest::build_run_manifest`\n - passes `user_settings_path: Some(state.active_config_path().to_path_buf())`\n- [ ] Use `tokio::process::Command` with argv values for git commands. Do not construct shell command strings. Set `GIT_TERMINAL_PROMPT=0` and explicit timeouts so private-repo credential failures cannot hang request handling.\n- [ ] Store only sanitized repository URLs in run metadata. Do not persist credentialed clone URLs.\n- [ ] Add test support injection for a fake `AutomationRunMaterializer` behind tests or the existing `test-support` feature.\n- [ ] Implement `GET /automations/{id}/runs`:\n - require the automation to exist\n - list cached runs from the store\n - filter by `run.automation.as_ref().is_some_and(|a| a.id == id)`\n - sort newest first\n - paginate with `page[limit]` and `page[offset]`\n - return the existing `{ data, meta }` list shape\n- [ ] Implement `POST /automations/{id}/runs`:\n - use `RequiredRunToolActor`\n - require automation `enabled == true`\n - find the enabled trigger with `type = \"api\"`\n - return `409` with API error code `automation_api_trigger_disabled` if not startable\n - materialize the run manifest\n - call the shared create-run helper with `AutomationRef { id, name, trigger_id: Some(api_trigger_id) }`\n - return `201` and the created `Run`\n- [ ] Add route tests using the fake materializer for disabled automation, disabled API trigger, successful run creation, persisted `Run.automation`, and associated run listing.\n- [ ] Add lower-level materializer tests for target URL construction, credential redaction, ref checkout command planning, and workflow path resolution using temp directories. Do not add a live GitHub test.\n- [ ] Run `cargo nextest run -p fabro-server automations`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-server\ngit commit -m \"feat: start runs from automations\"\n```\n\n## Task 8: Generate Clients And Final Verification\n\n**Files:**\n\n- Modify generated files under `lib/packages/fabro-api-client`\n- Modify generated Rust files under `lib/crates/fabro-api/src` if `cargo build -p fabro-api` updates them\n\n- [ ] Regenerate Rust API code:\n\n```bash\ncargo build -p fabro-api\n```\n\n- [ ] Regenerate the TypeScript API client:\n\n```bash\ncd lib/packages/fabro-api-client && bun run generate\n```\n\n- [ ] Confirm no web UI imports or CLI command modules changed:\n\n```bash\ngit diff -- apps/fabro-web lib/crates/fabro-cli\n```\n\nExpected: no application or CLI command changes caused by this plan.\n\n- [ ] Run focused tests:\n\n```bash\ncargo nextest run -p fabro-automation\ncargo nextest run -p fabro-api\ncargo nextest run -p fabro-server automations\ncargo nextest run -p fabro-server openapi_conformance\n```\n\n- [ ] Run broader checks:\n\n```bash\ncargo +nightly-2026-04-14 fmt --check --all\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\n```\n\n- [ ] If clippy or tests expose unrelated existing failures, record the exact failing command and failure summary in the implementation handoff.\n- [ ] Commit generated and verification fixes:\n\n```bash\ngit add docs/public/api-reference/fabro-api.yaml lib/crates lib/packages/fabro-api-client\ngit commit -m \"chore: regenerate automation API clients\"\n```\n\n## Acceptance Criteria\n\n- A server with no `automations/` directory starts and returns an empty automation list.\n- Creating an automation writes `dirname(active_config_path)/automations/.toml`.\n- Updating or deleting an automation requires `If-Match`.\n- Stale revisions are rejected.\n- Invalid automation and trigger shapes are rejected with `422`.\n- Disabling the `api` trigger makes the automation not startable through `POST /automations/{id}/runs`.\n- A successful API-triggered automation run returns a normal `Run` response with `automation.id`, `automation.name`, and `automation.trigger_id`.\n- `GET /automations/{id}/runs` returns runs linked to that automation.\n- No cron scheduler, web UI exposure, or CLI exposure is added.\n", - "internal.work_dir": "/home/daytona/workspace/fabro", - "internal.retry_count.implement": 0, - "graph.rankdir": "LR", - "thread.start.current_node": "toolchain", "internal.run_id": "01KSDXK5DJ61CFCK9YSDR8AETQ", "internal.retry_count.preflight_compile": 0, - "current_node": "implement", - "internal.thread_id": "preflight_lint", - "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "thread.toolchain.current_node": "preflight_compile", + "internal.retry_count.implement": 0, + "failure_signature": "implement|budget_exhausted|api_deterministic|openai|context_length", + "thread.preflight_compile.current_node": "preflight_lint", "outcome": "failed", - "internal.retry_count.start": 0, - "failure_class": "budget_exhausted", - "internal.node_visit_count": 1, "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", - "failure_signature": "implement|budget_exhausted|api_deterministic|openai|context_length" + "graph.model_stylesheet": "\n * { model: claude-opus-4-7; }\n ", + "internal.retry_count.preflight_lint": 0, + "internal.thread_id": "preflight_lint", + "internal.fidelity": "compact", + "internal.node_visit_count": 1, + "internal.retry_count.start": 0, + "graph.rankdir": "LR", + "internal.work_dir": "/home/daytona/workspace/fabro", + "graph.goal": "# Automations Backend API Implementation Plan\n\n> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.\n\n**Goal:** Build the backend data model and REST API for creating, editing, deleting, starting, and listing runs for Automations.\n\n**Architecture:** Automations are server-owned runnable bindings stored as one canonical TOML file per automation in `dirname(active_config_path)/automations/.toml`. The server loads those files into an in-memory store at startup, persists API mutations atomically, and attaches an automation reference to runs created through the automation API. Schedule triggers are stored and validated, but no cron scheduler or background trigger loop is added in this plan.\n\n**Tech Stack:** Rust, serde, toml, toml_edit, sha2, hex, croner for schedule validation only, Axum, OpenAPI/progenitor, existing Fabro run manifest and run creation pipeline.\n\n---\n\n## Locked Decisions\n\n- Backend only: do not add web UI routes/components and do not add CLI commands.\n- Storage root: `dirname(active_config_path)/automations`.\n- File layout: one automation per file, `automations/.toml`.\n- Canonical ID: the filename stem. The TOML file does not repeat `id`.\n- Automation ID format: `[a-z0-9][a-z0-9-]{0,62}`.\n- Trigger ID format: `[a-z0-9][a-z0-9_-]{0,62}`.\n- Trigger IDs are required, user-visible, editable, and unique within one automation.\n- Triggers are an array from v1.\n- The API trigger type is `api`, not `manual_api`. Trigger IDs remain user-visible and editable; examples use `id = \"api\"` but startability is based on `type = \"api\"`.\n- At most one trigger with `type = \"api\"` is allowed per automation.\n- Multiple `schedule` triggers are allowed.\n- Unknown trigger types, including future `event` shapes, return `422` in v1. Handlers must not let unknown trigger discriminators fail as JSON parse errors.\n- If an automation is disabled, or it has no enabled trigger with `type = \"api\"`, `POST /automations/{id}/runs` returns `409` and does not create a run.\n- API writes canonicalize TOML and may discard comments in automation files.\n- No runtime automation state store or derived automation status API is added in V1. Run history is available through `GET /automations/{id}/runs`; schedule expressions are validated but not evaluated for scheduling.\n\n## File Structure\n\nCreate:\n\n- `lib/crates/fabro-automation/Cargo.toml` - domain crate manifest.\n- `lib/crates/fabro-automation/src/lib.rs` - public exports.\n- `lib/crates/fabro-automation/src/error.rs` - validation and persistence errors.\n- `lib/crates/fabro-automation/src/id.rs` - `AutomationId` and `AutomationTriggerId`.\n- `lib/crates/fabro-automation/src/model.rs` - automation domain and serde/TOML model.\n- `lib/crates/fabro-automation/src/store.rs` - in-memory file-backed automation store.\n- `lib/crates/fabro-server/src/automation_materializer.rs` - GitHub target materialization and manifest building for automation runs.\n- `lib/crates/fabro-server/src/server/handler/automations.rs` - REST handlers and router.\n- `lib/crates/fabro-server/tests/it/api/automations.rs` - server API integration tests.\n- `lib/crates/fabro-server/tests/it/api/mod.rs` - wire the automations integration test module.\n\nModify:\n\n- `lib/crates/fabro-server/Cargo.toml` - add `fabro-automation`.\n- `lib/crates/fabro-api/Cargo.toml` - add `fabro-automation` so OpenAPI can reuse matching automation domain types.\n- `lib/crates/fabro-types/src/run_summary.rs` - extend `AutomationRef` with `trigger_id`.\n- `lib/crates/fabro-types/src/run.rs` - add `automation: Option` to `RunSpec`.\n- `lib/crates/fabro-types/src/run_event/run.rs` - add `automation: Option` to `RunCreatedProps`.\n- `lib/crates/fabro-workflow/src/operations/create.rs` - carry automation metadata through `CreateRunInput`, persistence options, `RunSpec`, and `run.created`.\n- `lib/crates/fabro-workflow/src/event/convert.rs` - preserve automation metadata in any legacy-to-current event conversion path that constructs `RunCreatedProps`.\n- `lib/crates/fabro-store/src/run_state.rs` - project `RunSpec.automation` into `Run.automation`.\n- `lib/crates/fabro-server/src/server.rs` - load the automation store into `AppState` and expose crate-private accessors.\n- `lib/crates/fabro-server/src/server/handler/mod.rs` - merge real automation routes.\n- `lib/crates/fabro-server/src/test_support.rs` - create temp automation storage by active config path and allow test-only materializer injection.\n- `docs/public/api-reference/fabro-api.yaml` - add automation paths and schemas.\n- `lib/crates/fabro-api/build.rs` - add replacement mappings only for domain types with identical wire shape.\n- `lib/crates/fabro-api/tests/*` - add JSON parity tests for reused automation types.\n- `lib/packages/fabro-api-client` - regenerate generated TypeScript client files only; do not import them from the web UI.\n\nDo not modify:\n\n- `apps/fabro-web/**`, except generated API package consumers are not touched.\n- CLI command modules.\n- Scheduler services or background run loops.\n\n## Public API Shape\n\nAdd these OpenAPI paths under `/api/v1`:\n\n```http\nGET /automations\nPOST /automations\nGET /automations/{id}\nPUT /automations/{id}\nPATCH /automations/{id}\nDELETE /automations/{id}\nGET /automations/{id}/runs\nPOST /automations/{id}/runs\n```\n\nUse this response model:\n\n```ts\ntype Automation = {\n id: string;\n revision: string;\n name: string;\n description: string | null;\n enabled: boolean;\n target: AutomationTarget;\n triggers: AutomationTrigger[];\n};\n\ntype AutomationTarget = {\n repository: string; // GitHub owner/repo\n ref: string;\n workflow: string;\n};\n\ntype AutomationTrigger =\n | { id: string; type: \"api\"; enabled: boolean }\n | { id: string; type: \"schedule\"; enabled: boolean; expression: string };\n\n```\n\nRequest models:\n\n```ts\ntype CreateAutomationRequest = {\n id: string;\n name: string;\n description?: string | null;\n enabled?: boolean;\n target: AutomationTarget;\n triggers: AutomationTrigger[];\n};\n\ntype ReplaceAutomationRequest = {\n name: string;\n description?: string | null;\n enabled: boolean;\n target: AutomationTarget;\n triggers: AutomationTrigger[];\n};\n\ntype PatchAutomationRequest = {\n name?: string;\n description?: string | null;\n enabled?: boolean;\n target?: AutomationTarget;\n triggers?: AutomationTrigger[];\n};\n```\n\n`GET /automations/{id}/runs` returns the existing paginated run list envelope:\n\n```json\n{\n \"data\": [],\n \"meta\": { \"has_more\": false, \"total\": 0 }\n}\n```\n\nIt accepts `page[limit]` and `page[offset]`, sorts newest first, filters by `Run.automation.id`, and returns `404` if the automation definition no longer exists.\n\n`POST /automations/{id}/runs` returns the existing `Run` response shape with `automation` populated:\n\n```json\n{\n \"automation\": {\n \"id\": \"nightly-deps\",\n \"name\": \"Nightly dependency update\",\n \"trigger_id\": \"api\"\n }\n}\n```\n\n## TOML Shape\n\nPersist this canonical TOML:\n\n```toml\nname = \"Nightly dependency update\"\ndescription = \"Open a PR for dependency updates.\"\nenabled = true\n\n[target]\nrepository = \"fabro-sh/fabro\"\nref = \"main\"\nworkflow = \"dependency-update\"\n\n[[triggers]]\nid = \"api\"\ntype = \"api\"\nenabled = false\n\n[[triggers]]\nid = \"nightly\"\ntype = \"schedule\"\nenabled = true\nexpression = \"0 3 * * *\"\n```\n\nDefaults:\n\n- `enabled` defaults to `true` when omitted in TOML or create requests.\n- `description` defaults to `null`.\n- Trigger `enabled` defaults to `true` when omitted in TOML or create requests.\n- `schedule.expression` must be a non-empty five-field cron expression accepted by `croner`.\n- `target.repository` must be a GitHub `owner/repo` slug using the existing server slug validation rules: owner max 39 chars, repo max 100 chars, no path traversal or separators inside either segment.\n- `target.ref` must be a non-empty branch, tag, or SHA selector and must not start with `-`, contain ASCII control characters, or contain shell/path traversal metacharacters that would make git argv ambiguous.\n- `target.workflow` is a Fabro workflow selector resolved inside the cloned repository with `WorkflowLocation::resolve`; it may be a workflow slug such as `dependency-update` or a relative workflow path, but absolute paths and `..` path traversal are invalid.\n\n## Task 1: Add Domain Crate And Model Tests\n\n**Files:**\n\n- Create: `lib/crates/fabro-automation/Cargo.toml`\n- Create: `lib/crates/fabro-automation/src/lib.rs`\n- Create: `lib/crates/fabro-automation/src/error.rs`\n- Create: `lib/crates/fabro-automation/src/id.rs`\n- Create: `lib/crates/fabro-automation/src/model.rs`\n\n- [ ] Read `docs/internal/testing-strategy.md` and `docs/internal/error-handling-strategy.md` before adding tests and error types.\n- [ ] Create the crate. Because the workspace uses `members = [\"lib/crates/*\"]`, no root workspace member edit is required.\n- [ ] Add dependencies in `lib/crates/fabro-automation/Cargo.toml`: `chrono`, `croner`, `hex`, `serde`, `sha2`, `thiserror`, `tokio`, `toml`, and `toml_edit`. Add dev-dependencies: `tempfile`.\n- [ ] Define `AutomationId` and `AutomationTriggerId` newtypes with `TryFrom`, `AsRef`, `Display`, `Serialize`, and `Deserialize`.\n- [ ] Define the domain model with this public shape:\n\n```rust\npub struct AutomationRevision(String);\n\npub struct RepositorySlug(String);\n\npub struct GitRefSelector(String);\n\npub struct WorkflowSlug(String);\n\npub struct Automation {\n pub id: AutomationId,\n pub revision: AutomationRevision,\n pub name: String,\n pub description: Option,\n pub enabled: bool,\n pub target: AutomationTarget,\n pub triggers: Vec,\n}\n\npub struct AutomationTarget {\n pub repository: RepositorySlug,\n pub ref_: GitRefSelector,\n pub workflow: WorkflowSlug,\n}\n\n#[serde(tag = \"type\", rename_all = \"snake_case\")]\npub enum AutomationTrigger {\n Api(ApiTrigger),\n Schedule(ScheduleTrigger),\n}\n\npub struct ApiTrigger {\n pub id: AutomationTriggerId,\n pub enabled: bool,\n}\n\npub struct ScheduleTrigger {\n pub id: AutomationTriggerId,\n pub enabled: bool,\n pub expression: String,\n}\n\npub struct AutomationDraft {\n pub id: AutomationId,\n pub name: String,\n pub description: Option,\n pub enabled: Option,\n pub target: AutomationTarget,\n pub triggers: Vec,\n}\n\npub struct AutomationReplace {\n pub name: String,\n pub description: Option,\n pub enabled: bool,\n pub target: AutomationTarget,\n pub triggers: Vec,\n}\n\npub struct AutomationPatch {\n pub name: Option,\n pub description: Option>,\n pub enabled: Option,\n pub target: Option,\n pub triggers: Option>,\n}\n```\n\n- [ ] Use `#[serde(rename = \"ref\")]` for the Rust field `ref_`.\n- [ ] Keep `revision` out of the persisted TOML model; compute it from raw file bytes.\n- [ ] Reject empty names, invalid GitHub repository slugs, invalid refs, invalid workflow selectors, duplicate trigger IDs, and more than one trigger with `type = \"api\"`.\n- [ ] Add unit tests for valid TOML, defaults, invalid automation IDs, invalid trigger IDs, duplicate trigger IDs, two `api` triggers, invalid repository slug, and invalid schedule expression.\n- [ ] Run `cargo nextest run -p fabro-automation`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-automation\ngit commit -m \"feat: add automation domain model\"\n```\n\n## Task 2: Implement File-Backed Automation Store\n\n**Files:**\n\n- Create: `lib/crates/fabro-automation/src/store.rs`\n- Modify: `lib/crates/fabro-automation/src/lib.rs`\n\n- [ ] Implement `AutomationStore` as an in-memory map guarded by `tokio::sync::RwLock`.\n- [ ] Load files from a configured directory with this behavior:\n - Missing directory means an empty store.\n - Non-`.toml` files are ignored.\n - Invalid filenames fail load.\n - Invalid TOML or invalid automation data fails load.\n- [ ] Compute `AutomationRevision` as lowercase hex SHA-256 of the exact TOML bytes read from disk.\n- [ ] Expose these async methods:\n\n```rust\npub async fn load(dir: impl Into) -> Result;\npub async fn list(&self) -> Vec;\npub async fn get(&self, id: &AutomationId) -> Option;\npub async fn create(&self, draft: AutomationDraft) -> Result;\npub async fn replace(\n &self,\n id: &AutomationId,\n expected: &AutomationRevision,\n draft: AutomationReplace,\n) -> Result;\npub async fn patch(\n &self,\n id: &AutomationId,\n expected: &AutomationRevision,\n patch: AutomationPatch,\n) -> Result;\npub async fn delete(\n &self,\n id: &AutomationId,\n expected: &AutomationRevision,\n) -> Result<(), AutomationStoreError>;\n```\n\n- [ ] Make create/update writes atomic by serializing to canonical TOML, writing a temp file in the automation directory, flushing it, and renaming it over the final path.\n- [ ] Create the automation directory on first write.\n- [ ] Map store errors into precise variants: not found, already exists, missing revision, revision mismatch, validation, parse, and I/O.\n- [ ] Add tests using `tempfile` for empty load, create writes file, replace changes revision, patch keeps unchanged fields, stale revision fails, delete removes file, and startup fails on malformed TOML.\n- [ ] Run `cargo nextest run -p fabro-automation`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-automation\ngit commit -m \"feat: persist automations as TOML files\"\n```\n\n## Task 3: Carry Automation Metadata Through Runs\n\n**Files:**\n\n- Modify: `lib/crates/fabro-types/src/run_summary.rs`\n- Modify: `lib/crates/fabro-types/src/run.rs`\n- Modify: `lib/crates/fabro-types/src/run_event/run.rs`\n- Modify: `lib/crates/fabro-workflow/src/operations/create.rs`\n- Modify: `lib/crates/fabro-workflow/src/event/convert.rs`\n- Modify: `lib/crates/fabro-store/src/run_state.rs`\n- Modify tests that construct `RunSpec` or `RunCreatedProps`\n\n- [ ] Extend `AutomationRef`:\n\n```rust\npub struct AutomationRef {\n pub id: String,\n #[serde(default)]\n pub name: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub trigger_id: Option,\n}\n```\n\n- [ ] Add `automation: Option` to `RunSpec` with `#[serde(default, skip_serializing_if = \"Option::is_none\")]`.\n- [ ] Add `automation: Option` to `RunCreatedProps` with the same serde behavior.\n- [ ] Add `automation: Option` to `fabro_workflow::operations::CreateRunInput`.\n- [ ] Thread the field through `PersistCreateOptions`, the `RunSpec` built in `persist_validated`, and the `Event::RunCreated` emitted in `persist_created_run`.\n- [ ] In `fabro-store/src/run_state.rs`, set `Run.automation` from `state.spec.automation.clone()` instead of always using `None`.\n- [ ] Preserve backward compatibility: old run specs and old `run.created` events without `automation` deserialize as `None`.\n- [ ] Update all test fixture constructors by setting `automation: None` unless the test specifically checks automation linkage.\n- [ ] Add a focused projection test proving `RunCreatedProps.automation` appears in cached `Run.automation`.\n- [ ] Run:\n\n```bash\ncargo nextest run -p fabro-types\ncargo nextest run -p fabro-workflow operations::create\ncargo nextest run -p fabro-store run_state\n```\n\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-types lib/crates/fabro-workflow lib/crates/fabro-store\ngit commit -m \"feat: associate runs with automations\"\n```\n\n## Task 4: Add OpenAPI Contract And Type Reuse\n\n**Files:**\n\n- Modify: `docs/public/api-reference/fabro-api.yaml`\n- Modify: `lib/crates/fabro-api/Cargo.toml`\n- Modify: `lib/crates/fabro-api/build.rs`\n- Create: `lib/crates/fabro-api/tests/automation_round_trip.rs`\n\n- [ ] Add an `Automations` tag.\n- [ ] Add schemas for `Automation`, `AutomationTarget`, `AutomationTrigger`, `AutomationApiTrigger`, `AutomationScheduleTrigger`, `CreateAutomationRequest`, `ReplaceAutomationRequest`, `PatchAutomationRequest`, and `AutomationListResponse`.\n- [ ] Use OpenAPI discriminator `propertyName: type` for trigger variants.\n- [ ] Implement request-body parsing so unknown trigger discriminator values are reported as domain validation errors (`422`), not JSON parse errors (`400`). Use raw DTOs or custom deserialization before converting into `fabro-automation` domain types.\n- [ ] Reuse existing `Run` and paginated run envelope schemas for `POST /automations/{id}/runs` and `GET /automations/{id}/runs`.\n- [ ] Add response codes:\n - `200` for reads and replace/patch.\n - `201` for create automation and create run.\n - `204` for delete.\n - `400` for malformed JSON or invalid path syntax.\n - `404` for missing automation.\n - `409` for duplicate create, stale revision, disabled automation, or disabled/missing `api` trigger.\n - `422` for domain validation errors.\n - `428` for missing `If-Match` on `PUT`, `PATCH`, or `DELETE`.\n- [ ] Add `If-Match` header parameters for mutating path operations except `POST /automations`.\n- [ ] Add `ETag` response header on `GET /automations/{id}`, `PUT`, and `PATCH`.\n- [ ] Before adding generated duplicate Rust types, search for matching domain types. If `fabro-automation` serde shape matches a schema exactly, add a `with_replacement(...)` entry in `lib/crates/fabro-api/build.rs`.\n- [ ] Add JSON parity tests for every automation replacement type used by `fabro-api`.\n- [ ] Run `cargo build -p fabro-api`.\n- [ ] Commit:\n\n```bash\ngit add docs/public/api-reference/fabro-api.yaml lib/crates/fabro-api\ngit commit -m \"feat: define automations API contract\"\n```\n\n## Task 5: Wire Automation Store Into Server State\n\n**Files:**\n\n- Modify: `lib/crates/fabro-server/Cargo.toml`\n- Modify: `lib/crates/fabro-server/src/server.rs`\n- Modify: `lib/crates/fabro-server/src/test_support.rs`\n\n- [ ] Add `fabro-automation = { path = \"../fabro-automation\" }` to server dependencies.\n- [ ] Add `automation_store: Arc` to `AppState`.\n- [ ] In `build_app_state`, compute the automation directory as:\n\n```rust\nlet automation_dir = active_config_path\n .parent()\n .unwrap_or_else(|| std::path::Path::new(\".\"))\n .join(\"automations\");\n```\n\n- [ ] Load `AutomationStore::load(automation_dir)` before constructing `AppState`.\n- [ ] Fail server startup if an existing automation file is malformed.\n- [ ] Add `pub(crate) fn automation_store(&self) -> Arc`.\n- [ ] In test support, keep the existing temp `active_config_path` behavior so each test gets its own sibling `automations` directory.\n- [ ] Add a server unit test for empty automation store creation when no automation directory exists.\n- [ ] Run `cargo nextest run -p fabro-server automation_store`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-server\ngit commit -m \"feat: load automation store in server state\"\n```\n\n## Task 6: Add Automation CRUD Routes\n\n**Files:**\n\n- Create: `lib/crates/fabro-server/src/server/handler/automations.rs`\n- Modify: `lib/crates/fabro-server/src/server/handler/mod.rs`\n- Create: `lib/crates/fabro-server/tests/it/api/automations.rs`\n- Modify: `lib/crates/fabro-server/tests/it/api/mod.rs`\n\n- [ ] Read `docs/internal/logging-strategy.md` and `docs/internal/error-handling-strategy.md` before adding request errors or logs.\n- [ ] Implement `automations::routes()` and merge it into `handler::real_routes()`.\n- [ ] Use `RequiredUser` for CRUD routes.\n- [ ] Implement `GET /automations` by listing store entries, sorting by ID ascending, and returning `{ data, meta: { total } }`.\n- [ ] Implement `POST /automations` with `CreateAutomationRequest`; duplicate ID returns `409`.\n- [ ] Implement `GET /automations/{id}` with `ETag: \"\"`.\n- [ ] Implement `PUT /automations/{id}` with `ReplaceAutomationRequest` and required `If-Match`.\n- [ ] Implement `PATCH /automations/{id}` with `PatchAutomationRequest`, shallow patch semantics, and required `If-Match`.\n- [ ] Implement `DELETE /automations/{id}` with required `If-Match`.\n- [ ] Add a helper that parses a quoted or unquoted `If-Match` revision and rejects missing headers with `428`.\n- [ ] Map `AutomationStoreError` to `ApiError`:\n - not found to `404`\n - already exists to `409`\n - missing revision to `428`\n - revision mismatch to `409`\n - validation to `422`\n - parse/I/O to `500` except malformed request bodies, which stay `400`\n- [ ] Add route tests for empty list, create, duplicate create, get with ETag, replace, stale replace, missing `If-Match`, patch clearing description, delete, invalid trigger IDs, duplicate trigger IDs, second trigger with `type = \"api\"`, and invalid schedule expression.\n- [ ] Run `cargo nextest run -p fabro-server automations`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-server\ngit commit -m \"feat: add automation CRUD API\"\n```\n\n## Task 7: Add Automation Run Listing And API-Triggered Runs\n\n**Files:**\n\n- Create: `lib/crates/fabro-server/src/automation_materializer.rs`\n- Modify: `lib/crates/fabro-server/src/server.rs`\n- Modify: `lib/crates/fabro-server/src/server/handler/runs.rs`\n- Modify: `lib/crates/fabro-server/src/server/handler/automations.rs`\n- Modify: `lib/crates/fabro-server/src/test_support.rs`\n- Create: `lib/crates/fabro-server/tests/it/api/automations.rs`\n- Modify: `lib/crates/fabro-server/tests/it/api/mod.rs`\n\n- [ ] Extract the common run creation body from `handler/runs.rs::create_run` into a crate-private helper that accepts:\n\n```rust\nstruct CreateRunFromManifestRequest {\n manifest: fabro_api::types::RunManifest,\n submitted_manifest_bytes: Vec,\n explicit_run_id: Option,\n explicit_title_supplied: bool,\n actor: fabro_types::Principal,\n headers: axum::http::HeaderMap,\n automation: Option,\n}\n```\n\n- [ ] Keep `POST /runs` behavior unchanged by calling the helper with `automation: None`.\n- [ ] Define a crate-private materializer trait:\n\n```rust\npub(crate) struct AutomationRunMaterializeInput {\n pub automation_id: fabro_automation::AutomationId,\n pub target: fabro_automation::AutomationTarget,\n pub run_id: fabro_types::RunId,\n pub user_settings_path: std::path::PathBuf,\n pub temp_root: std::path::PathBuf,\n}\n\npub(crate) struct AutomationRunMaterialized {\n pub manifest: fabro_api::types::RunManifest,\n pub submitted_manifest_bytes: Vec,\n}\n\n#[derive(thiserror::Error, Debug)]\npub(crate) enum AutomationRunMaterializeError {\n #[error(\"invalid automation target: {0}\")]\n InvalidTarget(String),\n #[error(\"failed to clone automation repository: {0}\")]\n CloneFailed(String),\n #[error(\"failed to resolve automation workflow: {0}\")]\n WorkflowNotFound(String),\n #[error(\"failed to build run manifest: {0}\")]\n Manifest(String),\n}\n\n#[async_trait::async_trait]\npub(crate) trait AutomationRunMaterializer: Send + Sync {\n async fn materialize(\n &self,\n input: AutomationRunMaterializeInput,\n ) -> Result;\n}\n```\n\n- [ ] Use a production implementation that:\n - validates target repository as GitHub `owner/repo`\n - is constructed with the server GitHub credentials, GitHub API base URL, HTTP client, and cleanup policy needed for clone materialization\n - creates a per-run temp directory under `AutomationRunMaterializeInput.temp_root`\n - clones `https://github.com/{owner}/{repo}.git`\n - uses existing GitHub clone credential helpers when configured\n - checks out the configured `ref`\n - resolves the workflow selector using `fabro_config::project::WorkflowLocation::resolve`\n - builds a `RunManifest` with `fabro_manifest::build_run_manifest`\n - passes `user_settings_path: Some(state.active_config_path().to_path_buf())`\n- [ ] Use `tokio::process::Command` with argv values for git commands. Do not construct shell command strings. Set `GIT_TERMINAL_PROMPT=0` and explicit timeouts so private-repo credential failures cannot hang request handling.\n- [ ] Store only sanitized repository URLs in run metadata. Do not persist credentialed clone URLs.\n- [ ] Add test support injection for a fake `AutomationRunMaterializer` behind tests or the existing `test-support` feature.\n- [ ] Implement `GET /automations/{id}/runs`:\n - require the automation to exist\n - list cached runs from the store\n - filter by `run.automation.as_ref().is_some_and(|a| a.id == id)`\n - sort newest first\n - paginate with `page[limit]` and `page[offset]`\n - return the existing `{ data, meta }` list shape\n- [ ] Implement `POST /automations/{id}/runs`:\n - use `RequiredRunToolActor`\n - require automation `enabled == true`\n - find the enabled trigger with `type = \"api\"`\n - return `409` with API error code `automation_api_trigger_disabled` if not startable\n - materialize the run manifest\n - call the shared create-run helper with `AutomationRef { id, name, trigger_id: Some(api_trigger_id) }`\n - return `201` and the created `Run`\n- [ ] Add route tests using the fake materializer for disabled automation, disabled API trigger, successful run creation, persisted `Run.automation`, and associated run listing.\n- [ ] Add lower-level materializer tests for target URL construction, credential redaction, ref checkout command planning, and workflow path resolution using temp directories. Do not add a live GitHub test.\n- [ ] Run `cargo nextest run -p fabro-server automations`.\n- [ ] Commit:\n\n```bash\ngit add lib/crates/fabro-server\ngit commit -m \"feat: start runs from automations\"\n```\n\n## Task 8: Generate Clients And Final Verification\n\n**Files:**\n\n- Modify generated files under `lib/packages/fabro-api-client`\n- Modify generated Rust files under `lib/crates/fabro-api/src` if `cargo build -p fabro-api` updates them\n\n- [ ] Regenerate Rust API code:\n\n```bash\ncargo build -p fabro-api\n```\n\n- [ ] Regenerate the TypeScript API client:\n\n```bash\ncd lib/packages/fabro-api-client && bun run generate\n```\n\n- [ ] Confirm no web UI imports or CLI command modules changed:\n\n```bash\ngit diff -- apps/fabro-web lib/crates/fabro-cli\n```\n\nExpected: no application or CLI command changes caused by this plan.\n\n- [ ] Run focused tests:\n\n```bash\ncargo nextest run -p fabro-automation\ncargo nextest run -p fabro-api\ncargo nextest run -p fabro-server automations\ncargo nextest run -p fabro-server openapi_conformance\n```\n\n- [ ] Run broader checks:\n\n```bash\ncargo +nightly-2026-04-14 fmt --check --all\ncargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings\n```\n\n- [ ] If clippy or tests expose unrelated existing failures, record the exact failing command and failure summary in the implementation handoff.\n- [ ] Commit generated and verification fixes:\n\n```bash\ngit add docs/public/api-reference/fabro-api.yaml lib/crates lib/packages/fabro-api-client\ngit commit -m \"chore: regenerate automation API clients\"\n```\n\n## Acceptance Criteria\n\n- A server with no `automations/` directory starts and returns an empty automation list.\n- Creating an automation writes `dirname(active_config_path)/automations/.toml`.\n- Updating or deleting an automation requires `If-Match`.\n- Stale revisions are rejected.\n- Invalid automation and trigger shapes are rejected with `422`.\n- Disabling the `api` trigger makes the automation not startable through `POST /automations/{id}/runs`.\n- A successful API-triggered automation run returns a normal `Run` response with `automation.id`, `automation.name`, and `automation.trigger_id`.\n- `GET /automations/{id}/runs` returns runs linked to that automation.\n- No cron scheduler, web UI exposure, or CLI exposure is added.\n", + "thread.start.current_node": "toolchain" }, "node_outcomes": { "start": { "status": "succeeded", "usage": null }, - "preflight_lint": { - "status": "succeeded", - "context_updates": { - "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" + "implement": { + "status": "failed", + "failure": { + "message": "LLM error: Context length exceeded for openai: Your input exceeds the context window of this model. Please adjust your input and try again.", + "category": "budget_exhausted", + "signature": "api_deterministic|openai|context_length" }, - "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", "usage": null }, "preflight_compile": { @@ -797,13 +798,12 @@ "notes": "Script completed: cargo check -q --workspace 2>&1", "usage": null }, - "implement": { - "status": "failed", - "failure": { - "message": "LLM error: Context length exceeded for openai: Your input exceeds the context window of this model. Please adjust your input and try again.", - "category": "budget_exhausted", - "signature": "api_deterministic|openai|context_length" + "preflight_lint": { + "status": "succeeded", + "context_updates": { + "command.output": "blob://sha256/12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126" }, + "notes": "Script completed: cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1", "usage": null }, "toolchain": { @@ -816,18 +816,110 @@ } }, "next_node_id": "simplify_opus", + "git_commit_sha": "1378a701cb5584bc5d421bda40214ed97425fe2e", "node_visits": { + "preflight_compile": 1, "implement": 1, - "preflight_lint": 1, "start": 1, - "toolchain": 1, - "preflight_compile": 1 + "preflight_lint": 1, + "toolchain": 1 } }, - "diff": {} + "diff": { + "patch": "diff --git a/Cargo.lock b/Cargo.lock\nindex 9c2f56b80..5949b1eca 100644\n--- a/Cargo.lock\n+++ b/Cargo.lock\n@@ -105,7 +105,7 @@ dependencies = [\n \"serde\",\n \"serde_json\",\n \"serde_with\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"tracing\",\n ]\n \n@@ -1022,6 +1022,17 @@ dependencies = [\n \"syn 2.0.117\",\n ]\n \n+[[package]]\n+name = \"croner\"\n+version = \"3.0.1\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576\"\n+dependencies = [\n+ \"chrono\",\n+ \"derive_builder\",\n+ \"strum 0.27.2\",\n+]\n+\n [[package]]\n name = \"crossbeam\"\n version = \"0.8.4\"\n@@ -1166,6 +1177,16 @@ dependencies = [\n \"darling_macro 0.14.4\",\n ]\n \n+[[package]]\n+name = \"darling\"\n+version = \"0.20.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee\"\n+dependencies = [\n+ \"darling_core 0.20.11\",\n+ \"darling_macro 0.20.11\",\n+]\n+\n [[package]]\n name = \"darling\"\n version = \"0.23.0\"\n@@ -1190,6 +1211,20 @@ dependencies = [\n \"syn 1.0.109\",\n ]\n \n+[[package]]\n+name = \"darling_core\"\n+version = \"0.20.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e\"\n+dependencies = [\n+ \"fnv\",\n+ \"ident_case\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"strsim 0.11.1\",\n+ \"syn 2.0.117\",\n+]\n+\n [[package]]\n name = \"darling_core\"\n version = \"0.23.0\"\n@@ -1214,6 +1249,17 @@ dependencies = [\n \"syn 1.0.109\",\n ]\n \n+[[package]]\n+name = \"darling_macro\"\n+version = \"0.20.11\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead\"\n+dependencies = [\n+ \"darling_core 0.20.11\",\n+ \"quote\",\n+ \"syn 2.0.117\",\n+]\n+\n [[package]]\n name = \"darling_macro\"\n version = \"0.23.0\"\n@@ -1332,6 +1378,37 @@ dependencies = [\n \"serde_core\",\n ]\n \n+[[package]]\n+name = \"derive_builder\"\n+version = \"0.20.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947\"\n+dependencies = [\n+ \"derive_builder_macro\",\n+]\n+\n+[[package]]\n+name = \"derive_builder_core\"\n+version = \"0.20.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8\"\n+dependencies = [\n+ \"darling 0.20.11\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn 2.0.117\",\n+]\n+\n+[[package]]\n+name = \"derive_builder_macro\"\n+version = \"0.20.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c\"\n+dependencies = [\n+ \"derive_builder_core\",\n+ \"syn 2.0.117\",\n+]\n+\n [[package]]\n name = \"derive_more\"\n version = \"2.1.1\"\n@@ -1632,7 +1709,7 @@ dependencies = [\n \"serde_json\",\n \"sha2\",\n \"shell-escape\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"tempfile\",\n \"thiserror 2.0.18\",\n \"tokio\",\n@@ -1647,6 +1724,7 @@ name = \"fabro-api\"\n version = \"0.243.0-nightly.1\"\n dependencies = [\n \"chrono\",\n+ \"fabro-automation\",\n \"fabro-config\",\n \"fabro-model\",\n \"fabro-types\",\n@@ -1687,6 +1765,22 @@ dependencies = [\n \"toml 0.8.23\",\n ]\n \n+[[package]]\n+name = \"fabro-automation\"\n+version = \"0.243.0-nightly.1\"\n+dependencies = [\n+ \"chrono\",\n+ \"croner\",\n+ \"hex\",\n+ \"serde\",\n+ \"sha2\",\n+ \"tempfile\",\n+ \"thiserror 2.0.18\",\n+ \"tokio\",\n+ \"toml 0.8.23\",\n+ \"toml_edit\",\n+]\n+\n [[package]]\n name = \"fabro-build-support\"\n version = \"0.243.0-nightly.1\"\n@@ -1963,7 +2057,7 @@ dependencies = [\n \"nom\",\n \"regex\",\n \"serde\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"thiserror 2.0.18\",\n ]\n \n@@ -2056,7 +2150,7 @@ dependencies = [\n \"rand 0.9.4\",\n \"serde\",\n \"serde_json\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"thiserror 2.0.18\",\n \"tokio\",\n \"tokio-stream\",\n@@ -2137,7 +2231,7 @@ dependencies = [\n \"schemars 1.2.1\",\n \"serde\",\n \"serde_json\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"tempfile\",\n \"tokio\",\n \"toml 0.8.23\",\n@@ -2153,7 +2247,7 @@ dependencies = [\n \"rust-embed\",\n \"serde\",\n \"serde_json\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"thiserror 2.0.18\",\n \"toml 0.8.23\",\n \"tracing\",\n@@ -2245,7 +2339,7 @@ dependencies = [\n \"serde\",\n \"serde_json\",\n \"shlex\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"tar\",\n \"tempfile\",\n \"thiserror 2.0.18\",\n@@ -2274,6 +2368,7 @@ dependencies = [\n \"fabro-agent\",\n \"fabro-api\",\n \"fabro-auth\",\n+ \"fabro-automation\",\n \"fabro-build-support\",\n \"fabro-client\",\n \"fabro-config\",\n@@ -2322,7 +2417,7 @@ dependencies = [\n \"serde_json\",\n \"serde_yaml\",\n \"sha2\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"sysinfo\",\n \"tempfile\",\n \"thiserror 2.0.18\",\n@@ -2355,7 +2450,7 @@ dependencies = [\n \"rustls\",\n \"serde\",\n \"serde_json\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"thiserror 2.0.18\",\n \"tokio\",\n \"tokio-tungstenite 0.26.2\",\n@@ -2480,7 +2575,7 @@ dependencies = [\n \"schemars 1.2.1\",\n \"serde\",\n \"serde_json\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"tempfile\",\n \"tokio\",\n \"toml 0.8.23\",\n@@ -2514,7 +2609,7 @@ dependencies = [\n \"serde\",\n \"serde_json\",\n \"sha2\",\n- \"strum\",\n+ \"strum 0.28.0\",\n \"tempfile\",\n \"toml 0.8.23\",\n \"ulid\",\n@@ -6689,13 +6784,34 @@ version = \"0.11.1\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f\"\n \n+[[package]]\n+name = \"strum\"\n+version = \"0.27.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf\"\n+dependencies = [\n+ \"strum_macros 0.27.2\",\n+]\n+\n [[package]]\n name = \"strum\"\n version = \"0.28.0\"\n source = \"registry+https://github.com/rust-lang/crates.io-index\"\n checksum = \"9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd\"\n dependencies = [\n- \"strum_macros\",\n+ \"strum_macros 0.28.0\",\n+]\n+\n+[[package]]\n+name = \"strum_macros\"\n+version = \"0.27.2\"\n+source = \"registry+https://github.com/rust-lang/crates.io-index\"\n+checksum = \"7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7\"\n+dependencies = [\n+ \"heck 0.5.0\",\n+ \"proc-macro2\",\n+ \"quote\",\n+ \"syn 2.0.117\",\n ]\n \n [[package]]\ndiff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml\nindex 5a2f9b751..3b68437d3 100644\n--- a/docs/public/api-reference/fabro-api.yaml\n+++ b/docs/public/api-reference/fabro-api.yaml\n@@ -15,6 +15,8 @@ tags:\n description: Browser authentication and demo-mode controls\n - name: Runs\n description: Run management operations\n+ - name: Automations\n+ description: Server-owned runnable automation bindings\n - name: Sessions\n description: Ask Fabro sessions bound to runs\n - name: Human-in-the-Loop\n@@ -3906,6 +3908,372 @@ paths:\n schema:\n $ref: \"#/components/schemas/ErrorResponse\"\n \n+ # ── Automations ──────────────────────────────────────────────────────\n+\n+ /api/v1/automations:\n+ get:\n+ operationId: listAutomations\n+ tags: [Automations]\n+ summary: List automations\n+ description: Returns all server-owned automation definitions sorted by ID.\n+ responses:\n+ \"200\":\n+ description: Automation definitions\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/AutomationListResponse\"\n+ post:\n+ operationId: createAutomation\n+ tags: [Automations]\n+ summary: Create automation\n+ description: Creates and persists a server-owned automation definition.\n+ requestBody:\n+ required: true\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/CreateAutomationRequest\"\n+ responses:\n+ \"201\":\n+ description: Automation created\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Automation\"\n+ \"400\":\n+ description: Malformed JSON\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Automation already exists\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"422\":\n+ description: Invalid automation definition\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+\n+ /api/v1/automations/{id}:\n+ get:\n+ operationId: getAutomation\n+ tags: [Automations]\n+ summary: Get automation\n+ parameters:\n+ - $ref: \"#/components/parameters/AutomationId\"\n+ responses:\n+ \"200\":\n+ description: Automation definition\n+ headers:\n+ ETag:\n+ $ref: \"#/components/headers/ETag\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Automation\"\n+ \"400\":\n+ description: Invalid automation ID syntax\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Automation not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ put:\n+ operationId: replaceAutomation\n+ tags: [Automations]\n+ summary: Replace automation\n+ parameters:\n+ - $ref: \"#/components/parameters/AutomationId\"\n+ - $ref: \"#/components/parameters/AutomationRevision\"\n+ requestBody:\n+ required: true\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ReplaceAutomationRequest\"\n+ responses:\n+ \"200\":\n+ description: Automation replaced\n+ headers:\n+ ETag:\n+ $ref: \"#/components/headers/ETag\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Automation\"\n+ \"400\":\n+ description: Malformed JSON or invalid automation ID syntax\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Automation not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Revision mismatch\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"422\":\n+ description: Invalid automation definition\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"428\":\n+ description: Missing If-Match revision\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ patch:\n+ operationId: patchAutomation\n+ tags: [Automations]\n+ summary: Patch automation\n+ description: Applies a shallow patch to an automation definition.\n+ parameters:\n+ - $ref: \"#/components/parameters/AutomationId\"\n+ - $ref: \"#/components/parameters/AutomationRevision\"\n+ requestBody:\n+ required: true\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/PatchAutomationRequest\"\n+ responses:\n+ \"200\":\n+ description: Automation patched\n+ headers:\n+ ETag:\n+ $ref: \"#/components/headers/ETag\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Automation\"\n+ \"400\":\n+ description: Malformed JSON or invalid automation ID syntax\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Automation not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Revision mismatch\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"422\":\n+ description: Invalid automation definition\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"428\":\n+ description: Missing If-Match revision\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ delete:\n+ operationId: deleteAutomation\n+ tags: [Automations]\n+ summary: Delete automation\n+ parameters:\n+ - $ref: \"#/components/parameters/AutomationId\"\n+ - $ref: \"#/components/parameters/AutomationRevision\"\n+ responses:\n+ \"204\":\n+ description: Automation deleted\n+ \"400\":\n+ description: Invalid automation ID syntax\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Automation not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Revision mismatch\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"428\":\n+ description: Missing If-Match revision\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+\n+ /api/v1/automations/{id}/runs:\n+ get:\n+ operationId: listAutomationRuns\n+ tags: [Automations]\n+ summary: List automation runs\n+ description: Returns durable runs linked to an existing automation, newest first.\n+ parameters:\n+ - $ref: \"#/components/parameters/AutomationId\"\n+ - $ref: \"#/components/parameters/PageLimit\"\n+ - $ref: \"#/components/parameters/PageOffset\"\n+ responses:\n+ \"200\":\n+ description: Paginated runs for the automation\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/PaginatedRunList\"\n+ \"400\":\n+ description: Invalid automation ID syntax\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Automation not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ post:\n+ operationId: createAutomationRun\n+ tags: [Automations]\n+ summary: Start automation run\n+ description: Creates a submitted run by materializing an automation target through its enabled `api` trigger.\n+ parameters:\n+ - $ref: \"#/components/parameters/AutomationId\"\n+ responses:\n+ \"201\":\n+ description: Run created from automation\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/Run\"\n+ \"400\":\n+ description: Invalid automation ID syntax\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"404\":\n+ description: Automation not found\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"409\":\n+ description: Automation is disabled or has no enabled api trigger\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+ \"422\":\n+ description: Automation target could not be materialized\n+ headers:\n+ x-request-id:\n+ $ref: \"#/components/headers/XRequestId\"\n+ content:\n+ application/json:\n+ schema:\n+ $ref: \"#/components/schemas/ErrorResponse\"\n+\n # ── Workflows ────────────────────────────────────────────────────────\n \n /api/v1/workflows:\n@@ -4576,6 +4944,25 @@ components:\n type: string\n example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z\n \n+ AutomationId:\n+ name: id\n+ in: path\n+ required: true\n+ description: Automation identifier. The canonical ID is the TOML filename stem.\n+ schema:\n+ type: string\n+ pattern: \"^[a-z0-9][a-z0-9-]{0,62}$\"\n+ example: nightly-deps\n+\n+ AutomationRevision:\n+ name: If-Match\n+ in: header\n+ required: true\n+ description: Current automation revision, quoted or unquoted.\n+ schema:\n+ type: string\n+ example: '\"d2d2c1f38c4fd03b6f2c14c9b5e4a8ff4d1bf74c1b8f2dbf0bff650f7e8e0f5a\"'\n+\n RunSelector:\n name: selector\n in: query\n@@ -4824,6 +5211,11 @@ components:\n schema:\n type: string\n format: uuid\n+ ETag:\n+ description: Current automation revision.\n+ schema:\n+ type: string\n+ example: '\"d2d2c1f38c4fd03b6f2c14c9b5e4a8ff4d1bf74c1b8f2dbf0bff650f7e8e0f5a\"'\n \n schemas:\n AuthConfigResponse:\n@@ -5305,6 +5697,202 @@ components:\n chose the personal access token flow; GitHub App installs rely on\n OAuth and do not receive a dev token.\n \n+ # ── Automations ──────────────────────────────────────────────────────\n+\n+ Automation:\n+ description: Server-owned runnable automation binding.\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - id\n+ - revision\n+ - name\n+ - description\n+ - enabled\n+ - target\n+ - triggers\n+ properties:\n+ id:\n+ type: string\n+ pattern: \"^[a-z0-9][a-z0-9-]{0,62}$\"\n+ revision:\n+ type: string\n+ description: Lowercase hex SHA-256 of the persisted TOML bytes.\n+ pattern: \"^[0-9a-f]{64}$\"\n+ name:\n+ type: string\n+ description:\n+ type: [\"string\", \"null\"]\n+ enabled:\n+ type: boolean\n+ target:\n+ $ref: \"#/components/schemas/AutomationTarget\"\n+ triggers:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/AutomationTrigger\"\n+\n+ AutomationTarget:\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - repository\n+ - ref\n+ - workflow\n+ properties:\n+ repository:\n+ type: string\n+ description: GitHub owner/repo slug.\n+ example: fabro-sh/fabro\n+ ref:\n+ type: string\n+ description: Branch, tag, or SHA selector to checkout.\n+ example: main\n+ workflow:\n+ type: string\n+ description: Workflow slug or relative workflow path inside the repository.\n+ example: dependency-update\n+\n+ AutomationTrigger:\n+ oneOf:\n+ - $ref: \"#/components/schemas/AutomationApiTrigger\"\n+ - $ref: \"#/components/schemas/AutomationScheduleTrigger\"\n+ discriminator:\n+ propertyName: type\n+ mapping:\n+ api: \"#/components/schemas/AutomationApiTrigger\"\n+ schedule: \"#/components/schemas/AutomationScheduleTrigger\"\n+\n+ AutomationApiTrigger:\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - id\n+ - type\n+ properties:\n+ id:\n+ type: string\n+ pattern: \"^[a-z0-9][a-z0-9_-]{0,62}$\"\n+ example: api\n+ type:\n+ type: string\n+ enum: [api]\n+ enabled:\n+ type: boolean\n+ default: true\n+\n+ AutomationScheduleTrigger:\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - id\n+ - type\n+ - expression\n+ properties:\n+ id:\n+ type: string\n+ pattern: \"^[a-z0-9][a-z0-9_-]{0,62}$\"\n+ example: nightly\n+ type:\n+ type: string\n+ enum: [schedule]\n+ enabled:\n+ type: boolean\n+ default: true\n+ expression:\n+ type: string\n+ description: Five-field cron expression accepted by croner.\n+ example: \"0 3 * * *\"\n+\n+ CreateAutomationRequest:\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - id\n+ - name\n+ - target\n+ - triggers\n+ properties:\n+ id:\n+ type: string\n+ pattern: \"^[a-z0-9][a-z0-9-]{0,62}$\"\n+ name:\n+ type: string\n+ description:\n+ type: [\"string\", \"null\"]\n+ default: null\n+ enabled:\n+ type: boolean\n+ default: true\n+ target:\n+ $ref: \"#/components/schemas/AutomationTarget\"\n+ triggers:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/AutomationTrigger\"\n+\n+ ReplaceAutomationRequest:\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - name\n+ - enabled\n+ - target\n+ - triggers\n+ properties:\n+ name:\n+ type: string\n+ description:\n+ type: [\"string\", \"null\"]\n+ default: null\n+ enabled:\n+ type: boolean\n+ target:\n+ $ref: \"#/components/schemas/AutomationTarget\"\n+ triggers:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/AutomationTrigger\"\n+\n+ PatchAutomationRequest:\n+ type: object\n+ additionalProperties: false\n+ properties:\n+ name:\n+ type: string\n+ description:\n+ type: [\"string\", \"null\"]\n+ enabled:\n+ type: boolean\n+ target:\n+ $ref: \"#/components/schemas/AutomationTarget\"\n+ triggers:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/AutomationTrigger\"\n+\n+ AutomationListResponse:\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - data\n+ - meta\n+ properties:\n+ data:\n+ type: array\n+ items:\n+ $ref: \"#/components/schemas/Automation\"\n+ meta:\n+ type: object\n+ additionalProperties: false\n+ required:\n+ - total\n+ properties:\n+ total:\n+ type: integer\n+ format: int64\n+ minimum: 0\n+\n # ── Pagination ───────────────────────────────────────────────────────\n \n PaginationMeta:\n@@ -9445,6 +10033,9 @@ components:\n type: string\n name:\n type: [\"string\", \"null\"]\n+ trigger_id:\n+ type: string\n+ description: User-visible trigger ID that started the run, when known.\n \n RunOrigin:\n type: object\ndiff --git a/lib/crates/fabro-api/Cargo.toml b/lib/crates/fabro-api/Cargo.toml\nindex 8b347f032..ce21c0986 100644\n--- a/lib/crates/fabro-api/Cargo.toml\n+++ b/lib/crates/fabro-api/Cargo.toml\n@@ -15,6 +15,7 @@ wildcard_imports = \"warn\"\n \n [dependencies]\n chrono = { workspace = true, features = [\"serde\"] }\n+fabro-automation = { path = \"../fabro-automation\" }\n fabro-config = { path = \"../fabro-config\" }\n fabro-model = { path = \"../fabro-model\" }\n fabro-types = { path = \"../fabro-types\" }\ndiff --git a/lib/crates/fabro-api/build.rs b/lib/crates/fabro-api/build.rs\nindex 14d13d70e..bbfb9df24 100644\n--- a/lib/crates/fabro-api/build.rs\n+++ b/lib/crates/fabro-api/build.rs\n@@ -201,6 +201,34 @@ fn main() {\n &[],\n ),\n (\"Run\", \"fabro_types::Run\", &[]),\n+ (\"Automation\", \"fabro_automation::Automation\", &[]),\n+ (\"AutomationTarget\", \"fabro_automation::AutomationTarget\", &[]),\n+ (\"AutomationTrigger\", \"fabro_automation::AutomationTrigger\", &[]),\n+ (\n+ \"AutomationApiTrigger\",\n+ \"fabro_automation::ApiTrigger\",\n+ &[],\n+ ),\n+ (\n+ \"AutomationScheduleTrigger\",\n+ \"fabro_automation::ScheduleTrigger\",\n+ &[],\n+ ),\n+ (\n+ \"CreateAutomationRequest\",\n+ \"fabro_automation::AutomationDraft\",\n+ &[],\n+ ),\n+ (\n+ \"ReplaceAutomationRequest\",\n+ \"fabro_automation::AutomationReplace\",\n+ &[],\n+ ),\n+ (\n+ \"PatchAutomationRequest\",\n+ \"fabro_automation::AutomationPatch\",\n+ &[],\n+ ),\n (\"RunApproval\", \"fabro_types::RunApproval\", &[]),\n (\"RunApprovalState\", \"fabro_types::RunApprovalState\", &[]),\n (\"RunRunnableSource\", \"fabro_types::RunRunnableSource\", &[]),\ndiff --git a/lib/crates/fabro-api/src/lib.rs b/lib/crates/fabro-api/src/lib.rs\nindex 9b40a152c..b1b6f6250 100644\n--- a/lib/crates/fabro-api/src/lib.rs\n+++ b/lib/crates/fabro-api/src/lib.rs\n@@ -14,6 +14,11 @@ mod generated {\n include!(concat!(env!(\"OUT_DIR\"), \"/codegen.rs\"));\n }\n pub mod types {\n+ pub use fabro_automation::{\n+ ApiTrigger as AutomationApiTrigger, Automation, AutomationDraft as CreateAutomationRequest,\n+ AutomationPatch as PatchAutomationRequest, AutomationReplace as ReplaceAutomationRequest,\n+ AutomationTarget, AutomationTrigger, ScheduleTrigger as AutomationScheduleTrigger,\n+ };\n pub use fabro_model::{\n Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode,\n Provider, ReasoningEffort, ReasoningEffortFeature, Speed as BillingSpeed,\ndiff --git a/lib/crates/fabro-api/tests/automation_round_trip.rs b/lib/crates/fabro-api/tests/automation_round_trip.rs\nnew file mode 100644\nindex 000000000..5c17b451f\n--- /dev/null\n+++ b/lib/crates/fabro-api/tests/automation_round_trip.rs\n@@ -0,0 +1,135 @@\n+use std::any::{TypeId, type_name};\n+\n+use fabro_api::types::{\n+ Automation as ApiAutomation, AutomationApiTrigger as ApiAutomationApiTrigger,\n+ AutomationScheduleTrigger as ApiAutomationScheduleTrigger,\n+ AutomationTarget as ApiAutomationTarget, AutomationTrigger as ApiAutomationTrigger,\n+ CreateAutomationRequest as ApiCreateAutomationRequest,\n+ PatchAutomationRequest as ApiPatchAutomationRequest,\n+ ReplaceAutomationRequest as ApiReplaceAutomationRequest,\n+};\n+use fabro_automation::{\n+ ApiTrigger, Automation, AutomationDraft, AutomationPatch, AutomationReplace, AutomationTarget,\n+ AutomationTrigger, ScheduleTrigger,\n+};\n+use serde_json::json;\n+\n+#[test]\n+fn automation_api_reuses_domain_types() {\n+ assert_same_type::();\n+ assert_same_type::();\n+ assert_same_type::();\n+ assert_same_type::();\n+ assert_same_type::();\n+ assert_same_type::();\n+ assert_same_type::();\n+ assert_same_type::();\n+}\n+\n+#[test]\n+fn automation_response_round_trips_json_shape() {\n+ let value = json!({\n+ \"id\": \"nightly-deps\",\n+ \"revision\": \"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\",\n+ \"name\": \"Nightly dependency update\",\n+ \"description\": \"Open a PR for dependency updates.\",\n+ \"enabled\": true,\n+ \"target\": {\n+ \"repository\": \"fabro-sh/fabro\",\n+ \"ref\": \"main\",\n+ \"workflow\": \"dependency-update\"\n+ },\n+ \"triggers\": [\n+ { \"id\": \"api\", \"type\": \"api\", \"enabled\": false },\n+ { \"id\": \"nightly\", \"type\": \"schedule\", \"enabled\": true, \"expression\": \"0 3 * * *\" }\n+ ]\n+ });\n+\n+ let automation: ApiAutomation = serde_json::from_value(value.clone()).unwrap();\n+ assert_eq!(serde_json::to_value(&automation).unwrap(), value);\n+}\n+\n+#[test]\n+fn create_automation_request_round_trips_json_shape() {\n+ let value = json!({\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"description\": \"Open a PR for dependency updates.\",\n+ \"enabled\": true,\n+ \"target\": {\n+ \"repository\": \"fabro-sh/fabro\",\n+ \"ref\": \"main\",\n+ \"workflow\": \"dependency-update\"\n+ },\n+ \"triggers\": [\n+ { \"id\": \"api\", \"type\": \"api\", \"enabled\": true }\n+ ]\n+ });\n+\n+ let request: ApiCreateAutomationRequest = serde_json::from_value(value.clone()).unwrap();\n+ assert_eq!(serde_json::to_value(&request).unwrap(), value);\n+}\n+\n+#[test]\n+fn replace_automation_request_round_trips_json_shape() {\n+ let value = json!({\n+ \"name\": \"Nightly dependency update\",\n+ \"description\": \"Open a PR for dependency updates.\",\n+ \"enabled\": false,\n+ \"target\": {\n+ \"repository\": \"fabro-sh/fabro\",\n+ \"ref\": \"main\",\n+ \"workflow\": \"dependency-update\"\n+ },\n+ \"triggers\": [\n+ { \"id\": \"api\", \"type\": \"api\", \"enabled\": true }\n+ ]\n+ });\n+\n+ let request: ApiReplaceAutomationRequest = serde_json::from_value(value.clone()).unwrap();\n+ assert_eq!(serde_json::to_value(&request).unwrap(), value);\n+}\n+\n+#[test]\n+fn patch_automation_request_preserves_null_description() {\n+ let value = json!({\n+ \"description\": null,\n+ \"enabled\": true\n+ });\n+\n+ let request: ApiPatchAutomationRequest = serde_json::from_value(value.clone()).unwrap();\n+ assert_eq!(serde_json::to_value(&request).unwrap(), value);\n+}\n+\n+#[test]\n+fn create_automation_request_defaults_optional_enabled_fields() {\n+ let value = json!({\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"target\": {\n+ \"repository\": \"fabro-sh/fabro\",\n+ \"ref\": \"main\",\n+ \"workflow\": \"dependency-update\"\n+ },\n+ \"triggers\": [\n+ { \"id\": \"api\", \"type\": \"api\" }\n+ ]\n+ });\n+\n+ let request: ApiCreateAutomationRequest = serde_json::from_value(value).unwrap();\n+ assert_eq!(request.enabled, None);\n+ let AutomationTrigger::Api(trigger) = &request.triggers[0] else {\n+ panic!(\"expected api trigger\");\n+ };\n+ assert!(trigger.enabled);\n+}\n+\n+fn assert_same_type() {\n+ assert_eq!(\n+ TypeId::of::(),\n+ TypeId::of::(),\n+ \"{} should be the same type as {}\",\n+ type_name::(),\n+ type_name::()\n+ );\n+}\ndiff --git a/lib/crates/fabro-api/tests/run_projection_round_trip.rs b/lib/crates/fabro-api/tests/run_projection_round_trip.rs\nindex 64a00df91..b8e2b17d0 100644\n--- a/lib/crates/fabro-api/tests/run_projection_round_trip.rs\n+++ b/lib/crates/fabro-api/tests/run_projection_round_trip.rs\n@@ -130,6 +130,7 @@ fn run_spec_json() -> serde_json::Value {\n workflow_slug: None,\n source_directory: None,\n labels: std::collections::HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-automation/Cargo.toml b/lib/crates/fabro-automation/Cargo.toml\nnew file mode 100644\nindex 000000000..a1a741d31\n--- /dev/null\n+++ b/lib/crates/fabro-automation/Cargo.toml\n@@ -0,0 +1,26 @@\n+[package]\n+name = \"fabro-automation\"\n+edition.workspace = true\n+version.workspace = true\n+publish = false\n+license.workspace = true\n+\n+[lib]\n+doctest = false\n+\n+[lints]\n+workspace = true\n+\n+[dependencies]\n+chrono = { workspace = true, features = [\"serde\"] }\n+croner = \"3\"\n+hex.workspace = true\n+serde.workspace = true\n+sha2.workspace = true\n+thiserror.workspace = true\n+tokio = { workspace = true, features = [\"fs\", \"io-util\", \"sync\"] }\n+toml.workspace = true\n+toml_edit.workspace = true\n+\n+[dev-dependencies]\n+tempfile = \"3\"\ndiff --git a/lib/crates/fabro-automation/src/error.rs b/lib/crates/fabro-automation/src/error.rs\nnew file mode 100644\nindex 000000000..7d5686d11\n--- /dev/null\n+++ b/lib/crates/fabro-automation/src/error.rs\n@@ -0,0 +1,64 @@\n+use std::path::PathBuf;\n+\n+use thiserror::Error;\n+\n+#[derive(Debug, Error)]\n+pub enum AutomationValidationError {\n+ #[error(\"invalid automation id: {0}\")]\n+ InvalidAutomationId(String),\n+ #[error(\"invalid automation trigger id: {0}\")]\n+ InvalidTriggerId(String),\n+ #[error(\"automation name cannot be empty\")]\n+ EmptyName,\n+ #[error(\"invalid repository slug: {0}\")]\n+ InvalidRepositorySlug(String),\n+ #[error(\"invalid git ref selector: {0}\")]\n+ InvalidGitRef(String),\n+ #[error(\"invalid workflow selector: {0}\")]\n+ InvalidWorkflowSelector(String),\n+ #[error(\"duplicate trigger id: {0}\")]\n+ DuplicateTriggerId(String),\n+ #[error(\"at most one api trigger is allowed\")]\n+ TooManyApiTriggers,\n+ #[error(\"invalid schedule expression: {0}\")]\n+ InvalidScheduleExpression(String),\n+ #[error(\"unknown trigger type: {0}\")]\n+ UnknownTriggerType(String),\n+}\n+\n+#[derive(Debug, Error)]\n+pub enum AutomationStoreError {\n+ #[error(\"automation not found: {0}\")]\n+ NotFound(String),\n+ #[error(\"automation already exists: {0}\")]\n+ AlreadyExists(String),\n+ #[error(\"missing revision\")]\n+ MissingRevision,\n+ #[error(\"revision mismatch\")]\n+ RevisionMismatch,\n+ #[error(transparent)]\n+ Validation(#[from] AutomationValidationError),\n+ #[error(\"failed to parse automation file {path}: {source}\")]\n+ Parse {\n+ path: PathBuf,\n+ source: toml::de::Error,\n+ },\n+ #[error(\"invalid automation filename: {path}\")]\n+ InvalidFilename { path: PathBuf },\n+ #[error(\"I/O error at {path}: {source}\")]\n+ Io {\n+ path: PathBuf,\n+ source: std::io::Error,\n+ },\n+ #[error(\"failed to serialize automation: {0}\")]\n+ Serialize(#[from] toml::ser::Error),\n+}\n+\n+impl AutomationStoreError {\n+ pub fn io(path: impl Into, source: std::io::Error) -> Self {\n+ Self::Io {\n+ path: path.into(),\n+ source,\n+ }\n+ }\n+}\ndiff --git a/lib/crates/fabro-automation/src/id.rs b/lib/crates/fabro-automation/src/id.rs\nnew file mode 100644\nindex 000000000..9c51e9f15\n--- /dev/null\n+++ b/lib/crates/fabro-automation/src/id.rs\n@@ -0,0 +1,158 @@\n+use std::fmt;\n+\n+use serde::{Deserialize, Deserializer, Serialize, Serializer};\n+\n+use crate::error::AutomationValidationError;\n+\n+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]\n+pub struct AutomationId(String);\n+\n+impl AutomationId {\n+ pub fn new(value: impl Into) -> Result {\n+ Self::try_from(value.into())\n+ }\n+\n+ pub fn as_str(&self) -> &str {\n+ &self.0\n+ }\n+}\n+\n+impl AsRef for AutomationId {\n+ fn as_ref(&self) -> &str {\n+ self.as_str()\n+ }\n+}\n+\n+impl fmt::Display for AutomationId {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ f.write_str(&self.0)\n+ }\n+}\n+\n+impl TryFrom for AutomationId {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: String) -> Result {\n+ validate_id(&value, IdKind::Automation)?;\n+ Ok(Self(value))\n+ }\n+}\n+\n+impl TryFrom<&str> for AutomationId {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: &str) -> Result {\n+ Self::try_from(value.to_string())\n+ }\n+}\n+\n+impl Serialize for AutomationId {\n+ fn serialize(&self, serializer: S) -> Result\n+ where\n+ S: Serializer,\n+ {\n+ serializer.serialize_str(self.as_str())\n+ }\n+}\n+\n+impl<'de> Deserialize<'de> for AutomationId {\n+ fn deserialize(deserializer: D) -> Result\n+ where\n+ D: Deserializer<'de>,\n+ {\n+ let value = String::deserialize(deserializer)?;\n+ Self::try_from(value).map_err(serde::de::Error::custom)\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]\n+pub struct AutomationTriggerId(String);\n+\n+impl AutomationTriggerId {\n+ pub fn new(value: impl Into) -> Result {\n+ Self::try_from(value.into())\n+ }\n+\n+ pub fn as_str(&self) -> &str {\n+ &self.0\n+ }\n+}\n+\n+impl AsRef for AutomationTriggerId {\n+ fn as_ref(&self) -> &str {\n+ self.as_str()\n+ }\n+}\n+\n+impl fmt::Display for AutomationTriggerId {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ f.write_str(&self.0)\n+ }\n+}\n+\n+impl TryFrom for AutomationTriggerId {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: String) -> Result {\n+ validate_id(&value, IdKind::Trigger)?;\n+ Ok(Self(value))\n+ }\n+}\n+\n+impl TryFrom<&str> for AutomationTriggerId {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: &str) -> Result {\n+ Self::try_from(value.to_string())\n+ }\n+}\n+\n+impl Serialize for AutomationTriggerId {\n+ fn serialize(&self, serializer: S) -> Result\n+ where\n+ S: Serializer,\n+ {\n+ serializer.serialize_str(self.as_str())\n+ }\n+}\n+\n+impl<'de> Deserialize<'de> for AutomationTriggerId {\n+ fn deserialize(deserializer: D) -> Result\n+ where\n+ D: Deserializer<'de>,\n+ {\n+ let value = String::deserialize(deserializer)?;\n+ Self::try_from(value).map_err(serde::de::Error::custom)\n+ }\n+}\n+\n+#[derive(Clone, Copy)]\n+enum IdKind {\n+ Automation,\n+ Trigger,\n+}\n+\n+fn validate_id(value: &str, kind: IdKind) -> Result<(), AutomationValidationError> {\n+ let valid_len = (1..=63).contains(&value.len());\n+ let first_valid = value\n+ .bytes()\n+ .next()\n+ .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit());\n+ let rest_valid = value.bytes().skip(1).all(|b| {\n+ b.is_ascii_lowercase()\n+ || b.is_ascii_digit()\n+ || b == b'-'\n+ || (matches!(kind, IdKind::Trigger) && b == b'_')\n+ });\n+\n+ if valid_len && first_valid && rest_valid {\n+ return Ok(());\n+ }\n+\n+ match kind {\n+ IdKind::Automation => Err(AutomationValidationError::InvalidAutomationId(\n+ value.to_string(),\n+ )),\n+ IdKind::Trigger => Err(AutomationValidationError::InvalidTriggerId(value.to_string())),\n+ }\n+}\ndiff --git a/lib/crates/fabro-automation/src/lib.rs b/lib/crates/fabro-automation/src/lib.rs\nnew file mode 100644\nindex 000000000..b3d378adb\n--- /dev/null\n+++ b/lib/crates/fabro-automation/src/lib.rs\n@@ -0,0 +1,12 @@\n+pub mod error;\n+pub mod id;\n+pub mod model;\n+pub mod store;\n+\n+pub use error::{AutomationStoreError, AutomationValidationError};\n+pub use id::{AutomationId, AutomationTriggerId};\n+pub use model::{\n+ ApiTrigger, Automation, AutomationDraft, AutomationPatch, AutomationReplace, AutomationRevision,\n+ AutomationTarget, AutomationTrigger, GitRefSelector, RepositorySlug, ScheduleTrigger, WorkflowSlug,\n+};\n+pub use store::AutomationStore;\ndiff --git a/lib/crates/fabro-automation/src/model.rs b/lib/crates/fabro-automation/src/model.rs\nnew file mode 100644\nindex 000000000..1d47b53cb\n--- /dev/null\n+++ b/lib/crates/fabro-automation/src/model.rs\n@@ -0,0 +1,747 @@\n+use std::collections::HashSet;\n+use std::fmt;\n+use std::path::{Component, Path};\n+use std::str::FromStr as _;\n+\n+use croner::Cron;\n+use serde::{Deserialize, Deserializer, Serialize, Serializer};\n+\n+use crate::error::AutomationValidationError;\n+use crate::id::{AutomationId, AutomationTriggerId};\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]\n+#[serde(transparent)]\n+pub struct AutomationRevision(String);\n+\n+impl AutomationRevision {\n+ pub fn new(value: impl Into) -> Self {\n+ Self(value.into())\n+ }\n+\n+ pub fn as_str(&self) -> &str {\n+ &self.0\n+ }\n+}\n+\n+impl AsRef for AutomationRevision {\n+ fn as_ref(&self) -> &str {\n+ self.as_str()\n+ }\n+}\n+\n+impl fmt::Display for AutomationRevision {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ f.write_str(self.as_str())\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]\n+pub struct RepositorySlug(String);\n+\n+impl RepositorySlug {\n+ pub fn new(value: impl Into) -> Result {\n+ let value = value.into();\n+ validate_repository_slug(&value)?;\n+ Ok(Self(value))\n+ }\n+\n+ pub fn as_str(&self) -> &str {\n+ &self.0\n+ }\n+\n+ pub fn owner_repo(&self) -> (&str, &str) {\n+ self.0\n+ .split_once('/')\n+ .expect(\"repository slug validation guarantees owner/repo\")\n+ }\n+}\n+\n+impl AsRef for RepositorySlug {\n+ fn as_ref(&self) -> &str {\n+ self.as_str()\n+ }\n+}\n+\n+impl fmt::Display for RepositorySlug {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ f.write_str(self.as_str())\n+ }\n+}\n+\n+impl TryFrom for RepositorySlug {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: String) -> Result {\n+ Self::new(value)\n+ }\n+}\n+\n+impl TryFrom<&str> for RepositorySlug {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: &str) -> Result {\n+ Self::new(value)\n+ }\n+}\n+\n+impl Serialize for RepositorySlug {\n+ fn serialize(&self, serializer: S) -> Result\n+ where\n+ S: Serializer,\n+ {\n+ serializer.serialize_str(self.as_str())\n+ }\n+}\n+\n+impl<'de> Deserialize<'de> for RepositorySlug {\n+ fn deserialize(deserializer: D) -> Result\n+ where\n+ D: Deserializer<'de>,\n+ {\n+ let value = String::deserialize(deserializer)?;\n+ Self::try_from(value).map_err(serde::de::Error::custom)\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]\n+pub struct GitRefSelector(String);\n+\n+impl GitRefSelector {\n+ pub fn new(value: impl Into) -> Result {\n+ let value = value.into();\n+ validate_git_ref(&value)?;\n+ Ok(Self(value))\n+ }\n+\n+ pub fn as_str(&self) -> &str {\n+ &self.0\n+ }\n+}\n+\n+impl AsRef for GitRefSelector {\n+ fn as_ref(&self) -> &str {\n+ self.as_str()\n+ }\n+}\n+\n+impl fmt::Display for GitRefSelector {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ f.write_str(self.as_str())\n+ }\n+}\n+\n+impl TryFrom for GitRefSelector {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: String) -> Result {\n+ Self::new(value)\n+ }\n+}\n+\n+impl TryFrom<&str> for GitRefSelector {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: &str) -> Result {\n+ Self::new(value)\n+ }\n+}\n+\n+impl Serialize for GitRefSelector {\n+ fn serialize(&self, serializer: S) -> Result\n+ where\n+ S: Serializer,\n+ {\n+ serializer.serialize_str(self.as_str())\n+ }\n+}\n+\n+impl<'de> Deserialize<'de> for GitRefSelector {\n+ fn deserialize(deserializer: D) -> Result\n+ where\n+ D: Deserializer<'de>,\n+ {\n+ let value = String::deserialize(deserializer)?;\n+ Self::try_from(value).map_err(serde::de::Error::custom)\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]\n+pub struct WorkflowSlug(String);\n+\n+impl WorkflowSlug {\n+ pub fn new(value: impl Into) -> Result {\n+ let value = value.into();\n+ validate_workflow_selector(&value)?;\n+ Ok(Self(value))\n+ }\n+\n+ pub fn as_str(&self) -> &str {\n+ &self.0\n+ }\n+}\n+\n+impl AsRef for WorkflowSlug {\n+ fn as_ref(&self) -> &str {\n+ self.as_str()\n+ }\n+}\n+\n+impl fmt::Display for WorkflowSlug {\n+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {\n+ f.write_str(self.as_str())\n+ }\n+}\n+\n+impl TryFrom for WorkflowSlug {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: String) -> Result {\n+ Self::new(value)\n+ }\n+}\n+\n+impl TryFrom<&str> for WorkflowSlug {\n+ type Error = AutomationValidationError;\n+\n+ fn try_from(value: &str) -> Result {\n+ Self::new(value)\n+ }\n+}\n+\n+impl Serialize for WorkflowSlug {\n+ fn serialize(&self, serializer: S) -> Result\n+ where\n+ S: Serializer,\n+ {\n+ serializer.serialize_str(self.as_str())\n+ }\n+}\n+\n+impl<'de> Deserialize<'de> for WorkflowSlug {\n+ fn deserialize(deserializer: D) -> Result\n+ where\n+ D: Deserializer<'de>,\n+ {\n+ let value = String::deserialize(deserializer)?;\n+ Self::try_from(value).map_err(serde::de::Error::custom)\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n+pub struct Automation {\n+ pub id: AutomationId,\n+ pub revision: AutomationRevision,\n+ pub name: String,\n+ #[serde(default)]\n+ pub description: Option,\n+ pub enabled: bool,\n+ pub target: AutomationTarget,\n+ pub triggers: Vec,\n+}\n+\n+impl Automation {\n+ pub fn api_trigger(&self) -> Option<&ApiTrigger> {\n+ self.triggers.iter().find_map(AutomationTrigger::as_api)\n+ }\n+\n+ pub(crate) fn from_persisted(\n+ id: AutomationId,\n+ revision: AutomationRevision,\n+ persisted: PersistedAutomation,\n+ ) -> Result {\n+ let automation = Self {\n+ id,\n+ revision,\n+ name: persisted.name,\n+ description: persisted.description,\n+ enabled: persisted.enabled,\n+ target: persisted.target,\n+ triggers: persisted.triggers,\n+ };\n+ automation.validate()?;\n+ Ok(automation)\n+ }\n+\n+ pub(crate) fn to_persisted(&self) -> PersistedAutomation {\n+ PersistedAutomation {\n+ name: self.name.clone(),\n+ description: self.description.clone(),\n+ enabled: self.enabled,\n+ target: self.target.clone(),\n+ triggers: self.triggers.clone(),\n+ }\n+ }\n+\n+ pub fn validate(&self) -> Result<(), AutomationValidationError> {\n+ validate_name(&self.name)?;\n+ validate_triggers(&self.triggers)\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n+pub struct AutomationTarget {\n+ pub repository: RepositorySlug,\n+ #[serde(rename = \"ref\")]\n+ pub ref_: GitRefSelector,\n+ pub workflow: WorkflowSlug,\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n+#[serde(tag = \"type\", rename_all = \"snake_case\")]\n+pub enum AutomationTrigger {\n+ Api(ApiTrigger),\n+ Schedule(ScheduleTrigger),\n+}\n+\n+impl AutomationTrigger {\n+ pub fn id(&self) -> &AutomationTriggerId {\n+ match self {\n+ Self::Api(trigger) => &trigger.id,\n+ Self::Schedule(trigger) => &trigger.id,\n+ }\n+ }\n+\n+ pub fn enabled(&self) -> bool {\n+ match self {\n+ Self::Api(trigger) => trigger.enabled,\n+ Self::Schedule(trigger) => trigger.enabled,\n+ }\n+ }\n+\n+ pub fn as_api(&self) -> Option<&ApiTrigger> {\n+ match self {\n+ Self::Api(trigger) => Some(trigger),\n+ Self::Schedule(_) => None,\n+ }\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n+pub struct ApiTrigger {\n+ pub id: AutomationTriggerId,\n+ #[serde(default = \"default_true\")]\n+ pub enabled: bool,\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n+pub struct ScheduleTrigger {\n+ pub id: AutomationTriggerId,\n+ #[serde(default = \"default_true\")]\n+ pub enabled: bool,\n+ pub expression: String,\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n+pub struct AutomationDraft {\n+ pub id: AutomationId,\n+ pub name: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub description: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub enabled: Option,\n+ pub target: AutomationTarget,\n+ pub triggers: Vec,\n+}\n+\n+impl AutomationDraft {\n+ pub(crate) fn into_automation(\n+ self,\n+ revision: AutomationRevision,\n+ ) -> Result {\n+ let automation = Automation {\n+ id: self.id,\n+ revision,\n+ name: self.name,\n+ description: self.description,\n+ enabled: self.enabled.unwrap_or(true),\n+ target: self.target,\n+ triggers: self.triggers,\n+ };\n+ automation.validate()?;\n+ Ok(automation)\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n+pub struct AutomationReplace {\n+ pub name: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub description: Option,\n+ pub enabled: bool,\n+ pub target: AutomationTarget,\n+ pub triggers: Vec,\n+}\n+\n+impl AutomationReplace {\n+ pub(crate) fn into_automation(\n+ self,\n+ id: AutomationId,\n+ revision: AutomationRevision,\n+ ) -> Result {\n+ let automation = Automation {\n+ id,\n+ revision,\n+ name: self.name,\n+ description: self.description,\n+ enabled: self.enabled,\n+ target: self.target,\n+ triggers: self.triggers,\n+ };\n+ automation.validate()?;\n+ Ok(automation)\n+ }\n+}\n+\n+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]\n+pub struct AutomationPatch {\n+ #[serde(default)]\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub name: Option,\n+ #[serde(default, deserialize_with = \"deserialize_optional_nullable\")]\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub description: Option>,\n+ #[serde(default)]\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub enabled: Option,\n+ #[serde(default)]\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub target: Option,\n+ #[serde(default)]\n+ #[serde(skip_serializing_if = \"Option::is_none\")]\n+ pub triggers: Option>,\n+}\n+\n+impl AutomationPatch {\n+ pub(crate) fn apply_to(\n+ self,\n+ existing: &Automation,\n+ revision: AutomationRevision,\n+ ) -> Result {\n+ let automation = Automation {\n+ id: existing.id.clone(),\n+ revision,\n+ name: self.name.unwrap_or_else(|| existing.name.clone()),\n+ description: self.description.unwrap_or_else(|| existing.description.clone()),\n+ enabled: self.enabled.unwrap_or(existing.enabled),\n+ target: self.target.unwrap_or_else(|| existing.target.clone()),\n+ triggers: self.triggers.unwrap_or_else(|| existing.triggers.clone()),\n+ };\n+ automation.validate()?;\n+ Ok(automation)\n+ }\n+}\n+\n+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]\n+pub(crate) struct PersistedAutomation {\n+ pub name: String,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub description: Option,\n+ #[serde(default = \"default_true\")]\n+ pub enabled: bool,\n+ pub target: AutomationTarget,\n+ #[serde(default)]\n+ pub triggers: Vec,\n+}\n+\n+fn default_true() -> bool {\n+ true\n+}\n+\n+fn deserialize_optional_nullable<'de, D>(\n+ deserializer: D,\n+) -> Result>, D::Error>\n+where\n+ D: Deserializer<'de>,\n+{\n+ Option::::deserialize(deserializer).map(Some)\n+}\n+\n+fn validate_name(name: &str) -> Result<(), AutomationValidationError> {\n+ if name.trim().is_empty() {\n+ return Err(AutomationValidationError::EmptyName);\n+ }\n+ Ok(())\n+}\n+\n+fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationValidationError> {\n+ let mut ids = HashSet::new();\n+ let mut api_count = 0_u8;\n+\n+ for trigger in triggers {\n+ if !ids.insert(trigger.id().as_str()) {\n+ return Err(AutomationValidationError::DuplicateTriggerId(\n+ trigger.id().to_string(),\n+ ));\n+ }\n+\n+ match trigger {\n+ AutomationTrigger::Api(_) => {\n+ api_count = api_count.saturating_add(1);\n+ if api_count > 1 {\n+ return Err(AutomationValidationError::TooManyApiTriggers);\n+ }\n+ }\n+ AutomationTrigger::Schedule(schedule) => {\n+ validate_schedule_expression(&schedule.expression)?;\n+ }\n+ }\n+ }\n+\n+ Ok(())\n+}\n+\n+fn validate_schedule_expression(expression: &str) -> Result<(), AutomationValidationError> {\n+ let is_five_field = expression.split_whitespace().count() == 5;\n+ if expression.trim().is_empty() || !is_five_field {\n+ return Err(AutomationValidationError::InvalidScheduleExpression(\n+ expression.to_string(),\n+ ));\n+ }\n+\n+ Cron::from_str(expression).map_err(|_| {\n+ AutomationValidationError::InvalidScheduleExpression(expression.to_string())\n+ })?;\n+ Ok(())\n+}\n+\n+fn validate_repository_slug(value: &str) -> Result<(), AutomationValidationError> {\n+ let Some((owner, repo)) = value.split_once('/') else {\n+ return Err(AutomationValidationError::InvalidRepositorySlug(\n+ value.to_string(),\n+ ));\n+ };\n+\n+ if repo.contains('/') || !valid_github_segment(owner, 39) || !valid_github_segment(repo, 100) {\n+ return Err(AutomationValidationError::InvalidRepositorySlug(\n+ value.to_string(),\n+ ));\n+ }\n+\n+ Ok(())\n+}\n+\n+fn valid_github_segment(value: &str, max_len: usize) -> bool {\n+ !value.is_empty()\n+ && value.len() <= max_len\n+ && !matches!(value, \".\" | \"..\")\n+ && value\n+ .bytes()\n+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))\n+}\n+\n+fn validate_git_ref(value: &str) -> Result<(), AutomationValidationError> {\n+ let invalid = value.is_empty()\n+ || value.starts_with('-')\n+ || value.starts_with('/')\n+ || value.ends_with('/')\n+ || value.contains(\"..\")\n+ || value.contains(\"//\")\n+ || value.bytes().any(|b| {\n+ b.is_ascii_control()\n+ || b.is_ascii_whitespace()\n+ || matches!(b, b'\\\\' | b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b']' | b'{' | b'}')\n+ });\n+\n+ if invalid {\n+ return Err(AutomationValidationError::InvalidGitRef(value.to_string()));\n+ }\n+ Ok(())\n+}\n+\n+fn validate_workflow_selector(value: &str) -> Result<(), AutomationValidationError> {\n+ if value.trim().is_empty() || value.bytes().any(|b| b.is_ascii_control()) {\n+ return Err(AutomationValidationError::InvalidWorkflowSelector(\n+ value.to_string(),\n+ ));\n+ }\n+\n+ let path = Path::new(value);\n+ if path.is_absolute()\n+ || path\n+ .components()\n+ .any(|component| matches!(component, Component::ParentDir | Component::RootDir | Component::Prefix(_)))\n+ {\n+ return Err(AutomationValidationError::InvalidWorkflowSelector(\n+ value.to_string(),\n+ ));\n+ }\n+\n+ Ok(())\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use super::*;\n+\n+ fn automation_id() -> AutomationId {\n+ AutomationId::try_from(\"nightly-deps\").expect(\"valid id\")\n+ }\n+\n+ fn revision() -> AutomationRevision {\n+ AutomationRevision::new(\"revision\")\n+ }\n+\n+ fn parse_toml(input: &str) -> Result {\n+ let persisted: PersistedAutomation = toml::from_str(input).expect(\"valid toml syntax\");\n+ Automation::from_persisted(automation_id(), revision(), persisted)\n+ }\n+\n+ #[test]\n+ fn parses_valid_toml() {\n+ let automation = parse_toml(\n+ r#\"\n+name = \"Nightly dependency update\"\n+description = \"Open a PR for dependency updates.\"\n+enabled = true\n+\n+[target]\n+repository = \"fabro-sh/fabro\"\n+ref = \"main\"\n+workflow = \"dependency-update\"\n+\n+[[triggers]]\n+id = \"api\"\n+type = \"api\"\n+enabled = false\n+\n+[[triggers]]\n+id = \"nightly\"\n+type = \"schedule\"\n+enabled = true\n+expression = \"0 3 * * *\"\n+\"#,\n+ )\n+ .expect(\"automation should parse\");\n+\n+ assert_eq!(automation.id.as_str(), \"nightly-deps\");\n+ assert_eq!(automation.description.as_deref(), Some(\"Open a PR for dependency updates.\"));\n+ assert!(matches!(automation.triggers[0], AutomationTrigger::Api(_)));\n+ assert!(matches!(automation.triggers[1], AutomationTrigger::Schedule(_)));\n+ }\n+\n+ #[test]\n+ fn applies_toml_defaults() {\n+ let automation = parse_toml(\n+ r#\"\n+name = \"Nightly dependency update\"\n+\n+[target]\n+repository = \"fabro-sh/fabro\"\n+ref = \"main\"\n+workflow = \"dependency-update\"\n+\n+[[triggers]]\n+id = \"api\"\n+type = \"api\"\n+\"#,\n+ )\n+ .expect(\"automation should parse\");\n+\n+ assert!(automation.enabled);\n+ assert_eq!(automation.description, None);\n+ let AutomationTrigger::Api(api) = &automation.triggers[0] else {\n+ panic!(\"expected api trigger\");\n+ };\n+ assert!(api.enabled);\n+ }\n+\n+ #[test]\n+ fn rejects_invalid_automation_ids() {\n+ for id in [\"\", \"-bad\", \"Bad\", \"bad_underscore\", &\"a\".repeat(64)] {\n+ assert!(AutomationId::try_from(id).is_err(), \"{id} should be invalid\");\n+ }\n+ }\n+\n+ #[test]\n+ fn rejects_invalid_trigger_ids() {\n+ for id in [\"\", \"-bad\", \"Bad\", \"bad.dot\", &\"a\".repeat(64)] {\n+ assert!(\n+ AutomationTriggerId::try_from(id).is_err(),\n+ \"{id} should be invalid\"\n+ );\n+ }\n+ }\n+\n+ #[test]\n+ fn rejects_duplicate_trigger_ids() {\n+ let err = parse_toml(\n+ r#\"\n+name = \"Nightly dependency update\"\n+\n+[target]\n+repository = \"fabro-sh/fabro\"\n+ref = \"main\"\n+workflow = \"dependency-update\"\n+\n+[[triggers]]\n+id = \"api\"\n+type = \"api\"\n+\n+[[triggers]]\n+id = \"api\"\n+type = \"schedule\"\n+expression = \"0 3 * * *\"\n+\"#,\n+ )\n+ .expect_err(\"duplicate trigger should fail\");\n+\n+ assert!(matches!(err, AutomationValidationError::DuplicateTriggerId(_)));\n+ }\n+\n+ #[test]\n+ fn rejects_two_api_triggers() {\n+ let err = parse_toml(\n+ r#\"\n+name = \"Nightly dependency update\"\n+\n+[target]\n+repository = \"fabro-sh/fabro\"\n+ref = \"main\"\n+workflow = \"dependency-update\"\n+\n+[[triggers]]\n+id = \"api\"\n+type = \"api\"\n+\n+[[triggers]]\n+id = \"other_api\"\n+type = \"api\"\n+\"#,\n+ )\n+ .expect_err(\"second api trigger should fail\");\n+\n+ assert!(matches!(err, AutomationValidationError::TooManyApiTriggers));\n+ }\n+\n+ #[test]\n+ fn rejects_invalid_repository_slug() {\n+ for repository in [\"owner\", \"owner/repo/extra\", \"../repo\", \"owner/bad/repo\"] {\n+ assert!(\n+ RepositorySlug::try_from(repository).is_err(),\n+ \"{repository} should be invalid\"\n+ );\n+ }\n+ }\n+\n+ #[test]\n+ fn rejects_invalid_schedule_expression() {\n+ let err = parse_toml(\n+ r#\"\n+name = \"Nightly dependency update\"\n+\n+[target]\n+repository = \"fabro-sh/fabro\"\n+ref = \"main\"\n+workflow = \"dependency-update\"\n+\n+[[triggers]]\n+id = \"nightly\"\n+type = \"schedule\"\n+expression = \"not a cron\"\n+\"#,\n+ )\n+ .expect_err(\"invalid schedule should fail\");\n+\n+ assert!(matches!(err, AutomationValidationError::InvalidScheduleExpression(_)));\n+ }\n+}\ndiff --git a/lib/crates/fabro-automation/src/store.rs b/lib/crates/fabro-automation/src/store.rs\nnew file mode 100644\nindex 000000000..b696d94fa\n--- /dev/null\n+++ b/lib/crates/fabro-automation/src/store.rs\n@@ -0,0 +1,438 @@\n+use std::collections::BTreeMap;\n+use std::path::{Path, PathBuf};\n+use std::time::{SystemTime, UNIX_EPOCH};\n+\n+use sha2::{Digest as _, Sha256};\n+use tokio::io::AsyncWriteExt as _;\n+use tokio::sync::RwLock;\n+\n+use crate::error::AutomationStoreError;\n+use crate::id::AutomationId;\n+use crate::model::{\n+ Automation, AutomationDraft, AutomationPatch, AutomationReplace, AutomationRevision,\n+ PersistedAutomation,\n+};\n+\n+#[derive(Debug)]\n+pub struct AutomationStore {\n+ dir: PathBuf,\n+ automations: RwLock>,\n+}\n+\n+impl AutomationStore {\n+ pub async fn load(dir: impl Into) -> Result {\n+ let dir = dir.into();\n+ tokio::task::spawn_blocking(move || Self::load_blocking(dir))\n+ .await\n+ .map_err(|err| {\n+ AutomationStoreError::io(\n+ \"\",\n+ std::io::Error::other(err.to_string()),\n+ )\n+ })?\n+ }\n+\n+ pub fn load_blocking(dir: impl Into) -> Result {\n+ let dir = dir.into();\n+ let mut automations = BTreeMap::new();\n+\n+ match std::fs::read_dir(&dir) {\n+ Ok(entries) => {\n+ for entry in entries {\n+ let entry = entry.map_err(|err| AutomationStoreError::io(&dir, err))?;\n+ let path = entry.path();\n+ let metadata =\n+ entry.metadata().map_err(|err| AutomationStoreError::io(&path, err))?;\n+ if !metadata.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some(\"toml\") {\n+ continue;\n+ }\n+\n+ let id = automation_id_from_path(&path)?;\n+ let bytes =\n+ std::fs::read(&path).map_err(|err| AutomationStoreError::io(&path, err))?;\n+ let persisted = parse_persisted(&path, &bytes)?;\n+ let revision = revision_for_bytes(&bytes);\n+ let automation = Automation::from_persisted(id.clone(), revision, persisted)?;\n+ automations.insert(id, automation);\n+ }\n+ }\n+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}\n+ Err(err) => return Err(AutomationStoreError::io(&dir, err)),\n+ }\n+\n+ Ok(Self {\n+ dir,\n+ automations: RwLock::new(automations),\n+ })\n+ }\n+\n+ pub async fn list(&self) -> Vec {\n+ self.automations.read().await.values().cloned().collect()\n+ }\n+\n+ pub async fn get(&self, id: &AutomationId) -> Option {\n+ self.automations.read().await.get(id).cloned()\n+ }\n+\n+ pub async fn create(\n+ &self,\n+ draft: AutomationDraft,\n+ ) -> Result {\n+ let mut automations = self.automations.write().await;\n+ if automations.contains_key(&draft.id) {\n+ return Err(AutomationStoreError::AlreadyExists(draft.id.to_string()));\n+ }\n+\n+ let automation = draft.into_automation(AutomationRevision::new(\"\"))?;\n+ let automation = self.persist(automation).await?;\n+ automations.insert(automation.id.clone(), automation.clone());\n+ Ok(automation)\n+ }\n+\n+ pub async fn replace(\n+ &self,\n+ id: &AutomationId,\n+ expected: &AutomationRevision,\n+ draft: AutomationReplace,\n+ ) -> Result {\n+ ensure_revision_present(expected)?;\n+ let mut automations = self.automations.write().await;\n+ let existing = automations\n+ .get(id)\n+ .ok_or_else(|| AutomationStoreError::NotFound(id.to_string()))?;\n+ ensure_revision_matches(existing, expected)?;\n+\n+ let automation = draft.into_automation(id.clone(), AutomationRevision::new(\"\"))?;\n+ let automation = self.persist(automation).await?;\n+ automations.insert(id.clone(), automation.clone());\n+ Ok(automation)\n+ }\n+\n+ pub async fn patch(\n+ &self,\n+ id: &AutomationId,\n+ expected: &AutomationRevision,\n+ patch: AutomationPatch,\n+ ) -> Result {\n+ ensure_revision_present(expected)?;\n+ let mut automations = self.automations.write().await;\n+ let existing = automations\n+ .get(id)\n+ .ok_or_else(|| AutomationStoreError::NotFound(id.to_string()))?;\n+ ensure_revision_matches(existing, expected)?;\n+\n+ let automation = patch.apply_to(existing, AutomationRevision::new(\"\"))?;\n+ let automation = self.persist(automation).await?;\n+ automations.insert(id.clone(), automation.clone());\n+ Ok(automation)\n+ }\n+\n+ pub async fn delete(\n+ &self,\n+ id: &AutomationId,\n+ expected: &AutomationRevision,\n+ ) -> Result<(), AutomationStoreError> {\n+ ensure_revision_present(expected)?;\n+ let mut automations = self.automations.write().await;\n+ let existing = automations\n+ .get(id)\n+ .ok_or_else(|| AutomationStoreError::NotFound(id.to_string()))?;\n+ ensure_revision_matches(existing, expected)?;\n+\n+ let path = self.path_for(id);\n+ match tokio::fs::remove_file(&path).await {\n+ Ok(()) => {}\n+ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}\n+ Err(err) => return Err(AutomationStoreError::io(&path, err)),\n+ }\n+ automations.remove(id);\n+ Ok(())\n+ }\n+\n+ fn path_for(&self, id: &AutomationId) -> PathBuf {\n+ self.dir.join(format!(\"{id}.toml\"))\n+ }\n+\n+ async fn persist(&self, mut automation: Automation) -> Result {\n+ tokio::fs::create_dir_all(&self.dir)\n+ .await\n+ .map_err(|err| AutomationStoreError::io(&self.dir, err))?;\n+\n+ let bytes = canonical_toml_bytes(&automation)?;\n+ automation.revision = revision_for_bytes(&bytes);\n+ atomic_write(&self.dir, &self.path_for(&automation.id), &bytes).await?;\n+ Ok(automation)\n+ }\n+}\n+\n+fn automation_id_from_path(path: &Path) -> Result {\n+ let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {\n+ return Err(AutomationStoreError::InvalidFilename {\n+ path: path.to_path_buf(),\n+ });\n+ };\n+ AutomationId::try_from(stem.to_string()).map_err(AutomationStoreError::Validation)\n+}\n+\n+fn parse_persisted(\n+ path: &Path,\n+ bytes: &[u8],\n+) -> Result {\n+ let text = std::str::from_utf8(bytes).map_err(|err| {\n+ AutomationStoreError::io(path, std::io::Error::new(std::io::ErrorKind::InvalidData, err))\n+ })?;\n+ toml::from_str(text).map_err(|source| AutomationStoreError::Parse {\n+ path: path.to_path_buf(),\n+ source,\n+ })\n+}\n+\n+fn canonical_toml_bytes(automation: &Automation) -> Result, AutomationStoreError> {\n+ let persisted = automation.to_persisted();\n+ let mut text = toml::to_string_pretty(&persisted)?;\n+ if !text.ends_with('\\n') {\n+ text.push('\\n');\n+ }\n+ Ok(text.into_bytes())\n+}\n+\n+fn revision_for_bytes(bytes: &[u8]) -> AutomationRevision {\n+ let digest = Sha256::digest(bytes);\n+ AutomationRevision::new(hex::encode(digest))\n+}\n+\n+fn ensure_revision_present(expected: &AutomationRevision) -> Result<(), AutomationStoreError> {\n+ if expected.as_str().is_empty() {\n+ return Err(AutomationStoreError::MissingRevision);\n+ }\n+ Ok(())\n+}\n+\n+fn ensure_revision_matches(\n+ automation: &Automation,\n+ expected: &AutomationRevision,\n+) -> Result<(), AutomationStoreError> {\n+ if &automation.revision != expected {\n+ return Err(AutomationStoreError::RevisionMismatch);\n+ }\n+ Ok(())\n+}\n+\n+async fn atomic_write(dir: &Path, final_path: &Path, bytes: &[u8]) -> Result<(), AutomationStoreError> {\n+ let mut last_error = None;\n+ for attempt in 0..16_u8 {\n+ let temp_path = dir.join(temp_file_name(attempt));\n+ match tokio::fs::OpenOptions::new()\n+ .write(true)\n+ .create_new(true)\n+ .open(&temp_path)\n+ .await\n+ {\n+ Ok(mut file) => {\n+ file.write_all(bytes)\n+ .await\n+ .map_err(|err| AutomationStoreError::io(&temp_path, err))?;\n+ file.flush()\n+ .await\n+ .map_err(|err| AutomationStoreError::io(&temp_path, err))?;\n+ file.sync_all()\n+ .await\n+ .map_err(|err| AutomationStoreError::io(&temp_path, err))?;\n+ drop(file);\n+ if let Err(err) = tokio::fs::rename(&temp_path, final_path).await {\n+ let _ = tokio::fs::remove_file(&temp_path).await;\n+ return Err(AutomationStoreError::io(final_path, err));\n+ }\n+ return Ok(());\n+ }\n+ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {\n+ last_error = Some(err);\n+ }\n+ Err(err) => return Err(AutomationStoreError::io(&temp_path, err)),\n+ }\n+ }\n+\n+ Err(AutomationStoreError::io(\n+ dir,\n+ last_error.unwrap_or_else(|| {\n+ std::io::Error::new(\n+ std::io::ErrorKind::AlreadyExists,\n+ \"failed to allocate temporary automation file\",\n+ )\n+ }),\n+ ))\n+}\n+\n+fn temp_file_name(attempt: u8) -> String {\n+ let nanos = SystemTime::now()\n+ .duration_since(UNIX_EPOCH)\n+ .map_or(0, |duration| duration.as_nanos());\n+ format!(\".automation-{nanos}-{attempt}.tmp\")\n+}\n+\n+#[cfg(test)]\n+mod tests {\n+ use crate::model::{ApiTrigger, GitRefSelector, RepositorySlug, WorkflowSlug};\n+\n+ use super::*;\n+\n+ fn id(value: &str) -> AutomationId {\n+ AutomationId::try_from(value).expect(\"valid automation id\")\n+ }\n+\n+ fn trigger_id(value: &str) -> crate::AutomationTriggerId {\n+ crate::AutomationTriggerId::try_from(value).expect(\"valid trigger id\")\n+ }\n+\n+ fn target(workflow: &str) -> crate::AutomationTarget {\n+ crate::AutomationTarget {\n+ repository: RepositorySlug::try_from(\"fabro-sh/fabro\").expect(\"valid repo\"),\n+ ref_: GitRefSelector::try_from(\"main\").expect(\"valid ref\"),\n+ workflow: WorkflowSlug::try_from(workflow).expect(\"valid workflow\"),\n+ }\n+ }\n+\n+ fn draft(id_value: &str) -> AutomationDraft {\n+ AutomationDraft {\n+ id: id(id_value),\n+ name: \"Nightly dependency update\".to_string(),\n+ description: Some(\"Open a PR for dependency updates.\".to_string()),\n+ enabled: None,\n+ target: target(\"dependency-update\"),\n+ triggers: vec![crate::AutomationTrigger::Api(ApiTrigger {\n+ id: trigger_id(\"api\"),\n+ enabled: true,\n+ })],\n+ }\n+ }\n+\n+ #[tokio::test]\n+ async fn missing_directory_loads_empty_store() {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let store = AutomationStore::load(temp.path().join(\"automations\"))\n+ .await\n+ .expect(\"store should load\");\n+\n+ assert!(store.list().await.is_empty());\n+ }\n+\n+ #[tokio::test]\n+ async fn create_writes_file() {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let dir = temp.path().join(\"automations\");\n+ let store = AutomationStore::load(&dir).await.expect(\"store should load\");\n+\n+ let automation = store.create(draft(\"nightly-deps\")).await.expect(\"create\");\n+\n+ let path = dir.join(\"nightly-deps.toml\");\n+ assert!(path.is_file());\n+ let text = tokio::fs::read_to_string(path).await.expect(\"read file\");\n+ assert!(text.contains(\"name = \\\"Nightly dependency update\\\"\"));\n+ assert_eq!(automation.revision.as_str().len(), 64);\n+ }\n+\n+ #[tokio::test]\n+ async fn replace_changes_revision() {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let store = AutomationStore::load(temp.path().join(\"automations\"))\n+ .await\n+ .expect(\"store should load\");\n+ let automation = store.create(draft(\"nightly-deps\")).await.expect(\"create\");\n+\n+ let replacement = AutomationReplace {\n+ name: \"Renamed\".to_string(),\n+ description: automation.description.clone(),\n+ enabled: automation.enabled,\n+ target: automation.target.clone(),\n+ triggers: automation.triggers.clone(),\n+ };\n+ let replaced = store\n+ .replace(&automation.id, &automation.revision, replacement)\n+ .await\n+ .expect(\"replace\");\n+\n+ assert_eq!(replaced.name, \"Renamed\");\n+ assert_ne!(replaced.revision, automation.revision);\n+ }\n+\n+ #[tokio::test]\n+ async fn patch_keeps_unchanged_fields() {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let store = AutomationStore::load(temp.path().join(\"automations\"))\n+ .await\n+ .expect(\"store should load\");\n+ let automation = store.create(draft(\"nightly-deps\")).await.expect(\"create\");\n+\n+ let patch = AutomationPatch {\n+ description: Some(None),\n+ ..AutomationPatch::default()\n+ };\n+ let patched = store\n+ .patch(&automation.id, &automation.revision, patch)\n+ .await\n+ .expect(\"patch\");\n+\n+ assert_eq!(patched.name, automation.name);\n+ assert_eq!(patched.description, None);\n+ assert_eq!(patched.target, automation.target);\n+ }\n+\n+ #[tokio::test]\n+ async fn stale_revision_fails() {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let store = AutomationStore::load(temp.path().join(\"automations\"))\n+ .await\n+ .expect(\"store should load\");\n+ let automation = store.create(draft(\"nightly-deps\")).await.expect(\"create\");\n+ let stale = AutomationRevision::new(\"stale\");\n+\n+ let err = store\n+ .replace(\n+ &automation.id,\n+ &stale,\n+ AutomationReplace {\n+ name: automation.name.clone(),\n+ description: automation.description.clone(),\n+ enabled: automation.enabled,\n+ target: automation.target.clone(),\n+ triggers: automation.triggers.clone(),\n+ },\n+ )\n+ .await\n+ .expect_err(\"stale revision should fail\");\n+\n+ assert!(matches!(err, AutomationStoreError::RevisionMismatch));\n+ }\n+\n+ #[tokio::test]\n+ async fn delete_removes_file() {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let dir = temp.path().join(\"automations\");\n+ let store = AutomationStore::load(&dir).await.expect(\"store should load\");\n+ let automation = store.create(draft(\"nightly-deps\")).await.expect(\"create\");\n+\n+ store\n+ .delete(&automation.id, &automation.revision)\n+ .await\n+ .expect(\"delete\");\n+\n+ assert!(!dir.join(\"nightly-deps.toml\").exists());\n+ assert!(store.get(&automation.id).await.is_none());\n+ }\n+\n+ #[tokio::test]\n+ async fn startup_fails_on_malformed_toml() {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let dir = temp.path().join(\"automations\");\n+ tokio::fs::create_dir_all(&dir).await.expect(\"create dir\");\n+ tokio::fs::write(dir.join(\"bad.toml\"), \"name =\")\n+ .await\n+ .expect(\"write malformed file\");\n+\n+ let err = AutomationStore::load(&dir)\n+ .await\n+ .expect_err(\"malformed toml should fail\");\n+\n+ assert!(matches!(err, AutomationStoreError::Parse { .. }));\n+ }\n+}\ndiff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml\nindex f83c73981..3dbe0a861 100644\n--- a/lib/crates/fabro-server/Cargo.toml\n+++ b/lib/crates/fabro-server/Cargo.toml\n@@ -21,6 +21,7 @@ required-features = [\"test-support\"]\n workspace = true\n \n [dependencies]\n+fabro-automation = { path = \"../fabro-automation\" }\n fabro-auth = { path = \"../fabro-auth\" }\n fabro-install = { path = \"../fabro-install\" }\n fabro-spa = { path = \"../fabro-spa\" }\ndiff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs\nindex e8416bfc1..c663c97b2 100644\n--- a/lib/crates/fabro-server/src/run_files.rs\n+++ b/lib/crates/fabro-server/src/run_files.rs\n@@ -2374,6 +2374,7 @@ index 1111111..2222222 160000\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::default(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs\nindex 0aefe8f78..14328d5bb 100644\n--- a/lib/crates/fabro-server/src/run_manifest.rs\n+++ b/lib/crates/fabro-server/src/run_manifest.rs\n@@ -216,6 +216,7 @@ pub(crate) fn create_run_input(\n git: prepared.git,\n fork_source_ref: None,\n parent_id: prepared.parent_id,\n+ automation: None,\n provenance: None,\n configured_providers,\n web_url,\ndiff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs\nindex bd2638729..569b9bb17 100644\n--- a/lib/crates/fabro-server/src/server.rs\n+++ b/lib/crates/fabro-server/src/server.rs\n@@ -45,6 +45,7 @@ pub use fabro_api::types::{\n SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse,\n };\n use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message};\n+use fabro_automation::AutomationStore;\n #[cfg(test)]\n use fabro_config::RunSettingsBuilder;\n use fabro_config::daemon::ServerDaemon;\n@@ -933,6 +934,7 @@ pub struct AppState {\n runs: Mutex>,\n aggregate_billing: Mutex,\n store: Arc,\n+ automation_store: Arc,\n session_runtimes: SessionRuntimeManager,\n artifact_store: ArtifactStore,\n worker_tokens: WorkerTokenKeys,\n@@ -1263,6 +1265,10 @@ impl AppState {\n &self.store\n }\n \n+ pub(crate) fn automation_store(&self) -> Arc {\n+ Arc::clone(&self.automation_store)\n+ }\n+\n pub(crate) fn session_runtimes(&self) -> &SessionRuntimeManager {\n &self.session_runtimes\n }\n@@ -2154,10 +2160,19 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> anyhow::Result Router> {\n+ Router::new()\n+ .route(\"/automations\", get(list_automations).post(create_automation))\n+ .route(\n+ \"/automations/{id}\",\n+ get(get_automation)\n+ .put(replace_automation)\n+ .patch(patch_automation)\n+ .delete(delete_automation),\n+ )\n+}\n+\n+async fn list_automations(_auth: RequiredUser, State(state): State>) -> Response {\n+ let mut automations = state.automation_store().list().await;\n+ automations.sort_by(|left, right| left.id.cmp(&right.id));\n+ let total = automations.len();\n+\n+ (\n+ StatusCode::OK,\n+ Json(serde_json::json!({\n+ \"data\": automations,\n+ \"meta\": { \"total\": total }\n+ })),\n+ )\n+ .into_response()\n+}\n+\n+async fn create_automation(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ body: Bytes,\n+) -> Response {\n+ let draft = match parse_domain_json::(&body) {\n+ Ok(draft) => draft,\n+ Err(err) => return err.into_response(),\n+ };\n+\n+ match state.automation_store().create(draft).await {\n+ Ok(automation) => (StatusCode::CREATED, Json(automation)).into_response(),\n+ Err(err) => automation_store_error(err).into_response(),\n+ }\n+}\n+\n+async fn get_automation(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ Path(id): Path,\n+) -> Response {\n+ let id = match parse_automation_id(id) {\n+ Ok(id) => id,\n+ Err(err) => return err.into_response(),\n+ };\n+\n+ match state.automation_store().get(&id).await {\n+ Some(automation) => automation_response(StatusCode::OK, automation),\n+ None => ApiError::not_found(\"Automation not found.\").into_response(),\n+ }\n+}\n+\n+async fn replace_automation(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ Path(id): Path,\n+ headers: HeaderMap,\n+ body: Bytes,\n+) -> Response {\n+ let id = match parse_automation_id(id) {\n+ Ok(id) => id,\n+ Err(err) => return err.into_response(),\n+ };\n+ let expected = match parse_if_match(&headers) {\n+ Ok(revision) => revision,\n+ Err(err) => return err.into_response(),\n+ };\n+ let draft = match parse_domain_json::(&body) {\n+ Ok(draft) => draft,\n+ Err(err) => return err.into_response(),\n+ };\n+\n+ match state.automation_store().replace(&id, &expected, draft).await {\n+ Ok(automation) => automation_response(StatusCode::OK, automation),\n+ Err(err) => automation_store_error(err).into_response(),\n+ }\n+}\n+\n+async fn patch_automation(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ Path(id): Path,\n+ headers: HeaderMap,\n+ body: Bytes,\n+) -> Response {\n+ let id = match parse_automation_id(id) {\n+ Ok(id) => id,\n+ Err(err) => return err.into_response(),\n+ };\n+ let expected = match parse_if_match(&headers) {\n+ Ok(revision) => revision,\n+ Err(err) => return err.into_response(),\n+ };\n+ let patch = match parse_domain_json::(&body) {\n+ Ok(patch) => patch,\n+ Err(err) => return err.into_response(),\n+ };\n+\n+ match state.automation_store().patch(&id, &expected, patch).await {\n+ Ok(automation) => automation_response(StatusCode::OK, automation),\n+ Err(err) => automation_store_error(err).into_response(),\n+ }\n+}\n+\n+async fn delete_automation(\n+ _auth: RequiredUser,\n+ State(state): State>,\n+ Path(id): Path,\n+ headers: HeaderMap,\n+) -> Response {\n+ let id = match parse_automation_id(id) {\n+ Ok(id) => id,\n+ Err(err) => return err.into_response(),\n+ };\n+ let expected = match parse_if_match(&headers) {\n+ Ok(revision) => revision,\n+ Err(err) => return err.into_response(),\n+ };\n+\n+ match state.automation_store().delete(&id, &expected).await {\n+ Ok(()) => StatusCode::NO_CONTENT.into_response(),\n+ Err(err) => automation_store_error(err).into_response(),\n+ }\n+}\n+\n+fn automation_response(status: StatusCode, automation: fabro_automation::Automation) -> Response {\n+ let etag = format!(\"\\\"{}\\\"\", automation.revision.as_str());\n+ let etag = HeaderValue::from_str(&etag).expect(\"automation revisions are valid header values\");\n+ (status, [(header::ETAG, etag)], Json(automation)).into_response()\n+}\n+\n+fn parse_automation_id(id: String) -> Result {\n+ AutomationId::try_from(id).map_err(|err| ApiError::bad_request(err.to_string()))\n+}\n+\n+fn parse_if_match(headers: &HeaderMap) -> Result {\n+ let Some(value) = headers.get(header::IF_MATCH) else {\n+ return Err(ApiError::new(\n+ StatusCode::PRECONDITION_REQUIRED,\n+ \"Missing If-Match revision.\",\n+ ));\n+ };\n+ let value = value\n+ .to_str()\n+ .map_err(|_| ApiError::bad_request(\"Invalid If-Match revision.\"))?\n+ .trim();\n+ let unquoted = value\n+ .strip_prefix('\"')\n+ .and_then(|inner| inner.strip_suffix('\"'))\n+ .unwrap_or(value)\n+ .trim();\n+ if unquoted.is_empty() {\n+ return Err(ApiError::new(\n+ StatusCode::PRECONDITION_REQUIRED,\n+ \"Missing If-Match revision.\",\n+ ));\n+ }\n+ Ok(AutomationRevision::new(unquoted))\n+}\n+\n+fn parse_domain_json(body: &[u8]) -> Result\n+where\n+ T: serde::de::DeserializeOwned,\n+{\n+ let value: serde_json::Value =\n+ serde_json::from_slice(body).map_err(|err| ApiError::bad_request(err.to_string()))?;\n+ serde_json::from_value(value).map_err(|err| {\n+ ApiError::new(\n+ StatusCode::UNPROCESSABLE_ENTITY,\n+ format!(\"Invalid automation definition: {err}\"),\n+ )\n+ })\n+}\n+\n+fn automation_store_error(err: AutomationStoreError) -> ApiError {\n+ match err {\n+ AutomationStoreError::NotFound(_) => ApiError::not_found(\"Automation not found.\"),\n+ AutomationStoreError::AlreadyExists(_) => {\n+ ApiError::new(StatusCode::CONFLICT, \"Automation already exists.\")\n+ }\n+ AutomationStoreError::MissingRevision => ApiError::new(\n+ StatusCode::PRECONDITION_REQUIRED,\n+ \"Missing If-Match revision.\",\n+ ),\n+ AutomationStoreError::RevisionMismatch => {\n+ ApiError::new(StatusCode::CONFLICT, \"Automation revision mismatch.\")\n+ }\n+ AutomationStoreError::Validation(err) => {\n+ ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, err.to_string())\n+ }\n+ AutomationStoreError::Parse { .. }\n+ | AutomationStoreError::InvalidFilename { .. }\n+ | AutomationStoreError::Io { .. }\n+ | AutomationStoreError::Serialize(_) => {\n+ ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, \"Automation store error.\")\n+ }\n+ }\n+}\ndiff --git a/lib/crates/fabro-server/src/server/handler/events.rs b/lib/crates/fabro-server/src/server/handler/events.rs\nindex 8e7806522..8450f92b5 100644\n--- a/lib/crates/fabro-server/src/server/handler/events.rs\n+++ b/lib/crates/fabro-server/src/server/handler/events.rs\n@@ -573,6 +573,7 @@ mod stage_events_tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-server/src/server/handler/mod.rs b/lib/crates/fabro-server/src/server/handler/mod.rs\nindex 2f07ff0bf..577935151 100644\n--- a/lib/crates/fabro-server/src/server/handler/mod.rs\n+++ b/lib/crates/fabro-server/src/server/handler/mod.rs\n@@ -6,6 +6,7 @@ use axum::routing::{get, post};\n use super::{ApiError, AppState, IntoResponse, Response, StatusCode, demo};\n \n mod artifacts;\n+mod automations;\n mod billing;\n mod completions;\n pub(in crate::server) mod events;\n@@ -148,6 +149,7 @@ pub(super) fn real_routes() -> Router> {\n .route(\"/insights/execute\", post(not_implemented))\n .route(\"/insights/history\", get(not_implemented))\n .merge(runs::routes())\n+ .merge(automations::routes())\n .merge(events::routes())\n .merge(billing::routes())\n .merge(pull_requests::routes())\ndiff --git a/lib/crates/fabro-server/src/server/handler/pair.rs b/lib/crates/fabro-server/src/server/handler/pair.rs\nindex e43f4e6f8..833f3f3e5 100644\n--- a/lib/crates/fabro-server/src/server/handler/pair.rs\n+++ b/lib/crates/fabro-server/src/server/handler/pair.rs\n@@ -1027,6 +1027,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-server/src/server/handler/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs\nindex f87184289..01722b1c5 100644\n--- a/lib/crates/fabro-server/src/server/handler/sessions.rs\n+++ b/lib/crates/fabro-server/src/server/handler/sessions.rs\n@@ -1697,6 +1697,7 @@ mod tests {\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::default(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs\nindex 34a991f0f..243c19dbc 100644\n--- a/lib/crates/fabro-server/src/server/tests.rs\n+++ b/lib/crates/fabro-server/src/server/tests.rs\n@@ -127,6 +127,18 @@ methods = [\"dev-token\"]\n )\n }\n \n+#[tokio::test]\n+async fn automations_store_starts_empty_when_directory_is_absent() {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let active_config_path = temp.path().join(\"settings.toml\");\n+\n+ let state = TestAppStateBuilder::new()\n+ .active_config_path(active_config_path)\n+ .build();\n+\n+ assert!(state.automation_store().list().await.is_empty());\n+}\n+\n async fn body_json(body: Body) -> serde_json::Value {\n let bytes = to_bytes(body, usize::MAX).await.unwrap();\n serde_json::from_slice(&bytes).unwrap()\n@@ -3149,6 +3161,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -3194,6 +3207,7 @@ async fn create_slack_notification_run(\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -4199,6 +4213,7 @@ async fn list_run_stages_distinguishes_visits() {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -5183,6 +5198,7 @@ async fn create_completed_run_ready_for_pull_request(\n source_directory: Some(\"/tmp/project\".to_string()),\n git: git.clone(),\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -5206,6 +5222,7 @@ async fn create_completed_run_ready_for_pull_request(\n manifest_blob: None,\n git,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -10837,6 +10854,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc, run_id: RunId\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -11586,6 +11604,7 @@ async fn delete_run_retry_after_missing_provider_resource_removes_metadata() {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-server/tests/it/api/automations.rs b/lib/crates/fabro-server/tests/it/api/automations.rs\nnew file mode 100644\nindex 000000000..69352a729\n--- /dev/null\n+++ b/lib/crates/fabro-server/tests/it/api/automations.rs\n@@ -0,0 +1,270 @@\n+use axum::body::Body;\n+use axum::http::{Request, StatusCode, header};\n+use tower::ServiceExt;\n+\n+use crate::helpers::{api, checked_response, response_json};\n+\n+fn automation_request() -> serde_json::Value {\n+ serde_json::json!({\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"description\": \"Open a PR for dependency updates.\",\n+ \"target\": {\n+ \"repository\": \"fabro-sh/fabro\",\n+ \"ref\": \"main\",\n+ \"workflow\": \"dependency-update\"\n+ },\n+ \"triggers\": [\n+ { \"id\": \"api\", \"type\": \"api\", \"enabled\": true },\n+ { \"id\": \"nightly\", \"type\": \"schedule\", \"enabled\": true, \"expression\": \"0 3 * * *\" }\n+ ]\n+ })\n+}\n+\n+fn replace_request(name: &str) -> serde_json::Value {\n+ serde_json::json!({\n+ \"name\": name,\n+ \"description\": \"Open a PR for dependency updates.\",\n+ \"enabled\": true,\n+ \"target\": {\n+ \"repository\": \"fabro-sh/fabro\",\n+ \"ref\": \"main\",\n+ \"workflow\": \"dependency-update\"\n+ },\n+ \"triggers\": [\n+ { \"id\": \"api\", \"type\": \"api\", \"enabled\": true }\n+ ]\n+ })\n+}\n+\n+fn test_app() -> (axum::Router, tempfile::TempDir) {\n+ let temp = tempfile::tempdir().expect(\"tempdir\");\n+ let active_config_path = temp.path().join(\"settings.toml\");\n+ let state = fabro_server::test_support::TestAppStateBuilder::new()\n+ .active_config_path(active_config_path)\n+ .build();\n+ (fabro_server::test_support::build_test_router(state), temp)\n+}\n+\n+async fn json_request(\n+ app: &axum::Router,\n+ method: &str,\n+ path: &str,\n+ body: serde_json::Value,\n+ if_match: Option<&str>,\n+ expected: StatusCode,\n+) -> serde_json::Value {\n+ let mut builder = Request::builder()\n+ .method(method)\n+ .uri(api(path))\n+ .header(header::CONTENT_TYPE, \"application/json\");\n+ if let Some(revision) = if_match {\n+ builder = builder.header(header::IF_MATCH, revision);\n+ }\n+ let request = builder\n+ .body(Body::from(body.to_string()))\n+ .expect(\"request should build\");\n+ response_json(\n+ app.clone().oneshot(request).await.unwrap(),\n+ expected,\n+ format!(\"{method} /api/v1{path}\"),\n+ )\n+ .await\n+}\n+\n+async fn empty_request(\n+ app: &axum::Router,\n+ method: &str,\n+ path: &str,\n+ if_match: Option<&str>,\n+ expected: StatusCode,\n+) -> axum::response::Response {\n+ let mut builder = Request::builder().method(method).uri(api(path));\n+ if let Some(revision) = if_match {\n+ builder = builder.header(header::IF_MATCH, revision);\n+ }\n+ let request = builder.body(Body::empty()).expect(\"request should build\");\n+ checked_response(\n+ app.clone().oneshot(request).await.unwrap(),\n+ expected,\n+ format!(\"{method} /api/v1{path}\"),\n+ )\n+ .await\n+}\n+\n+#[tokio::test]\n+async fn automations_crud_lifecycle_persists_files_and_etags() {\n+ let (app, temp) = test_app();\n+\n+ let list = empty_request(&app, \"GET\", \"/automations\", None, StatusCode::OK).await;\n+ let list = crate::helpers::body_json(list.into_body()).await;\n+ assert_eq!(list, serde_json::json!({ \"data\": [], \"meta\": { \"total\": 0 } }));\n+\n+ let created = json_request(\n+ &app,\n+ \"POST\",\n+ \"/automations\",\n+ automation_request(),\n+ None,\n+ StatusCode::CREATED,\n+ )\n+ .await;\n+ assert_eq!(created[\"id\"], \"nightly-deps\");\n+ assert_eq!(created[\"enabled\"], true);\n+ assert_eq!(created[\"triggers\"][0][\"type\"], \"api\");\n+ assert!(temp.path().join(\"automations/nightly-deps.toml\").is_file());\n+\n+ let duplicate = json_request(\n+ &app,\n+ \"POST\",\n+ \"/automations\",\n+ automation_request(),\n+ None,\n+ StatusCode::CONFLICT,\n+ )\n+ .await;\n+ assert_eq!(duplicate[\"errors\"][0][\"status\"], \"409\");\n+\n+ let get_response = empty_request(&app, \"GET\", \"/automations/nightly-deps\", None, StatusCode::OK).await;\n+ let etag = get_response\n+ .headers()\n+ .get(header::ETAG)\n+ .expect(\"ETag header\")\n+ .to_str()\n+ .expect(\"ETag should be valid\")\n+ .to_string();\n+ let fetched = crate::helpers::body_json(get_response.into_body()).await;\n+ assert_eq!(fetched[\"revision\"], created[\"revision\"]);\n+\n+ let replaced = json_request(\n+ &app,\n+ \"PUT\",\n+ \"/automations/nightly-deps\",\n+ replace_request(\"Renamed automation\"),\n+ Some(&etag),\n+ StatusCode::OK,\n+ )\n+ .await;\n+ assert_eq!(replaced[\"name\"], \"Renamed automation\");\n+ assert_ne!(replaced[\"revision\"], created[\"revision\"]);\n+\n+ let stale = json_request(\n+ &app,\n+ \"PUT\",\n+ \"/automations/nightly-deps\",\n+ replace_request(\"Stale update\"),\n+ Some(&etag),\n+ StatusCode::CONFLICT,\n+ )\n+ .await;\n+ assert_eq!(stale[\"errors\"][0][\"status\"], \"409\");\n+\n+ let missing_if_match = json_request(\n+ &app,\n+ \"PATCH\",\n+ \"/automations/nightly-deps\",\n+ serde_json::json!({ \"enabled\": false }),\n+ None,\n+ StatusCode::PRECONDITION_REQUIRED,\n+ )\n+ .await;\n+ assert_eq!(missing_if_match[\"errors\"][0][\"status\"], \"428\");\n+\n+ let current_etag = format!(\"\\\"{}\\\"\", replaced[\"revision\"].as_str().unwrap());\n+ let patched = json_request(\n+ &app,\n+ \"PATCH\",\n+ \"/automations/nightly-deps\",\n+ serde_json::json!({ \"description\": null }),\n+ Some(¤t_etag),\n+ StatusCode::OK,\n+ )\n+ .await;\n+ assert_eq!(patched[\"description\"], serde_json::Value::Null);\n+ assert_eq!(patched[\"name\"], \"Renamed automation\");\n+\n+ let delete_etag = format!(\"\\\"{}\\\"\", patched[\"revision\"].as_str().unwrap());\n+ empty_request(\n+ &app,\n+ \"DELETE\",\n+ \"/automations/nightly-deps\",\n+ Some(&delete_etag),\n+ StatusCode::NO_CONTENT,\n+ )\n+ .await;\n+ assert!(!temp.path().join(\"automations/nightly-deps.toml\").exists());\n+\n+ empty_request(&app, \"GET\", \"/automations/nightly-deps\", None, StatusCode::NOT_FOUND).await;\n+}\n+\n+#[tokio::test]\n+async fn automations_validation_errors_return_422() {\n+ let (app, _temp) = test_app();\n+\n+ let cases = [\n+ (\n+ \"invalid trigger id\",\n+ serde_json::json!({\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"target\": { \"repository\": \"fabro-sh/fabro\", \"ref\": \"main\", \"workflow\": \"dependency-update\" },\n+ \"triggers\": [{ \"id\": \"Bad\", \"type\": \"api\", \"enabled\": true }]\n+ }),\n+ ),\n+ (\n+ \"duplicate trigger ids\",\n+ serde_json::json!({\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"target\": { \"repository\": \"fabro-sh/fabro\", \"ref\": \"main\", \"workflow\": \"dependency-update\" },\n+ \"triggers\": [\n+ { \"id\": \"api\", \"type\": \"api\", \"enabled\": true },\n+ { \"id\": \"api\", \"type\": \"schedule\", \"enabled\": true, \"expression\": \"0 3 * * *\" }\n+ ]\n+ }),\n+ ),\n+ (\n+ \"two api triggers\",\n+ serde_json::json!({\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"target\": { \"repository\": \"fabro-sh/fabro\", \"ref\": \"main\", \"workflow\": \"dependency-update\" },\n+ \"triggers\": [\n+ { \"id\": \"api\", \"type\": \"api\", \"enabled\": true },\n+ { \"id\": \"api2\", \"type\": \"api\", \"enabled\": true }\n+ ]\n+ }),\n+ ),\n+ (\n+ \"invalid schedule expression\",\n+ serde_json::json!({\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"target\": { \"repository\": \"fabro-sh/fabro\", \"ref\": \"main\", \"workflow\": \"dependency-update\" },\n+ \"triggers\": [{ \"id\": \"nightly\", \"type\": \"schedule\", \"enabled\": true, \"expression\": \"not a cron\" }]\n+ }),\n+ ),\n+ (\n+ \"unknown trigger type\",\n+ serde_json::json!({\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"target\": { \"repository\": \"fabro-sh/fabro\", \"ref\": \"main\", \"workflow\": \"dependency-update\" },\n+ \"triggers\": [{ \"id\": \"api\", \"type\": \"event\", \"enabled\": true }]\n+ }),\n+ ),\n+ ];\n+\n+ for (name, body) in cases {\n+ let response = json_request(\n+ &app,\n+ \"POST\",\n+ \"/automations\",\n+ body,\n+ None,\n+ StatusCode::UNPROCESSABLE_ENTITY,\n+ )\n+ .await;\n+ assert_eq!(response[\"errors\"][0][\"status\"], \"422\", \"{name}\");\n+ }\n+}\ndiff --git a/lib/crates/fabro-server/tests/it/api/mod.rs b/lib/crates/fabro-server/tests/it/api/mod.rs\nindex 353b4ec95..a0207ccc7 100644\n--- a/lib/crates/fabro-server/tests/it/api/mod.rs\n+++ b/lib/crates/fabro-server/tests/it/api/mod.rs\n@@ -1,4 +1,5 @@\n mod auth_sessions;\n+mod automations;\n mod cli_auth_token;\n mod docs;\n mod events;\ndiff --git a/lib/crates/fabro-server/tests/it/api/run_files.rs b/lib/crates/fabro-server/tests/it/api/run_files.rs\nindex e820b00f9..10d163026 100644\n--- a/lib/crates/fabro-server/tests/it/api/run_files.rs\n+++ b/lib/crates/fabro-server/tests/it/api/run_files.rs\n@@ -72,6 +72,7 @@ async fn append_completed_run_with_final_patch(\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-store/src/run_state.rs b/lib/crates/fabro-store/src/run_state.rs\nindex a2e38e843..59ea66c3e 100644\n--- a/lib/crates/fabro-store/src/run_state.rs\n+++ b/lib/crates/fabro-store/src/run_state.rs\n@@ -781,6 +781,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result {\n workflow_slug: props.workflow_slug.clone(),\n source_directory: props.source_directory.clone(),\n labels,\n+ automation: props.automation.clone(),\n provenance: props.provenance.clone(),\n manifest_blob: props.manifest_blob,\n definition_blob: None,\n@@ -937,7 +938,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run {\n edge_count: i64::try_from(state.spec.graph.edges.len())\n .expect(\"graph edge count should fit in i64\"),\n },\n- automation: None,\n+ automation: state.spec.automation.clone(),\n repository: Some(RepositoryRef::from_origin_and_source(\n repo_origin_url,\n source_directory.as_deref(),\n@@ -1248,7 +1249,7 @@ mod tests {\n StagePromptProps, StageRetryingProps, StageStartedProps,\n };\n use fabro_types::{\n- AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint,\n+ AgentBackend, AutomationRef, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint,\n CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail,\n FailureReason, Graph, McpServerStatus, Outcome, PendingReason, PermissionLevel,\n PullRequestLink, QuestionType, ReasoningEffort, RunApprovalState, RunBlobId,\n@@ -1337,6 +1338,7 @@ mod tests {\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -1394,6 +1396,38 @@ mod tests {\n );\n }\n \n+ #[test]\n+ fn run_created_automation_projects_into_summary() {\n+ let event = test_raw_event(\n+ 1,\n+ \"run.created\",\n+ &json!({\n+ \"settings\": WorkflowSettings::default(),\n+ \"graph\": Graph::new(\"test\"),\n+ \"labels\": {},\n+ \"automation\": {\n+ \"id\": \"nightly-deps\",\n+ \"name\": \"Nightly dependency update\",\n+ \"trigger_id\": \"api\"\n+ },\n+ \"run_dir\": \"/tmp/run\"\n+ }),\n+ None,\n+ );\n+\n+ let projection = RunProjection::apply_events(&[event]).unwrap();\n+ let expected = Some(AutomationRef {\n+ id: \"nightly-deps\".to_string(),\n+ name: Some(\"Nightly dependency update\".to_string()),\n+ trigger_id: Some(\"api\".to_string()),\n+ });\n+ assert_eq!(projection.spec.automation, expected);\n+ assert_eq!(\n+ build_summary(&projection, &fixtures::RUN_1).automation,\n+ expected\n+ );\n+ }\n+\n fn test_raw_event(\n seq: u32,\n event: &str,\n@@ -2606,6 +2640,7 @@ mod tests {\n source_directory: Some(\"/tmp/repo\".to_string()),\n git: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -2631,6 +2666,7 @@ mod tests {\n source_directory: Some(\"/tmp/repo\".to_string()),\n git: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs\nindex 784868ace..d4ccd2d6b 100644\n--- a/lib/crates/fabro-store/src/slate/mod.rs\n+++ b/lib/crates/fabro-store/src/slate/mod.rs\n@@ -541,6 +541,7 @@ mod tests {\n workflow_slug: Some(\"night-sky\".to_string()),\n source_directory: Some(format!(\"/tmp/{label}\")),\n labels: std::collections::HashMap::from([(\"team\".to_string(), \"infra\".to_string())]),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs\nindex 2707ca353..265e72ad0 100644\n--- a/lib/crates/fabro-store/tests/serializable_projection.rs\n+++ b/lib/crates/fabro-store/tests/serializable_projection.rs\n@@ -21,6 +21,7 @@ fn sample_run_spec() -> RunSpec {\n workflow_slug: Some(\"demo\".to_string()),\n source_directory: Some(\"/tmp/project\".to_string()),\n labels: HashMap::from([(\"team\".to_string(), \"platform\".to_string())]),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs\nindex 269db43ee..2df593ad7 100644\n--- a/lib/crates/fabro-types/src/run.rs\n+++ b/lib/crates/fabro-types/src/run.rs\n@@ -2,6 +2,7 @@ use std::collections::HashMap;\n \n use serde::{Deserialize, Serialize};\n \n+use crate::AutomationRef;\n use crate::WorkflowSettings;\n use crate::graph::Graph;\n use crate::principal::Principal;\n@@ -91,6 +92,8 @@ pub struct RunSpec {\n #[serde(default, skip_serializing_if = \"HashMap::is_empty\")]\n pub labels: HashMap,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub automation: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub provenance: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub manifest_blob: Option,\ndiff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs\nindex fa189171f..2230d810a 100644\n--- a/lib/crates/fabro-types/src/run_event/run.rs\n+++ b/lib/crates/fabro-types/src/run_event/run.rs\n@@ -5,8 +5,8 @@ use serde::{Deserialize, Serialize};\n use super::{BilledTokenCounts, ExecOutputTail, RunNoticeLevel};\n use crate::status::{BlockedReason, PendingReason, SuccessReason};\n use crate::{\n- DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId, RunControlAction,\n- RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings,\n+ AutomationRef, DiffSummary, ForkSourceRef, GitContext, Graph, PairId, PairTarget, RunBlobId,\n+ RunControlAction, RunFailure, RunId, RunProvenance, RunTiming, WorkflowSettings,\n };\n \n #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n@@ -21,6 +21,8 @@ pub struct RunCreatedProps {\n pub workflow_config: Option,\n #[serde(default, skip_serializing_if = \"BTreeMap::is_empty\")]\n pub labels: BTreeMap,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub automation: Option,\n pub run_dir: String,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n pub source_directory: Option,\ndiff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs\nindex 3bba6d43e..7974d1ae7 100644\n--- a/lib/crates/fabro-types/src/run_projection.rs\n+++ b/lib/crates/fabro-types/src/run_projection.rs\n@@ -698,6 +698,7 @@ mod title_tests {\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -767,6 +768,7 @@ mod iter_stages_tests {\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::default(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-types/src/run_summary.rs b/lib/crates/fabro-types/src/run_summary.rs\nindex fb5e7f576..a81f77099 100644\n--- a/lib/crates/fabro-types/src/run_summary.rs\n+++ b/lib/crates/fabro-types/src/run_summary.rs\n@@ -104,9 +104,11 @@ pub struct WorkflowRef {\n \n #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n pub struct AutomationRef {\n- pub id: String,\n+ pub id: String,\n #[serde(default)]\n- pub name: Option,\n+ pub name: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ pub trigger_id: Option,\n }\n \n #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\ndiff --git a/lib/crates/fabro-types/tests/run_event_serde.rs b/lib/crates/fabro-types/tests/run_event_serde.rs\nindex 8f2df1972..9a1cd7e48 100644\n--- a/lib/crates/fabro-types/tests/run_event_serde.rs\n+++ b/lib/crates/fabro-types/tests/run_event_serde.rs\n@@ -23,6 +23,7 @@ fn run_created_props_round_trip_templated_settings() {\n workflow_source: Some(\"digraph Ship { start -> exit }\".to_string()),\n workflow_config: Some(\"[run]\\ngoal = \\\"Ship {{ env.TASK }}\\\"\".to_string()),\n labels: BTreeMap::from([(\"team\".to_string(), \"platform\".to_string())]),\n+ automation: None,\n run_dir: \"/tmp/run\".to_string(),\n source_directory: Some(\"/Users/client/project\".to_string()),\n workflow_slug: Some(\"demo\".to_string()),\n@@ -85,6 +86,7 @@ fn run_created_props_omits_web_url_when_absent() {\n workflow_source: None,\n workflow_config: None,\n labels: BTreeMap::new(),\n+ automation: None,\n run_dir: \"/tmp/run\".to_string(),\n source_directory: None,\n workflow_slug: None,\ndiff --git a/lib/crates/fabro-types/tests/run_spec_methods.rs b/lib/crates/fabro-types/tests/run_spec_methods.rs\nindex f5e8cf25f..9608c133c 100644\n--- a/lib/crates/fabro-types/tests/run_spec_methods.rs\n+++ b/lib/crates/fabro-types/tests/run_spec_methods.rs\n@@ -26,6 +26,7 @@ fn sample_run_spec() -> RunSpec {\n workflow_slug: Some(\"demo\".to_string()),\n source_directory: Some(\"/Users/client/project\".to_string()),\n labels: HashMap::from([(\"team\".to_string(), \"platform\".to_string())]),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-types/tests/run_spec_serde.rs b/lib/crates/fabro-types/tests/run_spec_serde.rs\nindex f6278ff34..8e71ec4d4 100644\n--- a/lib/crates/fabro-types/tests/run_spec_serde.rs\n+++ b/lib/crates/fabro-types/tests/run_spec_serde.rs\n@@ -22,6 +22,7 @@ fn run_spec_round_trips_templated_settings() {\n workflow_slug: Some(\"demo\".to_string()),\n source_directory: Some(\"/Users/client/project\".to_string()),\n labels: HashMap::from([(\"team\".to_string(), \"platform\".to_string())]),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-workflow/src/billing_rollup.rs b/lib/crates/fabro-workflow/src/billing_rollup.rs\nindex 0e909ce08..e7bcbf643 100644\n--- a/lib/crates/fabro-workflow/src/billing_rollup.rs\n+++ b/lib/crates/fabro-workflow/src/billing_rollup.rs\n@@ -372,6 +372,7 @@ mod tests {\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-workflow/src/event/convert.rs b/lib/crates/fabro-workflow/src/event/convert.rs\nindex 2e2014dc1..4d4947af6 100644\n--- a/lib/crates/fabro-workflow/src/event/convert.rs\n+++ b/lib/crates/fabro-workflow/src/event/convert.rs\n@@ -39,6 +39,7 @@ fn event_body_from_event(event: &Event) -> EventBody {\n manifest_blob,\n git,\n fork_source_ref,\n+ automation,\n retried_from,\n parent_id,\n web_url,\n@@ -59,6 +60,7 @@ fn event_body_from_event(event: &Event) -> EventBody {\n manifest_blob: *manifest_blob,\n git: git.clone(),\n fork_source_ref: fork_source_ref.clone(),\n+ automation: automation.clone(),\n retried_from: *retried_from,\n parent_id: *parent_id,\n web_url: web_url.clone(),\n@@ -2439,6 +2441,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/event/events.rs b/lib/crates/fabro-workflow/src/event/events.rs\nindex a38b184bb..0af7b7838 100644\n--- a/lib/crates/fabro-workflow/src/event/events.rs\n+++ b/lib/crates/fabro-workflow/src/event/events.rs\n@@ -1,7 +1,7 @@\n use std::collections::BTreeMap;\n \n use ::fabro_types::{\n- BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason,\n+ AutomationRef, BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason,\n ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget,\n ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, RunBlobId,\n RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance,\n@@ -48,6 +48,8 @@ pub enum Event {\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n fork_source_ref: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n+ automation: Option,\n+ #[serde(default, skip_serializing_if = \"Option::is_none\")]\n retried_from: Option,\n #[serde(default, skip_serializing_if = \"Option::is_none\")]\n parent_id: Option,\ndiff --git a/lib/crates/fabro-workflow/src/event/sink.rs b/lib/crates/fabro-workflow/src/event/sink.rs\nindex 967f0570a..a84163171 100644\n--- a/lib/crates/fabro-workflow/src/event/sink.rs\n+++ b/lib/crates/fabro-workflow/src/event/sink.rs\n@@ -247,6 +247,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/git.rs b/lib/crates/fabro-workflow/src/git.rs\nindex f48683dd0..4509e7e95 100644\n--- a/lib/crates/fabro-workflow/src/git.rs\n+++ b/lib/crates/fabro-workflow/src/git.rs\n@@ -472,6 +472,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/handler/agent.rs b/lib/crates/fabro-workflow/src/handler/agent.rs\nindex 3290ca85a..3e124bca2 100644\n--- a/lib/crates/fabro-workflow/src/handler/agent.rs\n+++ b/lib/crates/fabro-workflow/src/handler/agent.rs\n@@ -482,6 +482,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/handler/command.rs b/lib/crates/fabro-workflow/src/handler/command.rs\nindex 63922632a..a94392efa 100644\n--- a/lib/crates/fabro-workflow/src/handler/command.rs\n+++ b/lib/crates/fabro-workflow/src/handler/command.rs\n@@ -253,6 +253,7 @@ mod tests {\n workflow_slug: None,\n source_directory: None,\n labels: std::collections::HashMap::default(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -357,6 +358,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/handler/parallel.rs b/lib/crates/fabro-workflow/src/handler/parallel.rs\nindex 7c85042cc..da96e4d5e 100644\n--- a/lib/crates/fabro-workflow/src/handler/parallel.rs\n+++ b/lib/crates/fabro-workflow/src/handler/parallel.rs\n@@ -731,6 +731,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/handler/prompt.rs b/lib/crates/fabro-workflow/src/handler/prompt.rs\nindex 27b9fa524..dcb7d2089 100644\n--- a/lib/crates/fabro-workflow/src/handler/prompt.rs\n+++ b/lib/crates/fabro-workflow/src/handler/prompt.rs\n@@ -286,6 +286,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/lifecycle/git.rs b/lib/crates/fabro-workflow/src/lifecycle/git.rs\nindex bc34009d9..357d0e152 100644\n--- a/lib/crates/fabro-workflow/src/lifecycle/git.rs\n+++ b/lib/crates/fabro-workflow/src/lifecycle/git.rs\n@@ -730,6 +730,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/operations/archive.rs b/lib/crates/fabro-workflow/src/operations/archive.rs\nindex bb3693398..10de7cb65 100644\n--- a/lib/crates/fabro-workflow/src/operations/archive.rs\n+++ b/lib/crates/fabro-workflow/src/operations/archive.rs\n@@ -229,6 +229,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/operations/create.rs b/lib/crates/fabro-workflow/src/operations/create.rs\nindex 0460a1874..b10aee385 100644\n--- a/lib/crates/fabro-workflow/src/operations/create.rs\n+++ b/lib/crates/fabro-workflow/src/operations/create.rs\n@@ -13,7 +13,7 @@ use fabro_graphviz::graph::{AttrValue, Graph};\n use fabro_model::{Catalog, ProviderId};\n use fabro_store::Database;\n use fabro_types::{\n- ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,\n+ AutomationRef, ForkSourceRef, GitContext, ManifestPath, RunId, RunProvenance, WorkflowSettings,\n };\n use fabro_util::json::normalize_json_value;\n use tokio::task::spawn_blocking;\n@@ -44,6 +44,7 @@ pub struct CreateRunInput {\n pub git: Option,\n pub fork_source_ref: Option,\n pub parent_id: Option,\n+ pub automation: Option,\n pub provenance: Option,\n pub configured_providers: Vec,\n /// Public URL where this run can be viewed in the web UI, when the server\n@@ -70,6 +71,7 @@ struct PersistCreateOptions {\n source_directory: Option,\n git: Option,\n fork_source_ref: Option,\n+ automation: Option,\n provenance: Option,\n configured_providers: Vec,\n catalog: Arc,\n@@ -105,6 +107,7 @@ pub async fn create(\n git,\n fork_source_ref,\n parent_id,\n+ automation,\n provenance,\n configured_providers,\n web_url,\n@@ -146,6 +149,7 @@ pub async fn create(\n source_directory,\n git,\n fork_source_ref,\n+ automation,\n provenance,\n configured_providers,\n catalog,\n@@ -245,6 +249,7 @@ async fn persist_created_run(\n manifest_blob,\n git: record.git.clone(),\n fork_source_ref: record.fork_source_ref.clone(),\n+ automation: record.automation.clone(),\n retried_from: None,\n parent_id,\n web_url,\n@@ -358,6 +363,7 @@ fn persist_validated(\n source_directory,\n git,\n fork_source_ref,\n+ automation,\n provenance,\n configured_providers,\n catalog,\n@@ -386,6 +392,7 @@ fn persist_validated(\n definition_blob: None,\n git,\n fork_source_ref,\n+ automation,\n };\n \n pipeline::persist(validated, PersistOptions { run_dir, run_spec })\n@@ -1099,6 +1106,7 @@ mod tests {\n git: None,\n fork_source_ref: None,\n parent_id: None,\n+ automation: None,\n provenance: None,\n configured_providers: Vec::new(),\n web_url: None,\n@@ -1166,6 +1174,7 @@ mod tests {\n }),\n fork_source_ref: None,\n parent_id: None,\n+ automation: None,\n provenance: None,\n configured_providers: Vec::new(),\n web_url: None,\n@@ -1277,6 +1286,7 @@ mod tests {\n git: None,\n fork_source_ref: None,\n parent_id: None,\n+ automation: None,\n provenance: None,\n configured_providers: Vec::new(),\n web_url: None,\n@@ -1322,6 +1332,7 @@ mod tests {\n }),\n fork_source_ref: None,\n parent_id: None,\n+ automation: None,\n provenance: None,\n configured_providers: Vec::new(),\n web_url: None,\n@@ -1389,6 +1400,7 @@ mod tests {\n git: None,\n fork_source_ref: None,\n parent_id: None,\n+ automation: None,\n provenance: None,\n configured_providers: Vec::new(),\n web_url: None,\n@@ -1435,6 +1447,7 @@ mod tests {\n git: None,\n fork_source_ref: None,\n parent_id: None,\n+ automation: None,\n provenance: Some(fabro_types::RunProvenance {\n server: Some(fabro_types::RunServerProvenance {\n version: \"0.9.0\".to_string(),\ndiff --git a/lib/crates/fabro-workflow/src/operations/fork.rs b/lib/crates/fabro-workflow/src/operations/fork.rs\nindex 513975d71..4fba0fef2 100644\n--- a/lib/crates/fabro-workflow/src/operations/fork.rs\n+++ b/lib/crates/fabro-workflow/src/operations/fork.rs\n@@ -166,6 +166,7 @@ async fn persist_forked_run(\n manifest_blob: spec.manifest_blob,\n git: spec.git.clone(),\n fork_source_ref: spec.fork_source_ref.clone(),\n+ automation: spec.automation.clone(),\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -391,6 +392,7 @@ mod tests {\n push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,\n }),\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/operations/retry.rs b/lib/crates/fabro-workflow/src/operations/retry.rs\nindex 8527d2728..39e7eb134 100644\n--- a/lib/crates/fabro-workflow/src/operations/retry.rs\n+++ b/lib/crates/fabro-workflow/src/operations/retry.rs\n@@ -55,6 +55,7 @@ pub async fn retry_run(\n definition_blob,\n git,\n fork_source_ref,\n+ automation,\n } = source.spec;\n \n let settings = serde_json::to_value(&settings).map_err(|err| Error::engine(err.to_string()))?;\n@@ -81,6 +82,7 @@ pub async fn retry_run(\n manifest_blob,\n git,\n fork_source_ref,\n+ automation,\n retried_from: Some(source_run_id),\n parent_id,\n web_url: input.web_url.clone(),\n@@ -196,6 +198,7 @@ mod tests {\n manifest_blob,\n git: Some(git_context()),\n fork_source_ref,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/operations/start.rs b/lib/crates/fabro-workflow/src/operations/start.rs\nindex 66f1a5fc4..188661e64 100644\n--- a/lib/crates/fabro-workflow/src/operations/start.rs\n+++ b/lib/crates/fabro-workflow/src/operations/start.rs\n@@ -1334,6 +1334,7 @@ reasoning = false\n git: None,\n fork_source_ref: None,\n parent_id: None,\n+ automation: None,\n provenance: None,\n configured_providers: Vec::new(),\n web_url: None,\n@@ -1527,6 +1528,7 @@ reasoning = false\n git: None,\n fork_source_ref: None,\n parent_id: None,\n+ automation: None,\n provenance: None,\n configured_providers: Vec::new(),\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/operations/timeline.rs b/lib/crates/fabro-workflow/src/operations/timeline.rs\nindex 2170dc28a..54d9d5154 100644\n--- a/lib/crates/fabro-workflow/src/operations/timeline.rs\n+++ b/lib/crates/fabro-workflow/src/operations/timeline.rs\n@@ -247,6 +247,7 @@ mod tests {\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\nindex cef35cc39..59d0ccd93 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs\n@@ -164,6 +164,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI\n push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,\n }),\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -211,6 +212,7 @@ async fn seed_created_and_starting(\n manifest_blob: None,\n git: run_options.pre_run_git.clone(),\n fork_source_ref: run_options.fork_source_ref.clone(),\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/finalize.rs b/lib/crates/fabro-workflow/src/pipeline/finalize.rs\nindex 53692daab..fa80f6f9d 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/finalize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/finalize.rs\n@@ -742,6 +742,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -854,6 +855,7 @@ mod tests {\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\nindex c620c0151..029e988b7 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs\n@@ -863,6 +863,7 @@ mod tests {\n push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,\n }),\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs\nindex ee6150696..6643bfdd4 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/persist.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs\n@@ -147,6 +147,7 @@ mod tests {\n (\"env\".to_string(), \"test\".to_string()),\n (\"team\".to_string(), \"workflow\".to_string()),\n ]),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -173,6 +174,7 @@ mod tests {\n manifest_blob: None,\n git: record.git.clone(),\n fork_source_ref: record.fork_source_ref.clone(),\n+ automation: record.automation.clone(),\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs\nindex 92f4a0bc3..5b242d71f 100644\n--- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs\n+++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs\n@@ -822,6 +822,7 @@ mod tests {\n workflow_slug: None,\n source_directory: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -1146,6 +1147,7 @@ mod tests {\n push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,\n }),\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -1167,6 +1169,7 @@ mod tests {\n manifest_blob: None,\n git: run_spec.git.clone(),\n fork_source_ref: None,\n+ automation: run_spec.automation.clone(),\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -1215,6 +1218,7 @@ mod tests {\n push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,\n }),\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -1236,6 +1240,7 @@ mod tests {\n manifest_blob: None,\n git: run_spec.git.clone(),\n fork_source_ref: None,\n+ automation: run_spec.automation.clone(),\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -1569,6 +1574,7 @@ mod tests {\n source_directory: Some(tmp.path().display().to_string()),\n git: None,\n labels: std::collections::HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -1590,6 +1596,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: run_spec.automation.clone(),\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -1696,6 +1703,7 @@ mod tests {\n source_directory: Some(\"/tmp/project\".to_string()),\n git: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -1717,6 +1725,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: run_spec.automation.clone(),\n retried_from: None,\n parent_id: None,\n web_url: None,\n@@ -1865,6 +1874,7 @@ mod tests {\n source_directory: None,\n git: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -1886,6 +1896,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: run_spec.automation.clone(),\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/run_lookup.rs b/lib/crates/fabro-workflow/src/run_lookup.rs\nindex 5d8cdeb3b..ae139714c 100644\n--- a/lib/crates/fabro-workflow/src/run_lookup.rs\n+++ b/lib/crates/fabro-workflow/src/run_lookup.rs\n@@ -490,6 +490,7 @@ mod tests {\n push_outcome: fabro_types::PreRunPushOutcome::NotAttempted,\n }),\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -522,6 +523,7 @@ mod tests {\n manifest_blob: None,\n git: run_spec.git.clone(),\n fork_source_ref: run_spec.fork_source_ref.clone(),\n+ automation: run_spec.automation.clone(),\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/run_metadata.rs b/lib/crates/fabro-workflow/src/run_metadata.rs\nindex 9d679d0b4..02750a107 100644\n--- a/lib/crates/fabro-workflow/src/run_metadata.rs\n+++ b/lib/crates/fabro-workflow/src/run_metadata.rs\n@@ -638,6 +638,7 @@ mod tests {\n push_outcome: PreRunPushOutcome::NotAttempted,\n }),\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\ndiff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs\nindex 0f590c70f..4fd33aef4 100644\n--- a/lib/crates/fabro-workflow/src/runtime_store.rs\n+++ b/lib/crates/fabro-workflow/src/runtime_store.rs\n@@ -147,6 +147,7 @@ mod tests {\n source_directory: Some(\"/tmp/test\".to_string()),\n git: None,\n labels: HashMap::new(),\n+ automation: None,\n provenance: None,\n manifest_blob: None,\n definition_blob: None,\n@@ -172,6 +173,7 @@ mod tests {\n manifest_blob: None,\n git: None,\n fork_source_ref: None,\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\ndiff --git a/lib/crates/fabro-workflow/src/test_support.rs b/lib/crates/fabro-workflow/src/test_support.rs\nindex 7db2e1ddd..3d039e425 100644\n--- a/lib/crates/fabro-workflow/src/test_support.rs\n+++ b/lib/crates/fabro-workflow/src/test_support.rs\n@@ -128,6 +128,7 @@ async fn initialized(\n manifest_blob: None,\n git: run_options.pre_run_git.clone(),\n fork_source_ref: run_options.fork_source_ref.clone(),\n+ automation: None,\n retried_from: None,\n parent_id: None,\n web_url: None,\n", + "summary": { + "files_changed": 61, + "additions": 2989, + "deletions": 20 + } + } } ], - "conclusion": null, + "conclusion": { + "timestamp": "2026-05-24T22:01:17.492730Z", + "status": "failed", + "timing": { + "wall_time_ms": 2611699, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "failure": { + "reason": "cancelled", + "detail": { + "message": "Pipeline cancelled", + "category": "canceled" + } + }, + "final_git_commit_sha": "1378a701cb5584bc5d421bda40214ed97425fe2e", + "stages": [ + { + "stage_id": "start", + "stage_label": "start", + "timing": { + "wall_time_ms": 0, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "toolchain", + "stage_label": "toolchain", + "timing": { + "wall_time_ms": 1483, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "preflight_compile", + "stage_label": "preflight_compile", + "timing": { + "wall_time_ms": 126799, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "preflight_lint", + "stage_label": "preflight_lint", + "timing": { + "wall_time_ms": 139134, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + }, + { + "stage_id": "implement", + "stage_label": "implement", + "timing": { + "wall_time_ms": 2260190, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, + "retries": 0 + } + ], + "billing": { + "input_tokens": 7347929, + "output_tokens": 45957, + "total_tokens": 34484187, + "reasoning_tokens": 12040, + "cache_read_tokens": 27049622, + "cache_write_tokens": 28639 + }, + "total_retries": 0, + "diff": {} + }, "sandbox": { "provider": "daytona", "snapshot": "fabro-v12", @@ -847,6 +939,232 @@ "superseded_by": null, "pending_interviews": {}, "stages": { + "simplify_opus@1": { + "first_event_seq": 874, + "prompt": null, + "response": null, + "completion": null, + "provider_used": { + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" + }, + "diff": null, + "script_invocation": null, + "script_timing": null, + "parallel_results": null, + "output": null, + "started_at": "2026-05-24T22:00:10.538178Z", + "handler": "agent", + "usage": { + "input_tokens": 17766, + "output_tokens": 1124, + "total_tokens": 192575, + "reasoning_tokens": 0, + "cache_read_tokens": 145046, + "cache_write_tokens": 28639 + }, + "model": { + "provider": "anthropic", + "model_id": "claude-opus-4-7" + }, + "permission_level": "full", + "agent_tools": [ + { + "name": "AskUserQuestion", + "description": "Ask the human one or more questions and wait for their answers before continuing this stage.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskCreate", + "description": "Create pending tasks in the current session. Use concise subjects, descriptions, optional activeForm text, and metadata. Check TaskList first to avoid duplicate tasks.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskGet", + "description": "Get one task by taskId, including subject, status, description, owner, blockedBy, and blocks.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskList", + "description": "List tasks for the current session, including status, owner, and blocking dependencies. Use TaskGet with a taskId for full description and dependency details.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "TaskUpdate", + "description": "Update an existing task's status, text, owner, metadata, or dependencies. Valid statuses are pending, in_progress, completed, and deleted. After completing a task, call TaskList to find newly unblocked work.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "close_agent", + "description": "Close a running subagent that is no longer needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "edit_file", + "description": "Edit a file by replacing an exact string. The old_string must be an exact match and unique unless replace_all is true; include surrounding context when needed. Read the file first and preserve existing indentation.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": false + }, + { + "name": "glob", + "description": "Find files by file names using a glob pattern. Use path to choose the search root. Prefer this over shell find or ls when locating repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": false + }, + { + "name": "grep", + "description": "Search file contents with a regex pattern. Use path to choose the search root, glob_filter to limit matching files, case_insensitive for case folding, and max_results to cap output.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": false + }, + { + "name": "read_file", + "description": "Read files before editing them. Returns line-numbered text and supports offset/limit for large files. Use this instead of shell cat, head, tail, or sed when inspecting repository files.", + "source": { + "kind": "native" + }, + "category": "read", + "invoked": false + }, + { + "name": "send_input", + "description": "Send a follow-up message to a running subagent when new information or corrected instructions are needed.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "shell", + "description": "Execute shell commands for terminal operations, package managers, tests and builds. Use dedicated tools for file reads, file edits, filename searches, and content searches. Provide timeout_ms for long-running commands.", + "source": { + "kind": "native" + }, + "category": "shell", + "invoked": true + }, + { + "name": "spawn_agent", + "description": "Spawn a subagent for independent work or context isolation. Use it for tasks that can proceed separately, and avoid duplicating the same work in the parent session.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "wait", + "description": "Wait for a subagent to complete, then use the result to synthesize the outcome for the user.", + "source": { + "kind": "native" + }, + "category": "subagent", + "invoked": false + }, + { + "name": "web_fetch", + "description": "Fetch content from a URL that starts with http:// or https://. Pass a prompt to extract specific information or summarize the page; omit prompt to return the page content.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "web_search", + "description": "Search the web using Brave Search when current external information is needed. Returns result titles, URLs, and descriptions; use web_fetch for a specific URL.", + "source": { + "kind": "native" + }, + "category": "other", + "invoked": false + }, + { + "name": "write_file", + "description": "Create new files, or overwrite an existing file only when replacement is explicitly intended. Prefer edit_file for targeted changes to existing files because write_file overwrites the full file content.", + "source": { + "kind": "native" + }, + "category": "write", + "invoked": false + } + ], + "context_window": { + "provider": "anthropic", + "model": "claude-opus-4-7", + "context_window_tokens": 1000000, + "input_tokens": 29810, + "usage_percent": 2.981, + "count_method": "response_usage_scaled_breakdown", + "staleness": "live", + "generated_at": "2026-05-24T22:01:17.190992Z", + "event_seq": 900, + "breakdown": [ + { + "category": "system_prompt", + "tokens": 2797, + "usage_percent": 0.2797 + }, + { + "category": "tools", + "tokens": 3174, + "usage_percent": 0.3174 + }, + { + "category": "memory", + "tokens": 6631, + "usage_percent": 0.6631 + }, + { + "category": "conversation", + "tokens": 17200, + "usage_percent": 1.72 + }, + { + "category": "other", + "tokens": 8, + "usage_percent": 0.0008 + } + ], + "warnings": [] + }, + "state": "running" + }, "toolchain@1": { "first_event_seq": 21, "prompt": null, @@ -933,7 +1251,12 @@ "first_event_seq": 51, "prompt": null, "response": null, - "completion": null, + "completion": { + "outcome": "failed", + "notes": null, + "failure_reason": "LLM error: Context length exceeded for openai: Your input exceeds the context window of this model. Please adjust your input and try again.", + "timestamp": "2026-05-24T22:00:06.325028Z" + }, "provider_used": { "mode": "agent", "provider": "openai", @@ -947,6 +1270,12 @@ "output": null, "started_at": "2026-05-24T21:22:26.120276Z", "handler": "agent", + "timing": { + "wall_time_ms": 2260190, + "inference_time_ms": 0, + "tool_time_ms": 0, + "active_time_ms": 0 + }, "usage": { "input_tokens": 7330163, "output_tokens": 44833, @@ -1159,7 +1488,7 @@ ], "warnings": [] }, - "state": "running" + "state": "failed" }, "preflight_compile@1": { "first_event_seq": 31, diff --git a/stages/005-implement@1/diff.patch b/stages/005-implement@1/diff.patch new file mode 100644 index 000000000..1a414cde6 --- /dev/null +++ b/stages/005-implement@1/diff.patch @@ -0,0 +1,4211 @@ +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 5a2f9b751..3b68437d3 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 +@@ -3906,6 +3908,372 @@ paths: + schema: + $ref: "#/components/schemas/ErrorResponse" + ++ # ── Automations ────────────────────────────────────────────────────── ++ ++ /api/v1/automations: ++ get: ++ operationId: listAutomations ++ tags: [Automations] ++ summary: List automations ++ description: Returns all server-owned automation definitions sorted by ID. ++ responses: ++ "200": ++ description: Automation definitions ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/AutomationListResponse" ++ post: ++ operationId: createAutomation ++ tags: [Automations] ++ summary: Create automation ++ description: Creates and persists a server-owned automation definition. ++ 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 definition ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ ++ /api/v1/automations/{id}: ++ get: ++ operationId: getAutomation ++ tags: [Automations] ++ summary: Get automation ++ parameters: ++ - $ref: "#/components/parameters/AutomationId" ++ responses: ++ "200": ++ description: Automation definition ++ headers: ++ ETag: ++ $ref: "#/components/headers/ETag" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/Automation" ++ "400": ++ description: Invalid automation ID 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: ++ - $ref: "#/components/parameters/AutomationId" ++ - $ref: "#/components/parameters/AutomationRevision" ++ requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ReplaceAutomationRequest" ++ responses: ++ "200": ++ description: Automation replaced ++ headers: ++ ETag: ++ $ref: "#/components/headers/ETag" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/Automation" ++ "400": ++ description: Malformed JSON or invalid automation ID 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: Revision mismatch ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "422": ++ description: Invalid automation definition ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "428": ++ description: Missing If-Match revision ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ patch: ++ operationId: patchAutomation ++ tags: [Automations] ++ summary: Patch automation ++ description: Applies a shallow patch to an automation definition. ++ parameters: ++ - $ref: "#/components/parameters/AutomationId" ++ - $ref: "#/components/parameters/AutomationRevision" ++ requestBody: ++ required: true ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/PatchAutomationRequest" ++ responses: ++ "200": ++ description: Automation patched ++ headers: ++ ETag: ++ $ref: "#/components/headers/ETag" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/Automation" ++ "400": ++ description: Malformed JSON or invalid automation ID 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: Revision mismatch ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "422": ++ description: Invalid automation definition ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "428": ++ description: Missing If-Match revision ++ 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: ++ - $ref: "#/components/parameters/AutomationId" ++ - $ref: "#/components/parameters/AutomationRevision" ++ responses: ++ "204": ++ description: Automation deleted ++ "400": ++ description: Invalid automation ID 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: Revision mismatch ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ "428": ++ description: Missing If-Match revision ++ headers: ++ x-request-id: ++ $ref: "#/components/headers/XRequestId" ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/ErrorResponse" ++ ++ /api/v1/automations/{id}/runs: ++ get: ++ operationId: listAutomationRuns ++ tags: [Automations] ++ summary: List automation runs ++ description: Returns durable runs linked to an existing automation, newest first. ++ parameters: ++ - $ref: "#/components/parameters/AutomationId" ++ - $ref: "#/components/parameters/PageLimit" ++ - $ref: "#/components/parameters/PageOffset" ++ responses: ++ "200": ++ description: Paginated runs for the automation ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/PaginatedRunList" ++ "400": ++ description: Invalid automation ID 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: Creates a submitted run by materializing an automation target through its enabled `api` trigger. ++ parameters: ++ - $ref: "#/components/parameters/AutomationId" ++ responses: ++ "201": ++ description: Run created from automation ++ content: ++ application/json: ++ schema: ++ $ref: "#/components/schemas/Run" ++ "400": ++ description: Invalid automation ID 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 is disabled or has no 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" ++ + # ── Workflows ──────────────────────────────────────────────────────── + + /api/v1/workflows: +@@ -4576,6 +4944,25 @@ components: + type: string + example: 01JNQVR7M0EJ5GKAT2SC4ERS1Z + ++ AutomationId: ++ name: id ++ in: path ++ required: true ++ description: Automation identifier. The canonical ID is the TOML filename stem. ++ schema: ++ type: string ++ pattern: "^[a-z0-9][a-z0-9-]{0,62}$" ++ example: nightly-deps ++ ++ AutomationRevision: ++ name: If-Match ++ in: header ++ required: true ++ description: Current automation revision, quoted or unquoted. ++ schema: ++ type: string ++ example: '"d2d2c1f38c4fd03b6f2c14c9b5e4a8ff4d1bf74c1b8f2dbf0bff650f7e8e0f5a"' ++ + RunSelector: + name: selector + in: query +@@ -4824,6 +5211,11 @@ components: + schema: + type: string + format: uuid ++ ETag: ++ description: Current automation revision. ++ schema: ++ type: string ++ example: '"d2d2c1f38c4fd03b6f2c14c9b5e4a8ff4d1bf74c1b8f2dbf0bff650f7e8e0f5a"' + + schemas: + AuthConfigResponse: +@@ -5305,6 +5697,202 @@ components: + chose the personal access token flow; GitHub App installs rely on + OAuth and do not receive a dev token. + ++ # ── Automations ────────────────────────────────────────────────────── ++ ++ 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 of the persisted TOML bytes. ++ pattern: "^[0-9a-f]{64}$" ++ name: ++ type: string ++ description: ++ type: ["string", "null"] ++ enabled: ++ type: boolean ++ target: ++ $ref: "#/components/schemas/AutomationTarget" ++ triggers: ++ type: array ++ items: ++ $ref: "#/components/schemas/AutomationTrigger" ++ ++ AutomationTarget: ++ 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 checkout. ++ example: main ++ workflow: ++ type: string ++ description: Workflow slug or relative workflow path inside the repository. ++ example: dependency-update ++ ++ AutomationTrigger: ++ 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"] ++ default: 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"] ++ default: 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: ++ 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: int64 ++ minimum: 0 ++ + # ── Pagination ─────────────────────────────────────────────────────── + + PaginationMeta: +@@ -9445,6 +10033,9 @@ components: + type: string + name: + type: ["string", "null"] ++ trigger_id: ++ type: string ++ description: User-visible trigger ID that started the run, when known. + + 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..bbfb9df24 100644 +--- a/lib/crates/fabro-api/build.rs ++++ b/lib/crates/fabro-api/build.rs +@@ -201,6 +201,34 @@ fn main() { + &[], + ), + ("Run", "fabro_types::Run", &[]), ++ ("Automation", "fabro_automation::Automation", &[]), ++ ("AutomationTarget", "fabro_automation::AutomationTarget", &[]), ++ ("AutomationTrigger", "fabro_automation::AutomationTrigger", &[]), ++ ( ++ "AutomationApiTrigger", ++ "fabro_automation::ApiTrigger", ++ &[], ++ ), ++ ( ++ "AutomationScheduleTrigger", ++ "fabro_automation::ScheduleTrigger", ++ &[], ++ ), ++ ( ++ "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..5c17b451f +--- /dev/null ++++ b/lib/crates/fabro-api/tests/automation_round_trip.rs +@@ -0,0 +1,135 @@ ++use std::any::{TypeId, type_name}; ++ ++use fabro_api::types::{ ++ Automation as ApiAutomation, AutomationApiTrigger as ApiAutomationApiTrigger, ++ AutomationScheduleTrigger as ApiAutomationScheduleTrigger, ++ AutomationTarget as ApiAutomationTarget, AutomationTrigger as ApiAutomationTrigger, ++ CreateAutomationRequest as ApiCreateAutomationRequest, ++ PatchAutomationRequest as ApiPatchAutomationRequest, ++ ReplaceAutomationRequest as ApiReplaceAutomationRequest, ++}; ++use fabro_automation::{ ++ ApiTrigger, Automation, AutomationDraft, AutomationPatch, AutomationReplace, AutomationTarget, ++ AutomationTrigger, ScheduleTrigger, ++}; ++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::(); ++ assert_same_type::(); ++ assert_same_type::(); ++} ++ ++#[test] ++fn automation_response_round_trips_json_shape() { ++ let value = json!({ ++ "id": "nightly-deps", ++ "revision": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", ++ "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 * * *" } ++ ] ++ }); ++ ++ let automation: ApiAutomation = serde_json::from_value(value.clone()).unwrap(); ++ assert_eq!(serde_json::to_value(&automation).unwrap(), value); ++} ++ ++#[test] ++fn create_automation_request_round_trips_json_shape() { ++ let value = json!({ ++ "id": "nightly-deps", ++ "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": true } ++ ] ++ }); ++ ++ let request: ApiCreateAutomationRequest = serde_json::from_value(value.clone()).unwrap(); ++ assert_eq!(serde_json::to_value(&request).unwrap(), value); ++} ++ ++#[test] ++fn replace_automation_request_round_trips_json_shape() { ++ let value = json!({ ++ "name": "Nightly dependency update", ++ "description": "Open a PR for dependency updates.", ++ "enabled": false, ++ "target": { ++ "repository": "fabro-sh/fabro", ++ "ref": "main", ++ "workflow": "dependency-update" ++ }, ++ "triggers": [ ++ { "id": "api", "type": "api", "enabled": true } ++ ] ++ }); ++ ++ let request: ApiReplaceAutomationRequest = serde_json::from_value(value.clone()).unwrap(); ++ assert_eq!(serde_json::to_value(&request).unwrap(), value); ++} ++ ++#[test] ++fn patch_automation_request_preserves_null_description() { ++ let value = json!({ ++ "description": null, ++ "enabled": true ++ }); ++ ++ let request: ApiPatchAutomationRequest = serde_json::from_value(value.clone()).unwrap(); ++ assert_eq!(serde_json::to_value(&request).unwrap(), value); ++} ++ ++#[test] ++fn create_automation_request_defaults_optional_enabled_fields() { ++ let value = json!({ ++ "id": "nightly-deps", ++ "name": "Nightly dependency update", ++ "target": { ++ "repository": "fabro-sh/fabro", ++ "ref": "main", ++ "workflow": "dependency-update" ++ }, ++ "triggers": [ ++ { "id": "api", "type": "api" } ++ ] ++ }); ++ ++ let request: ApiCreateAutomationRequest = serde_json::from_value(value).unwrap(); ++ assert_eq!(request.enabled, None); ++ let AutomationTrigger::Api(trigger) = &request.triggers[0] else { ++ panic!("expected api trigger"); ++ }; ++ assert!(trigger.enabled); ++} ++ ++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..b8e2b17d0 100644 +--- a/lib/crates/fabro-api/tests/run_projection_round_trip.rs ++++ b/lib/crates/fabro-api/tests/run_projection_round_trip.rs +@@ -130,6 +130,7 @@ fn run_spec_json() -> serde_json::Value { + workflow_slug: None, + source_directory: None, + labels: std::collections::HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-automation/Cargo.toml b/lib/crates/fabro-automation/Cargo.toml +new file mode 100644 +index 000000000..a1a741d31 +--- /dev/null ++++ b/lib/crates/fabro-automation/Cargo.toml +@@ -0,0 +1,26 @@ ++[package] ++name = "fabro-automation" ++edition.workspace = true ++version.workspace = true ++publish = false ++license.workspace = true ++ ++[lib] ++doctest = false ++ ++[lints] ++workspace = true ++ ++[dependencies] ++chrono = { workspace = true, features = ["serde"] } ++croner = "3" ++hex.workspace = true ++serde.workspace = true ++sha2.workspace = true ++thiserror.workspace = true ++tokio = { workspace = true, features = ["fs", "io-util", "sync"] } ++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..7d5686d11 +--- /dev/null ++++ b/lib/crates/fabro-automation/src/error.rs +@@ -0,0 +1,64 @@ ++use std::path::PathBuf; ++ ++use thiserror::Error; ++ ++#[derive(Debug, Error)] ++pub enum AutomationValidationError { ++ #[error("invalid automation id: {0}")] ++ InvalidAutomationId(String), ++ #[error("invalid automation trigger id: {0}")] ++ InvalidTriggerId(String), ++ #[error("automation name cannot be empty")] ++ EmptyName, ++ #[error("invalid repository slug: {0}")] ++ InvalidRepositorySlug(String), ++ #[error("invalid git ref selector: {0}")] ++ InvalidGitRef(String), ++ #[error("invalid workflow selector: {0}")] ++ InvalidWorkflowSelector(String), ++ #[error("duplicate trigger id: {0}")] ++ DuplicateTriggerId(String), ++ #[error("at most one api trigger is allowed")] ++ TooManyApiTriggers, ++ #[error("invalid schedule expression: {0}")] ++ InvalidScheduleExpression(String), ++ #[error("unknown trigger type: {0}")] ++ UnknownTriggerType(String), ++} ++ ++#[derive(Debug, Error)] ++pub enum AutomationStoreError { ++ #[error("automation not found: {0}")] ++ NotFound(String), ++ #[error("automation already exists: {0}")] ++ AlreadyExists(String), ++ #[error("missing revision")] ++ MissingRevision, ++ #[error("revision mismatch")] ++ RevisionMismatch, ++ #[error(transparent)] ++ Validation(#[from] AutomationValidationError), ++ #[error("failed to parse automation file {path}: {source}")] ++ Parse { ++ path: PathBuf, ++ source: toml::de::Error, ++ }, ++ #[error("invalid automation filename: {path}")] ++ InvalidFilename { path: PathBuf }, ++ #[error("I/O error at {path}: {source}")] ++ Io { ++ path: PathBuf, ++ source: std::io::Error, ++ }, ++ #[error("failed to serialize automation: {0}")] ++ Serialize(#[from] toml::ser::Error), ++} ++ ++impl AutomationStoreError { ++ pub fn io(path: impl Into, source: std::io::Error) -> Self { ++ Self::Io { ++ 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..9c51e9f15 +--- /dev/null ++++ b/lib/crates/fabro-automation/src/id.rs +@@ -0,0 +1,158 @@ ++use std::fmt; ++ ++use serde::{Deserialize, Deserializer, Serialize, Serializer}; ++ ++use crate::error::AutomationValidationError; ++ ++#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] ++pub struct AutomationId(String); ++ ++impl AutomationId { ++ pub fn new(value: impl Into) -> Result { ++ Self::try_from(value.into()) ++ } ++ ++ pub fn as_str(&self) -> &str { ++ &self.0 ++ } ++} ++ ++impl AsRef for AutomationId { ++ 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.0) ++ } ++} ++ ++impl TryFrom for AutomationId { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: String) -> Result { ++ validate_id(&value, IdKind::Automation)?; ++ Ok(Self(value)) ++ } ++} ++ ++impl TryFrom<&str> for AutomationId { ++ type Error = AutomationValidationError; ++ ++ fn try_from(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<'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(serde::de::Error::custom) ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] ++pub struct AutomationTriggerId(String); ++ ++impl AutomationTriggerId { ++ pub fn new(value: impl Into) -> Result { ++ Self::try_from(value.into()) ++ } ++ ++ pub fn as_str(&self) -> &str { ++ &self.0 ++ } ++} ++ ++impl AsRef for AutomationTriggerId { ++ fn as_ref(&self) -> &str { ++ self.as_str() ++ } ++} ++ ++impl fmt::Display for AutomationTriggerId { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.write_str(&self.0) ++ } ++} ++ ++impl TryFrom for AutomationTriggerId { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: String) -> Result { ++ validate_id(&value, IdKind::Trigger)?; ++ Ok(Self(value)) ++ } ++} ++ ++impl TryFrom<&str> for AutomationTriggerId { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: &str) -> Result { ++ Self::try_from(value.to_string()) ++ } ++} ++ ++impl Serialize for AutomationTriggerId { ++ fn serialize(&self, serializer: S) -> Result ++ where ++ S: Serializer, ++ { ++ serializer.serialize_str(self.as_str()) ++ } ++} ++ ++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(serde::de::Error::custom) ++ } ++} ++ ++#[derive(Clone, Copy)] ++enum IdKind { ++ Automation, ++ Trigger, ++} ++ ++fn validate_id(value: &str, kind: IdKind) -> Result<(), AutomationValidationError> { ++ let valid_len = (1..=63).contains(&value.len()); ++ let first_valid = value ++ .bytes() ++ .next() ++ .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()); ++ let rest_valid = value.bytes().skip(1).all(|b| { ++ b.is_ascii_lowercase() ++ || b.is_ascii_digit() ++ || b == b'-' ++ || (matches!(kind, IdKind::Trigger) && b == b'_') ++ }); ++ ++ if valid_len && first_valid && rest_valid { ++ return Ok(()); ++ } ++ ++ match kind { ++ IdKind::Automation => Err(AutomationValidationError::InvalidAutomationId( ++ value.to_string(), ++ )), ++ IdKind::Trigger => Err(AutomationValidationError::InvalidTriggerId(value.to_string())), ++ } ++} +diff --git a/lib/crates/fabro-automation/src/lib.rs b/lib/crates/fabro-automation/src/lib.rs +new file mode 100644 +index 000000000..b3d378adb +--- /dev/null ++++ b/lib/crates/fabro-automation/src/lib.rs +@@ -0,0 +1,12 @@ ++pub mod error; ++pub mod id; ++pub mod model; ++pub 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..1d47b53cb +--- /dev/null ++++ b/lib/crates/fabro-automation/src/model.rs +@@ -0,0 +1,747 @@ ++use std::collections::HashSet; ++use std::fmt; ++use std::path::{Component, Path}; ++use std::str::FromStr as _; ++ ++use croner::Cron; ++use serde::{Deserialize, Deserializer, Serialize, Serializer}; ++ ++use crate::error::AutomationValidationError; ++use crate::id::{AutomationId, AutomationTriggerId}; ++ ++#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] ++#[serde(transparent)] ++pub struct AutomationRevision(String); ++ ++impl AutomationRevision { ++ pub fn new(value: impl Into) -> Self { ++ Self(value.into()) ++ } ++ ++ pub fn as_str(&self) -> &str { ++ &self.0 ++ } ++} ++ ++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()) ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] ++pub struct RepositorySlug(String); ++ ++impl RepositorySlug { ++ pub fn new(value: impl Into) -> Result { ++ let value = value.into(); ++ validate_repository_slug(&value)?; ++ Ok(Self(value)) ++ } ++ ++ pub fn as_str(&self) -> &str { ++ &self.0 ++ } ++ ++ pub fn owner_repo(&self) -> (&str, &str) { ++ self.0 ++ .split_once('/') ++ .expect("repository slug validation guarantees owner/repo") ++ } ++} ++ ++impl AsRef for RepositorySlug { ++ fn as_ref(&self) -> &str { ++ self.as_str() ++ } ++} ++ ++impl fmt::Display for RepositorySlug { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.write_str(self.as_str()) ++ } ++} ++ ++impl TryFrom for RepositorySlug { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: String) -> Result { ++ Self::new(value) ++ } ++} ++ ++impl TryFrom<&str> for RepositorySlug { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: &str) -> Result { ++ Self::new(value) ++ } ++} ++ ++impl Serialize for RepositorySlug { ++ fn serialize(&self, serializer: S) -> Result ++ where ++ S: Serializer, ++ { ++ serializer.serialize_str(self.as_str()) ++ } ++} ++ ++impl<'de> Deserialize<'de> for RepositorySlug { ++ fn deserialize(deserializer: D) -> Result ++ where ++ D: Deserializer<'de>, ++ { ++ let value = String::deserialize(deserializer)?; ++ Self::try_from(value).map_err(serde::de::Error::custom) ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] ++pub struct GitRefSelector(String); ++ ++impl GitRefSelector { ++ pub fn new(value: impl Into) -> Result { ++ let value = value.into(); ++ validate_git_ref(&value)?; ++ Ok(Self(value)) ++ } ++ ++ pub fn as_str(&self) -> &str { ++ &self.0 ++ } ++} ++ ++impl AsRef for GitRefSelector { ++ fn as_ref(&self) -> &str { ++ self.as_str() ++ } ++} ++ ++impl fmt::Display for GitRefSelector { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.write_str(self.as_str()) ++ } ++} ++ ++impl TryFrom for GitRefSelector { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: String) -> Result { ++ Self::new(value) ++ } ++} ++ ++impl TryFrom<&str> for GitRefSelector { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: &str) -> Result { ++ Self::new(value) ++ } ++} ++ ++impl Serialize for GitRefSelector { ++ fn serialize(&self, serializer: S) -> Result ++ where ++ S: Serializer, ++ { ++ serializer.serialize_str(self.as_str()) ++ } ++} ++ ++impl<'de> Deserialize<'de> for GitRefSelector { ++ fn deserialize(deserializer: D) -> Result ++ where ++ D: Deserializer<'de>, ++ { ++ let value = String::deserialize(deserializer)?; ++ Self::try_from(value).map_err(serde::de::Error::custom) ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] ++pub struct WorkflowSlug(String); ++ ++impl WorkflowSlug { ++ pub fn new(value: impl Into) -> Result { ++ let value = value.into(); ++ validate_workflow_selector(&value)?; ++ Ok(Self(value)) ++ } ++ ++ pub fn as_str(&self) -> &str { ++ &self.0 ++ } ++} ++ ++impl AsRef for WorkflowSlug { ++ fn as_ref(&self) -> &str { ++ self.as_str() ++ } ++} ++ ++impl fmt::Display for WorkflowSlug { ++ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { ++ f.write_str(self.as_str()) ++ } ++} ++ ++impl TryFrom for WorkflowSlug { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: String) -> Result { ++ Self::new(value) ++ } ++} ++ ++impl TryFrom<&str> for WorkflowSlug { ++ type Error = AutomationValidationError; ++ ++ fn try_from(value: &str) -> Result { ++ Self::new(value) ++ } ++} ++ ++impl Serialize for WorkflowSlug { ++ fn serialize(&self, serializer: S) -> Result ++ where ++ S: Serializer, ++ { ++ serializer.serialize_str(self.as_str()) ++ } ++} ++ ++impl<'de> Deserialize<'de> for WorkflowSlug { ++ fn deserialize(deserializer: D) -> Result ++ where ++ D: Deserializer<'de>, ++ { ++ let value = String::deserialize(deserializer)?; ++ Self::try_from(value).map_err(serde::de::Error::custom) ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ++pub struct Automation { ++ pub id: AutomationId, ++ pub revision: AutomationRevision, ++ pub name: String, ++ #[serde(default)] ++ pub description: Option, ++ pub enabled: bool, ++ pub target: AutomationTarget, ++ pub triggers: Vec, ++} ++ ++impl Automation { ++ pub fn api_trigger(&self) -> Option<&ApiTrigger> { ++ self.triggers.iter().find_map(AutomationTrigger::as_api) ++ } ++ ++ pub(crate) fn from_persisted( ++ id: AutomationId, ++ revision: AutomationRevision, ++ persisted: PersistedAutomation, ++ ) -> Result { ++ let automation = Self { ++ id, ++ revision, ++ name: persisted.name, ++ description: persisted.description, ++ enabled: persisted.enabled, ++ target: persisted.target, ++ triggers: persisted.triggers, ++ }; ++ automation.validate()?; ++ Ok(automation) ++ } ++ ++ pub(crate) fn to_persisted(&self) -> PersistedAutomation { ++ PersistedAutomation { ++ name: self.name.clone(), ++ description: self.description.clone(), ++ enabled: self.enabled, ++ target: self.target.clone(), ++ triggers: self.triggers.clone(), ++ } ++ } ++ ++ pub fn validate(&self) -> Result<(), AutomationValidationError> { ++ validate_name(&self.name)?; ++ validate_triggers(&self.triggers) ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ++pub struct AutomationTarget { ++ pub repository: RepositorySlug, ++ #[serde(rename = "ref")] ++ pub ref_: GitRefSelector, ++ pub workflow: WorkflowSlug, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ++#[serde(tag = "type", rename_all = "snake_case")] ++pub enum AutomationTrigger { ++ Api(ApiTrigger), ++ Schedule(ScheduleTrigger), ++} ++ ++impl AutomationTrigger { ++ pub fn id(&self) -> &AutomationTriggerId { ++ match self { ++ Self::Api(trigger) => &trigger.id, ++ Self::Schedule(trigger) => &trigger.id, ++ } ++ } ++ ++ pub fn enabled(&self) -> bool { ++ match self { ++ Self::Api(trigger) => trigger.enabled, ++ Self::Schedule(trigger) => trigger.enabled, ++ } ++ } ++ ++ pub fn as_api(&self) -> Option<&ApiTrigger> { ++ match self { ++ Self::Api(trigger) => Some(trigger), ++ Self::Schedule(_) => None, ++ } ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ++pub struct ApiTrigger { ++ pub id: AutomationTriggerId, ++ #[serde(default = "default_true")] ++ pub enabled: bool, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ++pub struct ScheduleTrigger { ++ pub id: AutomationTriggerId, ++ #[serde(default = "default_true")] ++ pub enabled: bool, ++ pub expression: String, ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ++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, ++} ++ ++impl AutomationDraft { ++ pub(crate) fn into_automation( ++ self, ++ revision: AutomationRevision, ++ ) -> Result { ++ let automation = Automation { ++ id: self.id, ++ revision, ++ name: self.name, ++ description: self.description, ++ enabled: self.enabled.unwrap_or(true), ++ target: self.target, ++ triggers: self.triggers, ++ }; ++ automation.validate()?; ++ Ok(automation) ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ++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, ++} ++ ++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) ++ } ++} ++ ++#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] ++pub struct AutomationPatch { ++ #[serde(default)] ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub name: Option, ++ #[serde(default, deserialize_with = "deserialize_optional_nullable")] ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub description: Option>, ++ #[serde(default)] ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub enabled: Option, ++ #[serde(default)] ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub target: Option, ++ #[serde(default)] ++ #[serde(skip_serializing_if = "Option::is_none")] ++ pub triggers: Option>, ++} ++ ++impl AutomationPatch { ++ pub(crate) fn apply_to( ++ self, ++ existing: &Automation, ++ revision: AutomationRevision, ++ ) -> Result { ++ let automation = Automation { ++ id: existing.id.clone(), ++ revision, ++ name: self.name.unwrap_or_else(|| existing.name.clone()), ++ description: self.description.unwrap_or_else(|| existing.description.clone()), ++ enabled: self.enabled.unwrap_or(existing.enabled), ++ target: self.target.unwrap_or_else(|| existing.target.clone()), ++ triggers: self.triggers.unwrap_or_else(|| existing.triggers.clone()), ++ }; ++ automation.validate()?; ++ Ok(automation) ++ } ++} ++ ++#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] ++pub(crate) struct PersistedAutomation { ++ pub name: String, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub description: Option, ++ #[serde(default = "default_true")] ++ pub enabled: bool, ++ pub target: AutomationTarget, ++ #[serde(default)] ++ pub triggers: Vec, ++} ++ ++fn default_true() -> bool { ++ true ++} ++ ++fn deserialize_optional_nullable<'de, D>( ++ deserializer: D, ++) -> Result>, D::Error> ++where ++ D: Deserializer<'de>, ++{ ++ Option::::deserialize(deserializer).map(Some) ++} ++ ++fn validate_name(name: &str) -> Result<(), AutomationValidationError> { ++ if name.trim().is_empty() { ++ return Err(AutomationValidationError::EmptyName); ++ } ++ Ok(()) ++} ++ ++fn validate_triggers(triggers: &[AutomationTrigger]) -> Result<(), AutomationValidationError> { ++ let mut ids = HashSet::new(); ++ let mut api_count = 0_u8; ++ ++ for trigger in triggers { ++ if !ids.insert(trigger.id().as_str()) { ++ return Err(AutomationValidationError::DuplicateTriggerId( ++ trigger.id().to_string(), ++ )); ++ } ++ ++ match trigger { ++ AutomationTrigger::Api(_) => { ++ api_count = api_count.saturating_add(1); ++ if api_count > 1 { ++ return Err(AutomationValidationError::TooManyApiTriggers); ++ } ++ } ++ AutomationTrigger::Schedule(schedule) => { ++ validate_schedule_expression(&schedule.expression)?; ++ } ++ } ++ } ++ ++ Ok(()) ++} ++ ++fn validate_schedule_expression(expression: &str) -> Result<(), AutomationValidationError> { ++ let is_five_field = expression.split_whitespace().count() == 5; ++ if expression.trim().is_empty() || !is_five_field { ++ return Err(AutomationValidationError::InvalidScheduleExpression( ++ expression.to_string(), ++ )); ++ } ++ ++ Cron::from_str(expression).map_err(|_| { ++ AutomationValidationError::InvalidScheduleExpression(expression.to_string()) ++ })?; ++ Ok(()) ++} ++ ++fn validate_repository_slug(value: &str) -> Result<(), AutomationValidationError> { ++ let Some((owner, repo)) = value.split_once('/') else { ++ return Err(AutomationValidationError::InvalidRepositorySlug( ++ value.to_string(), ++ )); ++ }; ++ ++ if repo.contains('/') || !valid_github_segment(owner, 39) || !valid_github_segment(repo, 100) { ++ return Err(AutomationValidationError::InvalidRepositorySlug( ++ value.to_string(), ++ )); ++ } ++ ++ Ok(()) ++} ++ ++fn valid_github_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 validate_git_ref(value: &str) -> Result<(), AutomationValidationError> { ++ let invalid = value.is_empty() ++ || value.starts_with('-') ++ || value.starts_with('/') ++ || value.ends_with('/') ++ || value.contains("..") ++ || value.contains("//") ++ || value.bytes().any(|b| { ++ b.is_ascii_control() ++ || b.is_ascii_whitespace() ++ || matches!(b, b'\\' | b'~' | b'^' | b':' | b'?' | b'*' | b'[' | b']' | b'{' | b'}') ++ }); ++ ++ if invalid { ++ return Err(AutomationValidationError::InvalidGitRef(value.to_string())); ++ } ++ Ok(()) ++} ++ ++fn validate_workflow_selector(value: &str) -> Result<(), AutomationValidationError> { ++ if value.trim().is_empty() || value.bytes().any(|b| b.is_ascii_control()) { ++ return Err(AutomationValidationError::InvalidWorkflowSelector( ++ value.to_string(), ++ )); ++ } ++ ++ let path = Path::new(value); ++ if path.is_absolute() ++ || path ++ .components() ++ .any(|component| matches!(component, Component::ParentDir | Component::RootDir | Component::Prefix(_))) ++ { ++ return Err(AutomationValidationError::InvalidWorkflowSelector( ++ value.to_string(), ++ )); ++ } ++ ++ Ok(()) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ fn automation_id() -> AutomationId { ++ AutomationId::try_from("nightly-deps").expect("valid id") ++ } ++ ++ fn revision() -> AutomationRevision { ++ AutomationRevision::new("revision") ++ } ++ ++ fn parse_toml(input: &str) -> Result { ++ let persisted: PersistedAutomation = toml::from_str(input).expect("valid toml syntax"); ++ Automation::from_persisted(automation_id(), revision(), persisted) ++ } ++ ++ #[test] ++ fn parses_valid_toml() { ++ let automation = parse_toml( ++ 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 * * *" ++"#, ++ ) ++ .expect("automation should parse"); ++ ++ assert_eq!(automation.id.as_str(), "nightly-deps"); ++ assert_eq!(automation.description.as_deref(), Some("Open a PR for dependency updates.")); ++ assert!(matches!(automation.triggers[0], AutomationTrigger::Api(_))); ++ assert!(matches!(automation.triggers[1], AutomationTrigger::Schedule(_))); ++ } ++ ++ #[test] ++ fn applies_toml_defaults() { ++ let automation = parse_toml( ++ r#" ++name = "Nightly dependency update" ++ ++[target] ++repository = "fabro-sh/fabro" ++ref = "main" ++workflow = "dependency-update" ++ ++[[triggers]] ++id = "api" ++type = "api" ++"#, ++ ) ++ .expect("automation should parse"); ++ ++ assert!(automation.enabled); ++ assert_eq!(automation.description, None); ++ let AutomationTrigger::Api(api) = &automation.triggers[0] else { ++ panic!("expected api trigger"); ++ }; ++ assert!(api.enabled); ++ } ++ ++ #[test] ++ fn rejects_invalid_automation_ids() { ++ for id in ["", "-bad", "Bad", "bad_underscore", &"a".repeat(64)] { ++ assert!(AutomationId::try_from(id).is_err(), "{id} should be invalid"); ++ } ++ } ++ ++ #[test] ++ fn rejects_invalid_trigger_ids() { ++ for id in ["", "-bad", "Bad", "bad.dot", &"a".repeat(64)] { ++ assert!( ++ AutomationTriggerId::try_from(id).is_err(), ++ "{id} should be invalid" ++ ); ++ } ++ } ++ ++ #[test] ++ fn rejects_duplicate_trigger_ids() { ++ let err = parse_toml( ++ r#" ++name = "Nightly dependency update" ++ ++[target] ++repository = "fabro-sh/fabro" ++ref = "main" ++workflow = "dependency-update" ++ ++[[triggers]] ++id = "api" ++type = "api" ++ ++[[triggers]] ++id = "api" ++type = "schedule" ++expression = "0 3 * * *" ++"#, ++ ) ++ .expect_err("duplicate trigger should fail"); ++ ++ assert!(matches!(err, AutomationValidationError::DuplicateTriggerId(_))); ++ } ++ ++ #[test] ++ fn rejects_two_api_triggers() { ++ let err = parse_toml( ++ r#" ++name = "Nightly dependency update" ++ ++[target] ++repository = "fabro-sh/fabro" ++ref = "main" ++workflow = "dependency-update" ++ ++[[triggers]] ++id = "api" ++type = "api" ++ ++[[triggers]] ++id = "other_api" ++type = "api" ++"#, ++ ) ++ .expect_err("second api trigger should fail"); ++ ++ assert!(matches!(err, AutomationValidationError::TooManyApiTriggers)); ++ } ++ ++ #[test] ++ fn rejects_invalid_repository_slug() { ++ for repository in ["owner", "owner/repo/extra", "../repo", "owner/bad/repo"] { ++ assert!( ++ RepositorySlug::try_from(repository).is_err(), ++ "{repository} should be invalid" ++ ); ++ } ++ } ++ ++ #[test] ++ fn rejects_invalid_schedule_expression() { ++ let err = parse_toml( ++ r#" ++name = "Nightly dependency update" ++ ++[target] ++repository = "fabro-sh/fabro" ++ref = "main" ++workflow = "dependency-update" ++ ++[[triggers]] ++id = "nightly" ++type = "schedule" ++expression = "not a cron" ++"#, ++ ) ++ .expect_err("invalid schedule should fail"); ++ ++ assert!(matches!(err, AutomationValidationError::InvalidScheduleExpression(_))); ++ } ++} +diff --git a/lib/crates/fabro-automation/src/store.rs b/lib/crates/fabro-automation/src/store.rs +new file mode 100644 +index 000000000..b696d94fa +--- /dev/null ++++ b/lib/crates/fabro-automation/src/store.rs +@@ -0,0 +1,438 @@ ++use std::collections::BTreeMap; ++use std::path::{Path, PathBuf}; ++use std::time::{SystemTime, UNIX_EPOCH}; ++ ++use sha2::{Digest as _, Sha256}; ++use tokio::io::AsyncWriteExt as _; ++use tokio::sync::RwLock; ++ ++use crate::error::AutomationStoreError; ++use crate::id::AutomationId; ++use crate::model::{ ++ Automation, AutomationDraft, AutomationPatch, AutomationReplace, AutomationRevision, ++ PersistedAutomation, ++}; ++ ++#[derive(Debug)] ++pub struct AutomationStore { ++ dir: PathBuf, ++ automations: RwLock>, ++} ++ ++impl AutomationStore { ++ pub async fn load(dir: impl Into) -> Result { ++ let dir = dir.into(); ++ tokio::task::spawn_blocking(move || Self::load_blocking(dir)) ++ .await ++ .map_err(|err| { ++ AutomationStoreError::io( ++ "", ++ std::io::Error::other(err.to_string()), ++ ) ++ })? ++ } ++ ++ pub fn load_blocking(dir: impl Into) -> Result { ++ let dir = dir.into(); ++ let mut automations = 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(); ++ let metadata = ++ entry.metadata().map_err(|err| AutomationStoreError::io(&path, err))?; ++ if !metadata.is_file() || path.extension().and_then(|ext| ext.to_str()) != Some("toml") { ++ continue; ++ } ++ ++ let id = automation_id_from_path(&path)?; ++ let bytes = ++ std::fs::read(&path).map_err(|err| AutomationStoreError::io(&path, err))?; ++ let persisted = parse_persisted(&path, &bytes)?; ++ let revision = revision_for_bytes(&bytes); ++ let automation = Automation::from_persisted(id.clone(), revision, persisted)?; ++ automations.insert(id, automation); ++ } ++ } ++ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} ++ Err(err) => return Err(AutomationStoreError::io(&dir, err)), ++ } ++ ++ Ok(Self { ++ dir, ++ automations: RwLock::new(automations), ++ }) ++ } ++ ++ pub async fn list(&self) -> Vec { ++ self.automations.read().await.values().cloned().collect() ++ } ++ ++ pub async fn get(&self, id: &AutomationId) -> Option { ++ self.automations.read().await.get(id).cloned() ++ } ++ ++ pub async fn create( ++ &self, ++ draft: AutomationDraft, ++ ) -> Result { ++ let mut automations = self.automations.write().await; ++ if automations.contains_key(&draft.id) { ++ return Err(AutomationStoreError::AlreadyExists(draft.id.to_string())); ++ } ++ ++ let automation = draft.into_automation(AutomationRevision::new(""))?; ++ let automation = self.persist(automation).await?; ++ automations.insert(automation.id.clone(), automation.clone()); ++ Ok(automation) ++ } ++ ++ pub async fn replace( ++ &self, ++ id: &AutomationId, ++ expected: &AutomationRevision, ++ draft: AutomationReplace, ++ ) -> Result { ++ ensure_revision_present(expected)?; ++ let mut automations = self.automations.write().await; ++ let existing = automations ++ .get(id) ++ .ok_or_else(|| AutomationStoreError::NotFound(id.to_string()))?; ++ ensure_revision_matches(existing, expected)?; ++ ++ let automation = draft.into_automation(id.clone(), AutomationRevision::new(""))?; ++ let automation = self.persist(automation).await?; ++ automations.insert(id.clone(), automation.clone()); ++ Ok(automation) ++ } ++ ++ pub async fn patch( ++ &self, ++ id: &AutomationId, ++ expected: &AutomationRevision, ++ patch: AutomationPatch, ++ ) -> Result { ++ ensure_revision_present(expected)?; ++ let mut automations = self.automations.write().await; ++ let existing = automations ++ .get(id) ++ .ok_or_else(|| AutomationStoreError::NotFound(id.to_string()))?; ++ ensure_revision_matches(existing, expected)?; ++ ++ let automation = patch.apply_to(existing, AutomationRevision::new(""))?; ++ let automation = self.persist(automation).await?; ++ automations.insert(id.clone(), automation.clone()); ++ Ok(automation) ++ } ++ ++ pub async fn delete( ++ &self, ++ id: &AutomationId, ++ expected: &AutomationRevision, ++ ) -> Result<(), AutomationStoreError> { ++ ensure_revision_present(expected)?; ++ let mut automations = self.automations.write().await; ++ let existing = automations ++ .get(id) ++ .ok_or_else(|| AutomationStoreError::NotFound(id.to_string()))?; ++ ensure_revision_matches(existing, expected)?; ++ ++ let path = self.path_for(id); ++ match tokio::fs::remove_file(&path).await { ++ Ok(()) => {} ++ Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} ++ Err(err) => return Err(AutomationStoreError::io(&path, err)), ++ } ++ automations.remove(id); ++ Ok(()) ++ } ++ ++ fn path_for(&self, id: &AutomationId) -> PathBuf { ++ self.dir.join(format!("{id}.toml")) ++ } ++ ++ async fn persist(&self, mut automation: Automation) -> Result { ++ tokio::fs::create_dir_all(&self.dir) ++ .await ++ .map_err(|err| AutomationStoreError::io(&self.dir, err))?; ++ ++ let bytes = canonical_toml_bytes(&automation)?; ++ automation.revision = revision_for_bytes(&bytes); ++ atomic_write(&self.dir, &self.path_for(&automation.id), &bytes).await?; ++ Ok(automation) ++ } ++} ++ ++fn automation_id_from_path(path: &Path) -> Result { ++ let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { ++ return Err(AutomationStoreError::InvalidFilename { ++ path: path.to_path_buf(), ++ }); ++ }; ++ AutomationId::try_from(stem.to_string()).map_err(AutomationStoreError::Validation) ++} ++ ++fn parse_persisted( ++ path: &Path, ++ bytes: &[u8], ++) -> Result { ++ let text = std::str::from_utf8(bytes).map_err(|err| { ++ AutomationStoreError::io(path, std::io::Error::new(std::io::ErrorKind::InvalidData, err)) ++ })?; ++ toml::from_str(text).map_err(|source| AutomationStoreError::Parse { ++ path: path.to_path_buf(), ++ source, ++ }) ++} ++ ++fn canonical_toml_bytes(automation: &Automation) -> Result, AutomationStoreError> { ++ let persisted = automation.to_persisted(); ++ let mut text = toml::to_string_pretty(&persisted)?; ++ if !text.ends_with('\n') { ++ text.push('\n'); ++ } ++ Ok(text.into_bytes()) ++} ++ ++fn revision_for_bytes(bytes: &[u8]) -> AutomationRevision { ++ let digest = Sha256::digest(bytes); ++ AutomationRevision::new(hex::encode(digest)) ++} ++ ++fn ensure_revision_present(expected: &AutomationRevision) -> Result<(), AutomationStoreError> { ++ if expected.as_str().is_empty() { ++ return Err(AutomationStoreError::MissingRevision); ++ } ++ Ok(()) ++} ++ ++fn ensure_revision_matches( ++ automation: &Automation, ++ expected: &AutomationRevision, ++) -> Result<(), AutomationStoreError> { ++ if &automation.revision != expected { ++ return Err(AutomationStoreError::RevisionMismatch); ++ } ++ Ok(()) ++} ++ ++async fn atomic_write(dir: &Path, final_path: &Path, bytes: &[u8]) -> Result<(), AutomationStoreError> { ++ let mut last_error = None; ++ for attempt in 0..16_u8 { ++ let temp_path = dir.join(temp_file_name(attempt)); ++ match tokio::fs::OpenOptions::new() ++ .write(true) ++ .create_new(true) ++ .open(&temp_path) ++ .await ++ { ++ Ok(mut file) => { ++ file.write_all(bytes) ++ .await ++ .map_err(|err| AutomationStoreError::io(&temp_path, err))?; ++ file.flush() ++ .await ++ .map_err(|err| AutomationStoreError::io(&temp_path, err))?; ++ file.sync_all() ++ .await ++ .map_err(|err| AutomationStoreError::io(&temp_path, err))?; ++ drop(file); ++ if let Err(err) = tokio::fs::rename(&temp_path, final_path).await { ++ let _ = tokio::fs::remove_file(&temp_path).await; ++ return Err(AutomationStoreError::io(final_path, err)); ++ } ++ return Ok(()); ++ } ++ Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { ++ last_error = Some(err); ++ } ++ Err(err) => return Err(AutomationStoreError::io(&temp_path, err)), ++ } ++ } ++ ++ Err(AutomationStoreError::io( ++ dir, ++ last_error.unwrap_or_else(|| { ++ std::io::Error::new( ++ std::io::ErrorKind::AlreadyExists, ++ "failed to allocate temporary automation file", ++ ) ++ }), ++ )) ++} ++ ++fn temp_file_name(attempt: u8) -> String { ++ let nanos = SystemTime::now() ++ .duration_since(UNIX_EPOCH) ++ .map_or(0, |duration| duration.as_nanos()); ++ format!(".automation-{nanos}-{attempt}.tmp") ++} ++ ++#[cfg(test)] ++mod tests { ++ use crate::model::{ApiTrigger, GitRefSelector, RepositorySlug, WorkflowSlug}; ++ ++ use super::*; ++ ++ fn id(value: &str) -> AutomationId { ++ AutomationId::try_from(value).expect("valid automation id") ++ } ++ ++ fn trigger_id(value: &str) -> crate::AutomationTriggerId { ++ crate::AutomationTriggerId::try_from(value).expect("valid trigger id") ++ } ++ ++ fn target(workflow: &str) -> crate::AutomationTarget { ++ crate::AutomationTarget { ++ repository: RepositorySlug::try_from("fabro-sh/fabro").expect("valid repo"), ++ ref_: GitRefSelector::try_from("main").expect("valid ref"), ++ workflow: WorkflowSlug::try_from(workflow).expect("valid workflow"), ++ } ++ } ++ ++ fn draft(id_value: &str) -> AutomationDraft { ++ AutomationDraft { ++ id: id(id_value), ++ name: "Nightly dependency update".to_string(), ++ description: Some("Open a PR for dependency updates.".to_string()), ++ enabled: None, ++ target: target("dependency-update"), ++ triggers: vec![crate::AutomationTrigger::Api(ApiTrigger { ++ id: trigger_id("api"), ++ enabled: true, ++ })], ++ } ++ } ++ ++ #[tokio::test] ++ async fn missing_directory_loads_empty_store() { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let store = AutomationStore::load(temp.path().join("automations")) ++ .await ++ .expect("store should load"); ++ ++ assert!(store.list().await.is_empty()); ++ } ++ ++ #[tokio::test] ++ async fn create_writes_file() { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let dir = temp.path().join("automations"); ++ let store = AutomationStore::load(&dir).await.expect("store should load"); ++ ++ let automation = store.create(draft("nightly-deps")).await.expect("create"); ++ ++ let path = dir.join("nightly-deps.toml"); ++ assert!(path.is_file()); ++ let text = tokio::fs::read_to_string(path).await.expect("read file"); ++ assert!(text.contains("name = \"Nightly dependency update\"")); ++ assert_eq!(automation.revision.as_str().len(), 64); ++ } ++ ++ #[tokio::test] ++ async fn replace_changes_revision() { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let store = AutomationStore::load(temp.path().join("automations")) ++ .await ++ .expect("store should load"); ++ let automation = store.create(draft("nightly-deps")).await.expect("create"); ++ ++ let replacement = AutomationReplace { ++ name: "Renamed".to_string(), ++ description: automation.description.clone(), ++ enabled: automation.enabled, ++ target: automation.target.clone(), ++ triggers: automation.triggers.clone(), ++ }; ++ let replaced = store ++ .replace(&automation.id, &automation.revision, replacement) ++ .await ++ .expect("replace"); ++ ++ assert_eq!(replaced.name, "Renamed"); ++ assert_ne!(replaced.revision, automation.revision); ++ } ++ ++ #[tokio::test] ++ async fn patch_keeps_unchanged_fields() { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let store = AutomationStore::load(temp.path().join("automations")) ++ .await ++ .expect("store should load"); ++ let automation = store.create(draft("nightly-deps")).await.expect("create"); ++ ++ let patch = AutomationPatch { ++ description: Some(None), ++ ..AutomationPatch::default() ++ }; ++ let patched = store ++ .patch(&automation.id, &automation.revision, patch) ++ .await ++ .expect("patch"); ++ ++ assert_eq!(patched.name, automation.name); ++ assert_eq!(patched.description, None); ++ assert_eq!(patched.target, automation.target); ++ } ++ ++ #[tokio::test] ++ async fn stale_revision_fails() { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let store = AutomationStore::load(temp.path().join("automations")) ++ .await ++ .expect("store should load"); ++ let automation = store.create(draft("nightly-deps")).await.expect("create"); ++ let stale = AutomationRevision::new("stale"); ++ ++ let err = store ++ .replace( ++ &automation.id, ++ &stale, ++ AutomationReplace { ++ name: automation.name.clone(), ++ description: automation.description.clone(), ++ enabled: automation.enabled, ++ target: automation.target.clone(), ++ triggers: automation.triggers.clone(), ++ }, ++ ) ++ .await ++ .expect_err("stale revision should fail"); ++ ++ assert!(matches!(err, AutomationStoreError::RevisionMismatch)); ++ } ++ ++ #[tokio::test] ++ async fn delete_removes_file() { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let dir = temp.path().join("automations"); ++ let store = AutomationStore::load(&dir).await.expect("store should load"); ++ let automation = store.create(draft("nightly-deps")).await.expect("create"); ++ ++ store ++ .delete(&automation.id, &automation.revision) ++ .await ++ .expect("delete"); ++ ++ assert!(!dir.join("nightly-deps.toml").exists()); ++ assert!(store.get(&automation.id).await.is_none()); ++ } ++ ++ #[tokio::test] ++ async fn startup_fails_on_malformed_toml() { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let dir = temp.path().join("automations"); ++ tokio::fs::create_dir_all(&dir).await.expect("create dir"); ++ tokio::fs::write(dir.join("bad.toml"), "name =") ++ .await ++ .expect("write malformed file"); ++ ++ let err = AutomationStore::load(&dir) ++ .await ++ .expect_err("malformed toml should fail"); ++ ++ assert!(matches!(err, AutomationStoreError::Parse { .. })); ++ } ++} +diff --git a/lib/crates/fabro-server/Cargo.toml b/lib/crates/fabro-server/Cargo.toml +index f83c73981..3dbe0a861 100644 +--- a/lib/crates/fabro-server/Cargo.toml ++++ b/lib/crates/fabro-server/Cargo.toml +@@ -21,6 +21,7 @@ required-features = ["test-support"] + workspace = true + + [dependencies] ++fabro-automation = { path = "../fabro-automation" } + fabro-auth = { path = "../fabro-auth" } + fabro-install = { path = "../fabro-install" } + fabro-spa = { path = "../fabro-spa" } +diff --git a/lib/crates/fabro-server/src/run_files.rs b/lib/crates/fabro-server/src/run_files.rs +index e8416bfc1..c663c97b2 100644 +--- a/lib/crates/fabro-server/src/run_files.rs ++++ b/lib/crates/fabro-server/src/run_files.rs +@@ -2374,6 +2374,7 @@ index 1111111..2222222 160000 + workflow_slug: None, + source_directory: None, + labels: HashMap::default(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-server/src/run_manifest.rs b/lib/crates/fabro-server/src/run_manifest.rs +index 0aefe8f78..14328d5bb 100644 +--- a/lib/crates/fabro-server/src/run_manifest.rs ++++ b/lib/crates/fabro-server/src/run_manifest.rs +@@ -216,6 +216,7 @@ pub(crate) fn create_run_input( + git: prepared.git, + fork_source_ref: None, + parent_id: prepared.parent_id, ++ automation: None, + provenance: None, + configured_providers, + web_url, +diff --git a/lib/crates/fabro-server/src/server.rs b/lib/crates/fabro-server/src/server.rs +index bd2638729..569b9bb17 100644 +--- a/lib/crates/fabro-server/src/server.rs ++++ b/lib/crates/fabro-server/src/server.rs +@@ -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; +@@ -933,6 +934,7 @@ pub struct AppState { + runs: Mutex>, + aggregate_billing: Mutex, + store: Arc, ++ automation_store: Arc, + session_runtimes: SessionRuntimeManager, + artifact_store: ArtifactStore, + worker_tokens: WorkerTokenKeys, +@@ -1263,6 +1265,10 @@ impl AppState { + &self.store + } + ++ pub(crate) fn automation_store(&self) -> Arc { ++ Arc::clone(&self.automation_store) ++ } ++ + pub(crate) fn session_runtimes(&self) -> &SessionRuntimeManager { + &self.session_runtimes + } +@@ -2154,10 +2160,19 @@ pub(crate) fn build_app_state(config: AppStateConfig) -> 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), ++ ) ++} ++ ++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(); ++ ++ ( ++ StatusCode::OK, ++ Json(serde_json::json!({ ++ "data": automations, ++ "meta": { "total": total } ++ })), ++ ) ++ .into_response() ++} ++ ++async fn create_automation( ++ _auth: RequiredUser, ++ State(state): State>, ++ body: Bytes, ++) -> Response { ++ let draft = match parse_domain_json::(&body) { ++ Ok(draft) => draft, ++ Err(err) => return err.into_response(), ++ }; ++ ++ match state.automation_store().create(draft).await { ++ Ok(automation) => (StatusCode::CREATED, Json(automation)).into_response(), ++ Err(err) => automation_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) => automation_response(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 draft = match parse_domain_json::(&body) { ++ Ok(draft) => draft, ++ Err(err) => return err.into_response(), ++ }; ++ ++ match state.automation_store().replace(&id, &expected, draft).await { ++ Ok(automation) => automation_response(StatusCode::OK, automation), ++ Err(err) => automation_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 patch = match parse_domain_json::(&body) { ++ Ok(patch) => patch, ++ Err(err) => return err.into_response(), ++ }; ++ ++ match state.automation_store().patch(&id, &expected, patch).await { ++ Ok(automation) => automation_response(StatusCode::OK, automation), ++ Err(err) => automation_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) => automation_store_error(err).into_response(), ++ } ++} ++ ++fn automation_response(status: StatusCode, automation: fabro_automation::Automation) -> Response { ++ let etag = format!("\"{}\"", automation.revision.as_str()); ++ let etag = HeaderValue::from_str(&etag).expect("automation revisions are valid header values"); ++ (status, [(header::ETAG, etag)], Json(automation)).into_response() ++} ++ ++fn parse_automation_id(id: String) -> Result { ++ AutomationId::try_from(id).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, ++ "Missing If-Match revision.", ++ )); ++ }; ++ let value = value ++ .to_str() ++ .map_err(|_| ApiError::bad_request("Invalid If-Match revision."))? ++ .trim(); ++ let unquoted = value ++ .strip_prefix('"') ++ .and_then(|inner| inner.strip_suffix('"')) ++ .unwrap_or(value) ++ .trim(); ++ if unquoted.is_empty() { ++ return Err(ApiError::new( ++ StatusCode::PRECONDITION_REQUIRED, ++ "Missing If-Match revision.", ++ )); ++ } ++ Ok(AutomationRevision::new(unquoted)) ++} ++ ++fn parse_domain_json(body: &[u8]) -> Result ++where ++ T: serde::de::DeserializeOwned, ++{ ++ let value: serde_json::Value = ++ serde_json::from_slice(body).map_err(|err| ApiError::bad_request(err.to_string()))?; ++ serde_json::from_value(value).map_err(|err| { ++ ApiError::new( ++ StatusCode::UNPROCESSABLE_ENTITY, ++ format!("Invalid automation definition: {err}"), ++ ) ++ }) ++} ++ ++fn automation_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, ++ "Missing If-Match revision.", ++ ), ++ AutomationStoreError::RevisionMismatch => { ++ ApiError::new(StatusCode::CONFLICT, "Automation revision mismatch.") ++ } ++ AutomationStoreError::Validation(err) => { ++ ApiError::new(StatusCode::UNPROCESSABLE_ENTITY, err.to_string()) ++ } ++ AutomationStoreError::Parse { .. } ++ | AutomationStoreError::InvalidFilename { .. } ++ | AutomationStoreError::Io { .. } ++ | AutomationStoreError::Serialize(_) => { ++ ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "Automation store error.") ++ } ++ } ++} +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/sessions.rs b/lib/crates/fabro-server/src/server/handler/sessions.rs +index f87184289..01722b1c5 100644 +--- a/lib/crates/fabro-server/src/server/handler/sessions.rs ++++ b/lib/crates/fabro-server/src/server/handler/sessions.rs +@@ -1697,6 +1697,7 @@ mod tests { + workflow_slug: None, + source_directory: None, + labels: HashMap::default(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-server/src/server/tests.rs b/lib/crates/fabro-server/src/server/tests.rs +index 34a991f0f..243c19dbc 100644 +--- a/lib/crates/fabro-server/src/server/tests.rs ++++ b/lib/crates/fabro-server/src/server/tests.rs +@@ -127,6 +127,18 @@ methods = ["dev-token"] + ) + } + ++#[tokio::test] ++async fn automations_store_starts_empty_when_directory_is_absent() { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let active_config_path = temp.path().join("settings.toml"); ++ ++ let state = TestAppStateBuilder::new() ++ .active_config_path(active_config_path) ++ .build(); ++ ++ assert!(state.automation_store().list().await.is_empty()); ++} ++ + async fn body_json(body: Body) -> serde_json::Value { + let bytes = to_bytes(body, usize::MAX).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +@@ -3149,6 +3161,7 @@ async fn append_default_run_created(run_store: &fabro_store::RunDatabase, run_id + manifest_blob: None, + git: None, + fork_source_ref: None, ++ automation: None, + retried_from: None, + parent_id: None, + web_url: None, +@@ -3194,6 +3207,7 @@ async fn create_slack_notification_run( + manifest_blob: None, + git: None, + fork_source_ref: None, ++ automation: None, + retried_from: None, + parent_id: None, + web_url: None, +@@ -4199,6 +4213,7 @@ async fn list_run_stages_distinguishes_visits() { + manifest_blob: None, + git: None, + fork_source_ref: None, ++ automation: None, + retried_from: None, + parent_id: None, + web_url: None, +@@ -5183,6 +5198,7 @@ async fn create_completed_run_ready_for_pull_request( + source_directory: Some("/tmp/project".to_string()), + git: git.clone(), + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -5206,6 +5222,7 @@ async fn create_completed_run_ready_for_pull_request( + manifest_blob: None, + git, + fork_source_ref: None, ++ automation: None, + retried_from: None, + parent_id: None, + web_url: None, +@@ -10837,6 +10854,7 @@ async fn create_preserved_local_sandbox_run(state: &Arc, run_id: RunId + manifest_blob: None, + git: None, + fork_source_ref: None, ++ automation: None, + retried_from: None, + parent_id: None, + web_url: None, +@@ -11586,6 +11604,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/tests/it/api/automations.rs b/lib/crates/fabro-server/tests/it/api/automations.rs +new file mode 100644 +index 000000000..69352a729 +--- /dev/null ++++ b/lib/crates/fabro-server/tests/it/api/automations.rs +@@ -0,0 +1,270 @@ ++use axum::body::Body; ++use axum::http::{Request, StatusCode, header}; ++use tower::ServiceExt; ++ ++use crate::helpers::{api, checked_response, response_json}; ++ ++fn automation_request() -> serde_json::Value { ++ serde_json::json!({ ++ "id": "nightly-deps", ++ "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 replace_request(name: &str) -> serde_json::Value { ++ serde_json::json!({ ++ "name": name, ++ "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": true } ++ ] ++ }) ++} ++ ++fn test_app() -> (axum::Router, tempfile::TempDir) { ++ let temp = tempfile::tempdir().expect("tempdir"); ++ let active_config_path = temp.path().join("settings.toml"); ++ let state = fabro_server::test_support::TestAppStateBuilder::new() ++ .active_config_path(active_config_path) ++ .build(); ++ (fabro_server::test_support::build_test_router(state), temp) ++} ++ ++async fn json_request( ++ app: &axum::Router, ++ method: &str, ++ path: &str, ++ body: serde_json::Value, ++ if_match: Option<&str>, ++ expected: StatusCode, ++) -> serde_json::Value { ++ let mut builder = Request::builder() ++ .method(method) ++ .uri(api(path)) ++ .header(header::CONTENT_TYPE, "application/json"); ++ if let Some(revision) = if_match { ++ builder = builder.header(header::IF_MATCH, revision); ++ } ++ let request = builder ++ .body(Body::from(body.to_string())) ++ .expect("request should build"); ++ response_json( ++ app.clone().oneshot(request).await.unwrap(), ++ expected, ++ format!("{method} /api/v1{path}"), ++ ) ++ .await ++} ++ ++async fn empty_request( ++ app: &axum::Router, ++ method: &str, ++ path: &str, ++ if_match: Option<&str>, ++ expected: StatusCode, ++) -> axum::response::Response { ++ let mut builder = Request::builder().method(method).uri(api(path)); ++ if let Some(revision) = if_match { ++ builder = builder.header(header::IF_MATCH, revision); ++ } ++ let request = builder.body(Body::empty()).expect("request should build"); ++ checked_response( ++ app.clone().oneshot(request).await.unwrap(), ++ expected, ++ format!("{method} /api/v1{path}"), ++ ) ++ .await ++} ++ ++#[tokio::test] ++async fn automations_crud_lifecycle_persists_files_and_etags() { ++ let (app, temp) = test_app(); ++ ++ let list = empty_request(&app, "GET", "/automations", None, StatusCode::OK).await; ++ let list = crate::helpers::body_json(list.into_body()).await; ++ assert_eq!(list, serde_json::json!({ "data": [], "meta": { "total": 0 } })); ++ ++ let created = json_request( ++ &app, ++ "POST", ++ "/automations", ++ automation_request(), ++ None, ++ StatusCode::CREATED, ++ ) ++ .await; ++ assert_eq!(created["id"], "nightly-deps"); ++ assert_eq!(created["enabled"], true); ++ assert_eq!(created["triggers"][0]["type"], "api"); ++ assert!(temp.path().join("automations/nightly-deps.toml").is_file()); ++ ++ let duplicate = json_request( ++ &app, ++ "POST", ++ "/automations", ++ automation_request(), ++ None, ++ StatusCode::CONFLICT, ++ ) ++ .await; ++ assert_eq!(duplicate["errors"][0]["status"], "409"); ++ ++ let get_response = empty_request(&app, "GET", "/automations/nightly-deps", None, StatusCode::OK).await; ++ let etag = get_response ++ .headers() ++ .get(header::ETAG) ++ .expect("ETag header") ++ .to_str() ++ .expect("ETag should be valid") ++ .to_string(); ++ let fetched = crate::helpers::body_json(get_response.into_body()).await; ++ assert_eq!(fetched["revision"], created["revision"]); ++ ++ let replaced = json_request( ++ &app, ++ "PUT", ++ "/automations/nightly-deps", ++ replace_request("Renamed automation"), ++ Some(&etag), ++ StatusCode::OK, ++ ) ++ .await; ++ assert_eq!(replaced["name"], "Renamed automation"); ++ assert_ne!(replaced["revision"], created["revision"]); ++ ++ let stale = json_request( ++ &app, ++ "PUT", ++ "/automations/nightly-deps", ++ replace_request("Stale update"), ++ Some(&etag), ++ StatusCode::CONFLICT, ++ ) ++ .await; ++ assert_eq!(stale["errors"][0]["status"], "409"); ++ ++ let missing_if_match = json_request( ++ &app, ++ "PATCH", ++ "/automations/nightly-deps", ++ serde_json::json!({ "enabled": false }), ++ None, ++ StatusCode::PRECONDITION_REQUIRED, ++ ) ++ .await; ++ assert_eq!(missing_if_match["errors"][0]["status"], "428"); ++ ++ let current_etag = format!("\"{}\"", replaced["revision"].as_str().unwrap()); ++ let patched = json_request( ++ &app, ++ "PATCH", ++ "/automations/nightly-deps", ++ serde_json::json!({ "description": null }), ++ Some(¤t_etag), ++ StatusCode::OK, ++ ) ++ .await; ++ assert_eq!(patched["description"], serde_json::Value::Null); ++ assert_eq!(patched["name"], "Renamed automation"); ++ ++ let delete_etag = format!("\"{}\"", patched["revision"].as_str().unwrap()); ++ empty_request( ++ &app, ++ "DELETE", ++ "/automations/nightly-deps", ++ Some(&delete_etag), ++ StatusCode::NO_CONTENT, ++ ) ++ .await; ++ assert!(!temp.path().join("automations/nightly-deps.toml").exists()); ++ ++ empty_request(&app, "GET", "/automations/nightly-deps", None, StatusCode::NOT_FOUND).await; ++} ++ ++#[tokio::test] ++async fn automations_validation_errors_return_422() { ++ let (app, _temp) = test_app(); ++ ++ let cases = [ ++ ( ++ "invalid trigger id", ++ serde_json::json!({ ++ "id": "nightly-deps", ++ "name": "Nightly dependency update", ++ "target": { "repository": "fabro-sh/fabro", "ref": "main", "workflow": "dependency-update" }, ++ "triggers": [{ "id": "Bad", "type": "api", "enabled": true }] ++ }), ++ ), ++ ( ++ "duplicate trigger ids", ++ serde_json::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 }, ++ { "id": "api", "type": "schedule", "enabled": true, "expression": "0 3 * * *" } ++ ] ++ }), ++ ), ++ ( ++ "two api triggers", ++ serde_json::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 }, ++ { "id": "api2", "type": "api", "enabled": true } ++ ] ++ }), ++ ), ++ ( ++ "invalid schedule expression", ++ serde_json::json!({ ++ "id": "nightly-deps", ++ "name": "Nightly dependency update", ++ "target": { "repository": "fabro-sh/fabro", "ref": "main", "workflow": "dependency-update" }, ++ "triggers": [{ "id": "nightly", "type": "schedule", "enabled": true, "expression": "not a cron" }] ++ }), ++ ), ++ ( ++ "unknown trigger type", ++ serde_json::json!({ ++ "id": "nightly-deps", ++ "name": "Nightly dependency update", ++ "target": { "repository": "fabro-sh/fabro", "ref": "main", "workflow": "dependency-update" }, ++ "triggers": [{ "id": "api", "type": "event", "enabled": true }] ++ }), ++ ), ++ ]; ++ ++ for (name, body) in cases { ++ let response = json_request( ++ &app, ++ "POST", ++ "/automations", ++ body, ++ None, ++ StatusCode::UNPROCESSABLE_ENTITY, ++ ) ++ .await; ++ assert_eq!(response["errors"][0]["status"], "422", "{name}"); ++ } ++} +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 a2e38e843..59ea66c3e 100644 +--- a/lib/crates/fabro-store/src/run_state.rs ++++ b/lib/crates/fabro-store/src/run_state.rs +@@ -781,6 +781,7 @@ fn projection_from_created(event: &EventEnvelope) -> Result { + workflow_slug: props.workflow_slug.clone(), + source_directory: props.source_directory.clone(), + labels, ++ automation: props.automation.clone(), + provenance: props.provenance.clone(), + manifest_blob: props.manifest_blob, + definition_blob: None, +@@ -937,7 +938,7 @@ pub(crate) fn build_summary(state: &RunProjection, run_id: &RunId) -> Run { + edge_count: i64::try_from(state.spec.graph.edges.len()) + .expect("graph edge count should fit in i64"), + }, +- automation: None, ++ automation: state.spec.automation.clone(), + repository: Some(RepositoryRef::from_origin_and_source( + repo_origin_url, + source_directory.as_deref(), +@@ -1248,7 +1249,7 @@ mod tests { + StagePromptProps, StageRetryingProps, StageStartedProps, + }; + use fabro_types::{ +- AgentBackend, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, ++ AgentBackend, AutomationRef, BilledModelUsage, BilledTokenCounts, BlockedReason, Checkpoint, + CheckpointRecord, CommandTermination, EventBody, FailureCategory, FailureDetail, + FailureReason, Graph, McpServerStatus, Outcome, PendingReason, PermissionLevel, + PullRequestLink, QuestionType, ReasoningEffort, RunApprovalState, RunBlobId, +@@ -1337,6 +1338,7 @@ mod tests { + workflow_slug: None, + source_directory: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -1394,6 +1396,38 @@ mod tests { + ); + } + ++ #[test] ++ fn run_created_automation_projects_into_summary() { ++ let event = test_raw_event( ++ 1, ++ "run.created", ++ &json!({ ++ "settings": WorkflowSettings::default(), ++ "graph": Graph::new("test"), ++ "labels": {}, ++ "automation": { ++ "id": "nightly-deps", ++ "name": "Nightly dependency update", ++ "trigger_id": "api" ++ }, ++ "run_dir": "/tmp/run" ++ }), ++ 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, +@@ -2606,6 +2640,7 @@ mod tests { + source_directory: Some("/tmp/repo".to_string()), + git: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -2631,6 +2666,7 @@ mod tests { + source_directory: Some("/tmp/repo".to_string()), + git: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-store/src/slate/mod.rs b/lib/crates/fabro-store/src/slate/mod.rs +index 784868ace..d4ccd2d6b 100644 +--- a/lib/crates/fabro-store/src/slate/mod.rs ++++ b/lib/crates/fabro-store/src/slate/mod.rs +@@ -541,6 +541,7 @@ mod tests { + workflow_slug: Some("night-sky".to_string()), + source_directory: Some(format!("/tmp/{label}")), + labels: std::collections::HashMap::from([("team".to_string(), "infra".to_string())]), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-store/tests/serializable_projection.rs b/lib/crates/fabro-store/tests/serializable_projection.rs +index 2707ca353..265e72ad0 100644 +--- a/lib/crates/fabro-store/tests/serializable_projection.rs ++++ b/lib/crates/fabro-store/tests/serializable_projection.rs +@@ -21,6 +21,7 @@ fn sample_run_spec() -> RunSpec { + workflow_slug: Some("demo".to_string()), + source_directory: Some("/tmp/project".to_string()), + labels: HashMap::from([("team".to_string(), "platform".to_string())]), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-types/src/run.rs b/lib/crates/fabro-types/src/run.rs +index 269db43ee..2df593ad7 100644 +--- a/lib/crates/fabro-types/src/run.rs ++++ b/lib/crates/fabro-types/src/run.rs +@@ -2,6 +2,7 @@ use std::collections::HashMap; + + use serde::{Deserialize, Serialize}; + ++use crate::AutomationRef; + use crate::WorkflowSettings; + use crate::graph::Graph; + use crate::principal::Principal; +@@ -91,6 +92,8 @@ pub struct RunSpec { + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub labels: HashMap, + #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub automation: Option, ++ #[serde(default, skip_serializing_if = "Option::is_none")] + pub provenance: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub manifest_blob: Option, +diff --git a/lib/crates/fabro-types/src/run_event/run.rs b/lib/crates/fabro-types/src/run_event/run.rs +index fa189171f..2230d810a 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)] +@@ -21,6 +21,8 @@ pub struct RunCreatedProps { + pub workflow_config: Option, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub labels: BTreeMap, ++ #[serde(default, skip_serializing_if = "Option::is_none")] ++ pub automation: Option, + pub run_dir: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub source_directory: Option, +diff --git a/lib/crates/fabro-types/src/run_projection.rs b/lib/crates/fabro-types/src/run_projection.rs +index 3bba6d43e..7974d1ae7 100644 +--- a/lib/crates/fabro-types/src/run_projection.rs ++++ b/lib/crates/fabro-types/src/run_projection.rs +@@ -698,6 +698,7 @@ mod title_tests { + workflow_slug: None, + source_directory: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -767,6 +768,7 @@ mod iter_stages_tests { + workflow_slug: None, + source_directory: None, + labels: HashMap::default(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +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..9a1cd7e48 100644 +--- a/lib/crates/fabro-types/tests/run_event_serde.rs ++++ b/lib/crates/fabro-types/tests/run_event_serde.rs +@@ -23,6 +23,7 @@ fn run_created_props_round_trip_templated_settings() { + workflow_source: Some("digraph Ship { start -> exit }".to_string()), + workflow_config: Some("[run]\ngoal = \"Ship {{ env.TASK }}\"".to_string()), + labels: BTreeMap::from([("team".to_string(), "platform".to_string())]), ++ automation: None, + run_dir: "/tmp/run".to_string(), + source_directory: Some("/Users/client/project".to_string()), + workflow_slug: Some("demo".to_string()), +@@ -85,6 +86,7 @@ fn run_created_props_omits_web_url_when_absent() { + workflow_source: None, + workflow_config: None, + labels: BTreeMap::new(), ++ automation: None, + run_dir: "/tmp/run".to_string(), + source_directory: None, + workflow_slug: 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..9608c133c 100644 +--- a/lib/crates/fabro-types/tests/run_spec_methods.rs ++++ b/lib/crates/fabro-types/tests/run_spec_methods.rs +@@ -26,6 +26,7 @@ fn sample_run_spec() -> RunSpec { + workflow_slug: Some("demo".to_string()), + source_directory: Some("/Users/client/project".to_string()), + labels: HashMap::from([("team".to_string(), "platform".to_string())]), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: 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..8e71ec4d4 100644 +--- a/lib/crates/fabro-types/tests/run_spec_serde.rs ++++ b/lib/crates/fabro-types/tests/run_spec_serde.rs +@@ -22,6 +22,7 @@ fn run_spec_round_trips_templated_settings() { + workflow_slug: Some("demo".to_string()), + source_directory: Some("/Users/client/project".to_string()), + labels: HashMap::from([("team".to_string(), "platform".to_string())]), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-workflow/src/billing_rollup.rs b/lib/crates/fabro-workflow/src/billing_rollup.rs +index 0e909ce08..e7bcbf643 100644 +--- a/lib/crates/fabro-workflow/src/billing_rollup.rs ++++ b/lib/crates/fabro-workflow/src/billing_rollup.rs +@@ -372,6 +372,7 @@ mod tests { + workflow_slug: None, + source_directory: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: 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..0af7b7838 100644 +--- a/lib/crates/fabro-workflow/src/event/events.rs ++++ b/lib/crates/fabro-workflow/src/event/events.rs +@@ -1,7 +1,7 @@ + use std::collections::BTreeMap; + + use ::fabro_types::{ +- BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason, ++ AutomationRef, BilledTokenCounts, BlockedReason, CommandTermination, DiffSummary, FailureReason, + ForkSourceRef, GitContext, PairId, PairMessageId, PairSystemMessageKind, PairTarget, + ParallelBranchId, PendingReason, PermissionLevel, Principal, PullRequestLink, RunBlobId, + RunFailure, RunId, RunNoticeLevel, RunPairEndedReason, RunPairFailedReason, RunProvenance, +@@ -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..a94392efa 100644 +--- a/lib/crates/fabro-workflow/src/handler/command.rs ++++ b/lib/crates/fabro-workflow/src/handler/command.rs +@@ -253,6 +253,7 @@ mod tests { + workflow_slug: None, + source_directory: None, + labels: std::collections::HashMap::default(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -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..b10aee385 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; +@@ -44,6 +44,7 @@ pub struct CreateRunInput { + pub git: Option, + pub fork_source_ref: Option, + pub parent_id: Option, ++ pub automation: Option, + pub provenance: Option, + pub configured_providers: Vec, + /// Public URL where this run can be viewed in the web UI, when the server +@@ -70,6 +71,7 @@ struct PersistCreateOptions { + source_directory: Option, + git: Option, + fork_source_ref: Option, ++ automation: Option, + provenance: Option, + configured_providers: Vec, + catalog: Arc, +@@ -105,6 +107,7 @@ pub async fn create( + git, + fork_source_ref, + parent_id, ++ automation, + provenance, + configured_providers, + web_url, +@@ -146,6 +149,7 @@ pub async fn create( + source_directory, + git, + fork_source_ref, ++ automation, + provenance, + configured_providers, + catalog, +@@ -245,6 +249,7 @@ async fn persist_created_run( + manifest_blob, + git: record.git.clone(), + fork_source_ref: record.fork_source_ref.clone(), ++ automation: record.automation.clone(), + retried_from: None, + parent_id, + web_url, +@@ -358,6 +363,7 @@ fn persist_validated( + source_directory, + git, + fork_source_ref, ++ automation, + provenance, + configured_providers, + catalog, +@@ -386,6 +392,7 @@ fn persist_validated( + definition_blob: None, + git, + fork_source_ref, ++ automation, + }; + + pipeline::persist(validated, PersistOptions { run_dir, run_spec }) +@@ -1099,6 +1106,7 @@ mod tests { + git: None, + fork_source_ref: None, + parent_id: None, ++ automation: None, + provenance: None, + configured_providers: Vec::new(), + web_url: None, +@@ -1166,6 +1174,7 @@ mod tests { + }), + fork_source_ref: None, + parent_id: None, ++ automation: None, + provenance: None, + configured_providers: Vec::new(), + web_url: None, +@@ -1277,6 +1286,7 @@ mod tests { + git: None, + fork_source_ref: None, + parent_id: None, ++ automation: None, + provenance: None, + configured_providers: Vec::new(), + web_url: None, +@@ -1322,6 +1332,7 @@ mod tests { + }), + fork_source_ref: None, + parent_id: None, ++ automation: None, + provenance: None, + configured_providers: Vec::new(), + web_url: None, +@@ -1389,6 +1400,7 @@ mod tests { + git: None, + fork_source_ref: None, + parent_id: None, ++ automation: None, + provenance: None, + configured_providers: Vec::new(), + web_url: None, +@@ -1435,6 +1447,7 @@ mod tests { + git: None, + fork_source_ref: None, + parent_id: None, ++ automation: None, + provenance: Some(fabro_types::RunProvenance { + server: Some(fabro_types::RunServerProvenance { + version: "0.9.0".to_string(), +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 8527d2728..39e7eb134 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(), +@@ -196,6 +198,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..188661e64 100644 +--- a/lib/crates/fabro-workflow/src/operations/start.rs ++++ b/lib/crates/fabro-workflow/src/operations/start.rs +@@ -1334,6 +1334,7 @@ reasoning = false + git: None, + fork_source_ref: None, + parent_id: None, ++ automation: None, + provenance: None, + configured_providers: Vec::new(), + web_url: None, +@@ -1527,6 +1528,7 @@ reasoning = false + git: None, + fork_source_ref: None, + parent_id: None, ++ automation: None, + provenance: None, + configured_providers: Vec::new(), + web_url: None, +diff --git a/lib/crates/fabro-workflow/src/operations/timeline.rs b/lib/crates/fabro-workflow/src/operations/timeline.rs +index 2170dc28a..54d9d5154 100644 +--- a/lib/crates/fabro-workflow/src/operations/timeline.rs ++++ b/lib/crates/fabro-workflow/src/operations/timeline.rs +@@ -247,6 +247,7 @@ mod tests { + workflow_slug: None, + source_directory: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +index cef35cc39..59d0ccd93 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/execute/tests.rs +@@ -164,6 +164,7 @@ fn persisted_workflow(graph: Graph, source: String, run_dir: &Path, run_id: RunI + push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, + }), + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: 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..fa80f6f9d 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, +@@ -854,6 +855,7 @@ mod tests { + workflow_slug: None, + source_directory: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-workflow/src/pipeline/initialize.rs b/lib/crates/fabro-workflow/src/pipeline/initialize.rs +index c620c0151..029e988b7 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/initialize.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/initialize.rs +@@ -863,6 +863,7 @@ mod tests { + push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, + }), + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-workflow/src/pipeline/persist.rs b/lib/crates/fabro-workflow/src/pipeline/persist.rs +index ee6150696..6643bfdd4 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/persist.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/persist.rs +@@ -147,6 +147,7 @@ mod tests { + ("env".to_string(), "test".to_string()), + ("team".to_string(), "workflow".to_string()), + ]), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: 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..5b242d71f 100644 +--- a/lib/crates/fabro-workflow/src/pipeline/pull_request.rs ++++ b/lib/crates/fabro-workflow/src/pipeline/pull_request.rs +@@ -822,6 +822,7 @@ mod tests { + workflow_slug: None, + source_directory: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -1146,6 +1147,7 @@ mod tests { + push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, + }), + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -1167,6 +1169,7 @@ mod tests { + manifest_blob: None, + git: run_spec.git.clone(), + fork_source_ref: None, ++ automation: run_spec.automation.clone(), + retried_from: None, + parent_id: None, + web_url: None, +@@ -1215,6 +1218,7 @@ mod tests { + push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, + }), + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -1236,6 +1240,7 @@ mod tests { + manifest_blob: None, + git: run_spec.git.clone(), + fork_source_ref: None, ++ automation: run_spec.automation.clone(), + retried_from: None, + parent_id: None, + web_url: None, +@@ -1569,6 +1574,7 @@ mod tests { + source_directory: Some(tmp.path().display().to_string()), + git: None, + labels: std::collections::HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -1590,6 +1596,7 @@ mod tests { + manifest_blob: None, + git: None, + fork_source_ref: None, ++ automation: run_spec.automation.clone(), + retried_from: None, + parent_id: None, + web_url: None, +@@ -1696,6 +1703,7 @@ mod tests { + source_directory: Some("/tmp/project".to_string()), + git: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -1717,6 +1725,7 @@ mod tests { + manifest_blob: None, + git: None, + fork_source_ref: None, ++ automation: run_spec.automation.clone(), + retried_from: None, + parent_id: None, + web_url: None, +@@ -1865,6 +1874,7 @@ mod tests { + source_directory: None, + git: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +@@ -1886,6 +1896,7 @@ mod tests { + manifest_blob: None, + git: None, + fork_source_ref: None, ++ automation: run_spec.automation.clone(), + 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..ae139714c 100644 +--- a/lib/crates/fabro-workflow/src/run_lookup.rs ++++ b/lib/crates/fabro-workflow/src/run_lookup.rs +@@ -490,6 +490,7 @@ mod tests { + push_outcome: fabro_types::PreRunPushOutcome::NotAttempted, + }), + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: 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..02750a107 100644 +--- a/lib/crates/fabro-workflow/src/run_metadata.rs ++++ b/lib/crates/fabro-workflow/src/run_metadata.rs +@@ -638,6 +638,7 @@ mod tests { + push_outcome: PreRunPushOutcome::NotAttempted, + }), + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: None, +diff --git a/lib/crates/fabro-workflow/src/runtime_store.rs b/lib/crates/fabro-workflow/src/runtime_store.rs +index 0f590c70f..4fd33aef4 100644 +--- a/lib/crates/fabro-workflow/src/runtime_store.rs ++++ b/lib/crates/fabro-workflow/src/runtime_store.rs +@@ -147,6 +147,7 @@ mod tests { + source_directory: Some("/tmp/test".to_string()), + git: None, + labels: HashMap::new(), ++ automation: None, + provenance: None, + manifest_blob: None, + definition_blob: 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/stages/005-implement@1/status.json b/stages/005-implement@1/status.json new file mode 100644 index 000000000..594139c26 --- /dev/null +++ b/stages/005-implement@1/status.json @@ -0,0 +1,6 @@ +{ + "outcome": "failed", + "notes": null, + "failure_reason": "LLM error: Context length exceeded for openai: Your input exceeds the context window of this model. Please adjust your input and try again.", + "timestamp": "2026-05-24T22:00:06.325028Z" +} \ No newline at end of file diff --git a/stages/006-simplify_opus@1/prompt.md b/stages/006-simplify_opus@1/prompt.md new file mode 100644 index 000000000..693ea9d71 --- /dev/null +++ b/stages/006-simplify_opus@1/prompt.md @@ -0,0 +1,730 @@ +Goal: # Automations Backend API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the backend data model and REST API for creating, editing, deleting, starting, and listing runs for Automations. + +**Architecture:** Automations are server-owned runnable bindings stored as one canonical TOML file per automation in `dirname(active_config_path)/automations/.toml`. The server loads those files into an in-memory store at startup, persists API mutations atomically, and attaches an automation reference to runs created through the automation API. Schedule triggers are stored and validated, but no cron scheduler or background trigger loop is added in this plan. + +**Tech Stack:** Rust, serde, toml, toml_edit, sha2, hex, croner for schedule validation only, Axum, OpenAPI/progenitor, existing Fabro run manifest and run creation pipeline. + +--- + +## Locked Decisions + +- Backend only: do not add web UI routes/components and do not add CLI commands. +- Storage root: `dirname(active_config_path)/automations`. +- File layout: one automation per file, `automations/.toml`. +- Canonical ID: the filename stem. The TOML file does not repeat `id`. +- Automation ID format: `[a-z0-9][a-z0-9-]{0,62}`. +- Trigger ID format: `[a-z0-9][a-z0-9_-]{0,62}`. +- Trigger IDs are required, user-visible, editable, and unique within one automation. +- Triggers are an array from v1. +- The API trigger type is `api`, not `manual_api`. Trigger IDs remain user-visible and editable; examples use `id = "api"` but startability is based on `type = "api"`. +- At most one trigger with `type = "api"` is allowed per automation. +- Multiple `schedule` triggers are allowed. +- Unknown trigger types, including future `event` shapes, return `422` in v1. Handlers must not let unknown trigger discriminators fail as JSON parse errors. +- If an automation is disabled, or it has no enabled trigger with `type = "api"`, `POST /automations/{id}/runs` returns `409` and does not create a run. +- API writes canonicalize TOML and may discard comments in automation files. +- No runtime automation state store or derived automation status API is added in V1. Run history is available through `GET /automations/{id}/runs`; schedule expressions are validated but not evaluated for scheduling. + +## File Structure + +Create: + +- `lib/crates/fabro-automation/Cargo.toml` - domain crate manifest. +- `lib/crates/fabro-automation/src/lib.rs` - public exports. +- `lib/crates/fabro-automation/src/error.rs` - validation and persistence errors. +- `lib/crates/fabro-automation/src/id.rs` - `AutomationId` and `AutomationTriggerId`. +- `lib/crates/fabro-automation/src/model.rs` - automation domain and serde/TOML model. +- `lib/crates/fabro-automation/src/store.rs` - in-memory file-backed automation store. +- `lib/crates/fabro-server/src/automation_materializer.rs` - GitHub target materialization and manifest building for automation runs. +- `lib/crates/fabro-server/src/server/handler/automations.rs` - REST handlers and router. +- `lib/crates/fabro-server/tests/it/api/automations.rs` - server API integration tests. +- `lib/crates/fabro-server/tests/it/api/mod.rs` - wire the automations integration test module. + +Modify: + +- `lib/crates/fabro-server/Cargo.toml` - add `fabro-automation`. +- `lib/crates/fabro-api/Cargo.toml` - add `fabro-automation` so OpenAPI can reuse matching automation domain types. +- `lib/crates/fabro-types/src/run_summary.rs` - extend `AutomationRef` with `trigger_id`. +- `lib/crates/fabro-types/src/run.rs` - add `automation: Option` to `RunSpec`. +- `lib/crates/fabro-types/src/run_event/run.rs` - add `automation: Option` to `RunCreatedProps`. +- `lib/crates/fabro-workflow/src/operations/create.rs` - carry automation metadata through `CreateRunInput`, persistence options, `RunSpec`, and `run.created`. +- `lib/crates/fabro-workflow/src/event/convert.rs` - preserve automation metadata in any legacy-to-current event conversion path that constructs `RunCreatedProps`. +- `lib/crates/fabro-store/src/run_state.rs` - project `RunSpec.automation` into `Run.automation`. +- `lib/crates/fabro-server/src/server.rs` - load the automation store into `AppState` and expose crate-private accessors. +- `lib/crates/fabro-server/src/server/handler/mod.rs` - merge real automation routes. +- `lib/crates/fabro-server/src/test_support.rs` - create temp automation storage by active config path and allow test-only materializer injection. +- `docs/public/api-reference/fabro-api.yaml` - add automation paths and schemas. +- `lib/crates/fabro-api/build.rs` - add replacement mappings only for domain types with identical wire shape. +- `lib/crates/fabro-api/tests/*` - add JSON parity tests for reused automation types. +- `lib/packages/fabro-api-client` - regenerate generated TypeScript client files only; do not import them from the web UI. + +Do not modify: + +- `apps/fabro-web/**`, except generated API package consumers are not touched. +- CLI command modules. +- Scheduler services or background run loops. + +## Public API Shape + +Add these OpenAPI paths under `/api/v1`: + +```http +GET /automations +POST /automations +GET /automations/{id} +PUT /automations/{id} +PATCH /automations/{id} +DELETE /automations/{id} +GET /automations/{id}/runs +POST /automations/{id}/runs +``` + +Use this response model: + +```ts +type Automation = { + id: string; + revision: string; + name: string; + description: string | null; + enabled: boolean; + target: AutomationTarget; + triggers: AutomationTrigger[]; +}; + +type AutomationTarget = { + repository: string; // GitHub owner/repo + ref: string; + workflow: string; +}; + +type AutomationTrigger = + | { id: string; type: "api"; enabled: boolean } + | { id: string; type: "schedule"; enabled: boolean; expression: string }; + +``` + +Request models: + +```ts +type CreateAutomationRequest = { + id: string; + name: string; + description?: string | null; + enabled?: boolean; + target: AutomationTarget; + triggers: AutomationTrigger[]; +}; + +type ReplaceAutomationRequest = { + name: string; + description?: string | null; + enabled: boolean; + target: AutomationTarget; + triggers: AutomationTrigger[]; +}; + +type PatchAutomationRequest = { + name?: string; + description?: string | null; + enabled?: boolean; + target?: AutomationTarget; + triggers?: AutomationTrigger[]; +}; +``` + +`GET /automations/{id}/runs` returns the existing paginated run list envelope: + +```json +{ + "data": [], + "meta": { "has_more": false, "total": 0 } +} +``` + +It accepts `page[limit]` and `page[offset]`, sorts newest first, filters by `Run.automation.id`, and returns `404` if the automation definition no longer exists. + +`POST /automations/{id}/runs` returns the existing `Run` response shape with `automation` populated: + +```json +{ + "automation": { + "id": "nightly-deps", + "name": "Nightly dependency update", + "trigger_id": "api" + } +} +``` + +## TOML Shape + +Persist this canonical TOML: + +```toml +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 * * *" +``` + +Defaults: + +- `enabled` defaults to `true` when omitted in TOML or create requests. +- `description` defaults to `null`. +- Trigger `enabled` defaults to `true` when omitted in TOML or create requests. +- `schedule.expression` must be a non-empty five-field cron expression accepted by `croner`. +- `target.repository` must be a GitHub `owner/repo` slug using the existing server slug validation rules: owner max 39 chars, repo max 100 chars, no path traversal or separators inside either segment. +- `target.ref` must be a non-empty branch, tag, or SHA selector and must not start with `-`, contain ASCII control characters, or contain shell/path traversal metacharacters that would make git argv ambiguous. +- `target.workflow` is a Fabro workflow selector resolved inside the cloned repository with `WorkflowLocation::resolve`; it may be a workflow slug such as `dependency-update` or a relative workflow path, but absolute paths and `..` path traversal are invalid. + +## Task 1: Add Domain Crate And Model Tests + +**Files:** + +- Create: `lib/crates/fabro-automation/Cargo.toml` +- Create: `lib/crates/fabro-automation/src/lib.rs` +- Create: `lib/crates/fabro-automation/src/error.rs` +- Create: `lib/crates/fabro-automation/src/id.rs` +- Create: `lib/crates/fabro-automation/src/model.rs` + +- [ ] Read `docs/internal/testing-strategy.md` and `docs/internal/error-handling-strategy.md` before adding tests and error types. +- [ ] Create the crate. Because the workspace uses `members = ["lib/crates/*"]`, no root workspace member edit is required. +- [ ] Add dependencies in `lib/crates/fabro-automation/Cargo.toml`: `chrono`, `croner`, `hex`, `serde`, `sha2`, `thiserror`, `tokio`, `toml`, and `toml_edit`. Add dev-dependencies: `tempfile`. +- [ ] Define `AutomationId` and `AutomationTriggerId` newtypes with `TryFrom`, `AsRef`, `Display`, `Serialize`, and `Deserialize`. +- [ ] Define the domain model with this public shape: + +```rust +pub struct AutomationRevision(String); + +pub struct RepositorySlug(String); + +pub struct GitRefSelector(String); + +pub struct WorkflowSlug(String); + +pub struct Automation { + pub id: AutomationId, + pub revision: AutomationRevision, + pub name: String, + pub description: Option, + pub enabled: bool, + pub target: AutomationTarget, + pub triggers: Vec, +} + +pub struct AutomationTarget { + pub repository: RepositorySlug, + pub ref_: GitRefSelector, + pub workflow: WorkflowSlug, +} + +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AutomationTrigger { + Api(ApiTrigger), + Schedule(ScheduleTrigger), +} + +pub struct ApiTrigger { + pub id: AutomationTriggerId, + pub enabled: bool, +} + +pub struct ScheduleTrigger { + pub id: AutomationTriggerId, + pub enabled: bool, + pub expression: String, +} + +pub struct AutomationDraft { + pub id: AutomationId, + pub name: String, + pub description: Option, + pub enabled: Option, + pub target: AutomationTarget, + pub triggers: Vec, +} + +pub struct AutomationReplace { + pub name: String, + pub description: Option, + pub enabled: bool, + pub target: AutomationTarget, + pub triggers: Vec, +} + +pub struct AutomationPatch { + pub name: Option, + pub description: Option>, + pub enabled: Option, + pub target: Option, + pub triggers: Option>, +} +``` + +- [ ] Use `#[serde(rename = "ref")]` for the Rust field `ref_`. +- [ ] Keep `revision` out of the persisted TOML model; compute it from raw file bytes. +- [ ] Reject empty names, invalid GitHub repository slugs, invalid refs, invalid workflow selectors, duplicate trigger IDs, and more than one trigger with `type = "api"`. +- [ ] Add unit tests for valid TOML, defaults, invalid automation IDs, invalid trigger IDs, duplicate trigger IDs, two `api` triggers, invalid repository slug, and invalid schedule expression. +- [ ] Run `cargo nextest run -p fabro-automation`. +- [ ] Commit: + +```bash +git add lib/crates/fabro-automation +git commit -m "feat: add automation domain model" +``` + +## Task 2: Implement File-Backed Automation Store + +**Files:** + +- Create: `lib/crates/fabro-automation/src/store.rs` +- Modify: `lib/crates/fabro-automation/src/lib.rs` + +- [ ] Implement `AutomationStore` as an in-memory map guarded by `tokio::sync::RwLock`. +- [ ] Load files from a configured directory with this behavior: + - Missing directory means an empty store. + - Non-`.toml` files are ignored. + - Invalid filenames fail load. + - Invalid TOML or invalid automation data fails load. +- [ ] Compute `AutomationRevision` as lowercase hex SHA-256 of the exact TOML bytes read from disk. +- [ ] Expose these async methods: + +```rust +pub async fn load(dir: impl Into) -> Result; +pub async fn list(&self) -> Vec; +pub async fn get(&self, id: &AutomationId) -> Option; +pub async fn create(&self, draft: AutomationDraft) -> Result; +pub async fn replace( + &self, + id: &AutomationId, + expected: &AutomationRevision, + draft: AutomationReplace, +) -> Result; +pub async fn patch( + &self, + id: &AutomationId, + expected: &AutomationRevision, + patch: AutomationPatch, +) -> Result; +pub async fn delete( + &self, + id: &AutomationId, + expected: &AutomationRevision, +) -> Result<(), AutomationStoreError>; +``` + +- [ ] Make create/update writes atomic by serializing to canonical TOML, writing a temp file in the automation directory, flushing it, and renaming it over the final path. +- [ ] Create the automation directory on first write. +- [ ] Map store errors into precise variants: not found, already exists, missing revision, revision mismatch, validation, parse, and I/O. +- [ ] Add tests using `tempfile` for empty load, create writes file, replace changes revision, patch keeps unchanged fields, stale revision fails, delete removes file, and startup fails on malformed TOML. +- [ ] Run `cargo nextest run -p fabro-automation`. +- [ ] Commit: + +```bash +git add lib/crates/fabro-automation +git commit -m "feat: persist automations as TOML files" +``` + +## Task 3: Carry Automation Metadata Through Runs + +**Files:** + +- Modify: `lib/crates/fabro-types/src/run_summary.rs` +- Modify: `lib/crates/fabro-types/src/run.rs` +- Modify: `lib/crates/fabro-types/src/run_event/run.rs` +- Modify: `lib/crates/fabro-workflow/src/operations/create.rs` +- Modify: `lib/crates/fabro-workflow/src/event/convert.rs` +- Modify: `lib/crates/fabro-store/src/run_state.rs` +- Modify tests that construct `RunSpec` or `RunCreatedProps` + +- [ ] Extend `AutomationRef`: + +```rust +pub struct AutomationRef { + pub id: String, + #[serde(default)] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_id: Option, +} +``` + +- [ ] Add `automation: Option` to `RunSpec` with `#[serde(default, skip_serializing_if = "Option::is_none")]`. +- [ ] Add `automation: Option` to `RunCreatedProps` with the same serde behavior. +- [ ] Add `automation: Option` to `fabro_workflow::operations::CreateRunInput`. +- [ ] Thread the field through `PersistCreateOptions`, the `RunSpec` built in `persist_validated`, and the `Event::RunCreated` emitted in `persist_created_run`. +- [ ] In `fabro-store/src/run_state.rs`, set `Run.automation` from `state.spec.automation.clone()` instead of always using `None`. +- [ ] Preserve backward compatibility: old run specs and old `run.created` events without `automation` deserialize as `None`. +- [ ] Update all test fixture constructors by setting `automation: None` unless the test specifically checks automation linkage. +- [ ] Add a focused projection test proving `RunCreatedProps.automation` appears in cached `Run.automation`. +- [ ] Run: + +```bash +cargo nextest run -p fabro-types +cargo nextest run -p fabro-workflow operations::create +cargo nextest run -p fabro-store run_state +``` + +- [ ] Commit: + +```bash +git add lib/crates/fabro-types lib/crates/fabro-workflow lib/crates/fabro-store +git commit -m "feat: associate runs with automations" +``` + +## Task 4: Add OpenAPI Contract And Type Reuse + +**Files:** + +- Modify: `docs/public/api-reference/fabro-api.yaml` +- Modify: `lib/crates/fabro-api/Cargo.toml` +- Modify: `lib/crates/fabro-api/build.rs` +- Create: `lib/crates/fabro-api/tests/automation_round_trip.rs` + +- [ ] Add an `Automations` tag. +- [ ] Add schemas for `Automation`, `AutomationTarget`, `AutomationTrigger`, `AutomationApiTrigger`, `AutomationScheduleTrigger`, `CreateAutomationRequest`, `ReplaceAutomationRequest`, `PatchAutomationRequest`, and `AutomationListResponse`. +- [ ] Use OpenAPI discriminator `propertyName: type` for trigger variants. +- [ ] Implement request-body parsing so unknown trigger discriminator values are reported as domain validation errors (`422`), not JSON parse errors (`400`). Use raw DTOs or custom deserialization before converting into `fabro-automation` domain types. +- [ ] Reuse existing `Run` and paginated run envelope schemas for `POST /automations/{id}/runs` and `GET /automations/{id}/runs`. +- [ ] Add response codes: + - `200` for reads and replace/patch. + - `201` for create automation and create run. + - `204` for delete. + - `400` for malformed JSON or invalid path syntax. + - `404` for missing automation. + - `409` for duplicate create, stale revision, disabled automation, or disabled/missing `api` trigger. + - `422` for domain validation errors. + - `428` for missing `If-Match` on `PUT`, `PATCH`, or `DELETE`. +- [ ] Add `If-Match` header parameters for mutating path operations except `POST /automations`. +- [ ] Add `ETag` response header on `GET /automations/{id}`, `PUT`, and `PATCH`. +- [ ] Before adding generated duplicate Rust types, search for matching domain types. If `fabro-automation` serde shape matches a schema exactly, add a `with_replacement(...)` entry in `lib/crates/fabro-api/build.rs`. +- [ ] Add JSON parity tests for every automation replacement type used by `fabro-api`. +- [ ] Run `cargo build -p fabro-api`. +- [ ] Commit: + +```bash +git add docs/public/api-reference/fabro-api.yaml lib/crates/fabro-api +git commit -m "feat: define automations API contract" +``` + +## Task 5: Wire Automation Store Into Server State + +**Files:** + +- Modify: `lib/crates/fabro-server/Cargo.toml` +- Modify: `lib/crates/fabro-server/src/server.rs` +- Modify: `lib/crates/fabro-server/src/test_support.rs` + +- [ ] Add `fabro-automation = { path = "../fabro-automation" }` to server dependencies. +- [ ] Add `automation_store: Arc` to `AppState`. +- [ ] In `build_app_state`, compute the automation directory as: + +```rust +let automation_dir = active_config_path + .parent() + .unwrap_or_else(|| std::path::Path::new(".")) + .join("automations"); +``` + +- [ ] Load `AutomationStore::load(automation_dir)` before constructing `AppState`. +- [ ] Fail server startup if an existing automation file is malformed. +- [ ] Add `pub(crate) fn automation_store(&self) -> Arc`. +- [ ] In test support, keep the existing temp `active_config_path` behavior so each test gets its own sibling `automations` directory. +- [ ] Add a server unit test for empty automation store creation when no automation directory exists. +- [ ] Run `cargo nextest run -p fabro-server automation_store`. +- [ ] Commit: + +```bash +git add lib/crates/fabro-server +git commit -m "feat: load automation store in server state" +``` + +## Task 6: Add Automation CRUD Routes + +**Files:** + +- Create: `lib/crates/fabro-server/src/server/handler/automations.rs` +- Modify: `lib/crates/fabro-server/src/server/handler/mod.rs` +- Create: `lib/crates/fabro-server/tests/it/api/automations.rs` +- Modify: `lib/crates/fabro-server/tests/it/api/mod.rs` + +- [ ] Read `docs/internal/logging-strategy.md` and `docs/internal/error-handling-strategy.md` before adding request errors or logs. +- [ ] Implement `automations::routes()` and merge it into `handler::real_routes()`. +- [ ] Use `RequiredUser` for CRUD routes. +- [ ] Implement `GET /automations` by listing store entries, sorting by ID ascending, and returning `{ data, meta: { total } }`. +- [ ] Implement `POST /automations` with `CreateAutomationRequest`; duplicate ID returns `409`. +- [ ] Implement `GET /automations/{id}` with `ETag: ""`. +- [ ] Implement `PUT /automations/{id}` with `ReplaceAutomationRequest` and required `If-Match`. +- [ ] Implement `PATCH /automations/{id}` with `PatchAutomationRequest`, shallow patch semantics, and required `If-Match`. +- [ ] Implement `DELETE /automations/{id}` with required `If-Match`. +- [ ] Add a helper that parses a quoted or unquoted `If-Match` revision and rejects missing headers with `428`. +- [ ] Map `AutomationStoreError` to `ApiError`: + - not found to `404` + - already exists to `409` + - missing revision to `428` + - revision mismatch to `409` + - validation to `422` + - parse/I/O to `500` except malformed request bodies, which stay `400` +- [ ] Add route tests for empty list, create, duplicate create, get with ETag, replace, stale replace, missing `If-Match`, patch clearing description, delete, invalid trigger IDs, duplicate trigger IDs, second trigger with `type = "api"`, and invalid schedule expression. +- [ ] Run `cargo nextest run -p fabro-server automations`. +- [ ] Commit: + +```bash +git add lib/crates/fabro-server +git commit -m "feat: add automation CRUD API" +``` + +## Task 7: Add Automation Run Listing And API-Triggered Runs + +**Files:** + +- Create: `lib/crates/fabro-server/src/automation_materializer.rs` +- Modify: `lib/crates/fabro-server/src/server.rs` +- Modify: `lib/crates/fabro-server/src/server/handler/runs.rs` +- Modify: `lib/crates/fabro-server/src/server/handler/automations.rs` +- Modify: `lib/crates/fabro-server/src/test_support.rs` +- Create: `lib/crates/fabro-server/tests/it/api/automations.rs` +- Modify: `lib/crates/fabro-server/tests/it/api/mod.rs` + +- [ ] Extract the common run creation body from `handler/runs.rs::create_run` into a crate-private helper that accepts: + +```rust +struct CreateRunFromManifestRequest { + manifest: fabro_api::types::RunManifest, + submitted_manifest_bytes: Vec, + explicit_run_id: Option, + explicit_title_supplied: bool, + actor: fabro_types::Principal, + headers: axum::http::HeaderMap, + automation: Option, +} +``` + +- [ ] Keep `POST /runs` behavior unchanged by calling the helper with `automation: None`. +- [ ] Define a crate-private materializer trait: + +```rust +pub(crate) struct AutomationRunMaterializeInput { + pub automation_id: fabro_automation::AutomationId, + pub target: fabro_automation::AutomationTarget, + pub run_id: fabro_types::RunId, + pub user_settings_path: std::path::PathBuf, + pub temp_root: std::path::PathBuf, +} + +pub(crate) struct AutomationRunMaterialized { + pub manifest: fabro_api::types::RunManifest, + pub submitted_manifest_bytes: Vec, +} + +#[derive(thiserror::Error, Debug)] +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::async_trait] +pub(crate) trait AutomationRunMaterializer: Send + Sync { + async fn materialize( + &self, + input: AutomationRunMaterializeInput, + ) -> Result; +} +``` + +- [ ] Use a production implementation that: + - validates target repository as GitHub `owner/repo` + - is constructed with the server GitHub credentials, GitHub API base URL, HTTP client, and cleanup policy needed for clone materialization + - creates a per-run temp directory under `AutomationRunMaterializeInput.temp_root` + - clones `https://github.com/{owner}/{repo}.git` + - uses existing GitHub clone credential helpers when configured + - checks out the configured `ref` + - resolves the workflow selector using `fabro_config::project::WorkflowLocation::resolve` + - builds a `RunManifest` with `fabro_manifest::build_run_manifest` + - passes `user_settings_path: Some(state.active_config_path().to_path_buf())` +- [ ] Use `tokio::process::Command` with argv values for git commands. Do not construct shell command strings. Set `GIT_TERMINAL_PROMPT=0` and explicit timeouts so private-repo credential failures cannot hang request handling. +- [ ] Store only sanitized repository URLs in run metadata. Do not persist credentialed clone URLs. +- [ ] Add test support injection for a fake `AutomationRunMaterializer` behind tests or the existing `test-support` feature. +- [ ] Implement `GET /automations/{id}/runs`: + - require the automation to exist + - list cached runs from the store + - filter by `run.automation.as_ref().is_some_and(|a| a.id == id)` + - sort newest first + - paginate with `page[limit]` and `page[offset]` + - return the existing `{ data, meta }` list shape +- [ ] Implement `POST /automations/{id}/runs`: + - use `RequiredRunToolActor` + - require automation `enabled == true` + - find the enabled trigger with `type = "api"` + - return `409` with API error code `automation_api_trigger_disabled` if not startable + - materialize the run manifest + - call the shared create-run helper with `AutomationRef { id, name, trigger_id: Some(api_trigger_id) }` + - return `201` and the created `Run` +- [ ] Add route tests using the fake materializer for disabled automation, disabled API trigger, successful run creation, persisted `Run.automation`, and associated run listing. +- [ ] Add lower-level materializer tests for target URL construction, credential redaction, ref checkout command planning, and workflow path resolution using temp directories. Do not add a live GitHub test. +- [ ] Run `cargo nextest run -p fabro-server automations`. +- [ ] Commit: + +```bash +git add lib/crates/fabro-server +git commit -m "feat: start runs from automations" +``` + +## Task 8: Generate Clients And Final Verification + +**Files:** + +- Modify generated files under `lib/packages/fabro-api-client` +- Modify generated Rust files under `lib/crates/fabro-api/src` if `cargo build -p fabro-api` updates them + +- [ ] Regenerate Rust API code: + +```bash +cargo build -p fabro-api +``` + +- [ ] Regenerate the TypeScript API client: + +```bash +cd lib/packages/fabro-api-client && bun run generate +``` + +- [ ] Confirm no web UI imports or CLI command modules changed: + +```bash +git diff -- apps/fabro-web lib/crates/fabro-cli +``` + +Expected: no application or CLI command changes caused by this plan. + +- [ ] Run focused tests: + +```bash +cargo nextest run -p fabro-automation +cargo nextest run -p fabro-api +cargo nextest run -p fabro-server automations +cargo nextest run -p fabro-server openapi_conformance +``` + +- [ ] Run broader checks: + +```bash +cargo +nightly-2026-04-14 fmt --check --all +cargo +nightly-2026-04-14 clippy --workspace --all-targets -- -D warnings +``` + +- [ ] If clippy or tests expose unrelated existing failures, record the exact failing command and failure summary in the implementation handoff. +- [ ] Commit generated and verification fixes: + +```bash +git add docs/public/api-reference/fabro-api.yaml lib/crates lib/packages/fabro-api-client +git commit -m "chore: regenerate automation API clients" +``` + +## Acceptance Criteria + +- A server with no `automations/` directory starts and returns an empty automation list. +- Creating an automation writes `dirname(active_config_path)/automations/.toml`. +- Updating or deleting an automation requires `If-Match`. +- Stale revisions are rejected. +- Invalid automation and trigger shapes are rejected with `422`. +- Disabling the `api` trigger makes the automation not startable through `POST /automations/{id}/runs`. +- A successful API-triggered automation run returns a normal `Run` response with `automation.id`, `automation.name`, and `automation.trigger_id`. +- `GET /automations/{id}/runs` returns runs linked to that automation. +- No cron scheduler, web UI exposure, or CLI exposure is added. + + +## Completed stages +- **toolchain**: succeeded + - 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` + - Output: + ``` + cargo 1.95.0 (f2d3ce0bd 2026-03-21) + ``` +- **preflight_compile**: succeeded + - Script: `cargo check -q --workspace 2>&1` + - Output: (empty) +- **preflight_lint**: succeeded + - Script: `cargo +nightly-2026-04-14 clippy -q --workspace --all-targets -- -D warnings 2>&1` + - Output: (empty) +- **implement**: failed + +## Context +- failure_class: budget_exhausted +- failure_signature: implement|budget_exhausted|api_deterministic|openai|context_length + + +# Simplify: Code Review and Cleanup + +Review changes vs. origin for reuse, quality, and efficiency. Fix any issues found. + +## Phase 1: Identify Changes + +Run git diff (or git diff HEAD if there are staged changes) to see what changed. If there are no git changes, review the most recently modified files that the user mentioned or that you edited earlier in this conversation. + +## Phase 2: Launch Three Review Agents in Parallel + +Use the Agent tool to launch all three agents concurrently in a single message. Pass each agent the full diff so it has the complete context. + +### Agent 1: Code Reuse Review + +For each change: + +1. Search for existing utilities and helpers that could replace newly written code. Use Grep to find similar patterns elsewhere in the codebase — common locations are utility directories, shared modules, and files adjacent to the changed ones. +2. Flag any new function that duplicates existing functionality. Suggest the existing function to use instead. +3. Flag any inline logic that could use an existing utility — hand-rolled string manipulation, manual path handling, custom environment checks, ad-hoc type guards, and similar patterns are common candidates. + +Note: This is a greenfield app, so focus on maximizing simplicity and don't worry about changing things to achieve it. + +### Agent 2: Code Quality Review + +Review the same changes for hacky patterns: + +1. Redundant state: state that duplicates existing state, cached values that could be derived, observers/effects that could be direct calls +2. Parameter sprawl: adding new parameters to a function instead of generalizing or restructuring existing ones +3. Copy-paste with slight variation: near-duplicate code blocks that should be unified with a shared abstraction +4. Leaky abstractions: exposing internal details that should be encapsulated, or breaking existing abstraction boundaries +5. Stringly-typed code: using raw strings where constants, enums (string unions), or branded types already exist in the codebase + +Note: This is a greenfield app, so be aggressive in optimizing quality. + +### Agent 3: Efficiency Review + +Review the same changes for efficiency: + +1. Unnecessary work: redundant computations, repeated file reads, duplicate network/API calls, N+1 patterns +2. Missed concurrency: independent operations run sequentially when they could run in parallel +3. Hot-path bloat: new blocking work added to startup or per-request/per-render hot paths +4. Unnecessary existence checks: pre-checking file/resource existence before operating (TOCTOU anti-pattern) — operate directly and handle the error +5. Memory: unbounded data structures, missing cleanup, event listener leaks +6. Overly broad operations: reading entire files when only a portion is needed, loading all items when filtering for one + +## Phase 3: Fix Issues + +Wait for all three agents to complete. Aggregate their findings and fix each issue directly. If a finding is a false positive or not worth addressing, note it and move on — do not argue with the finding, just skip it. + +When done, briefly summarize what was fixed (or confirm the code was already clean). \ No newline at end of file diff --git a/stages/006-simplify_opus@1/provider_used.json b/stages/006-simplify_opus@1/provider_used.json new file mode 100644 index 000000000..672cc50e5 --- /dev/null +++ b/stages/006-simplify_opus@1/provider_used.json @@ -0,0 +1,5 @@ +{ + "mode": "agent", + "provider": "anthropic", + "model": "claude-opus-4-7" +} \ No newline at end of file