diff --git a/run.json b/run.json index 0583f66f9..7f1bcca63 100644 --- a/run.json +++ b/run.json @@ -484,15 +484,15 @@ }, "web_url": "http://127.0.0.1:32276/runs/01KSDXK5DJ61CFCK9YSDR8AETQ", "start": { - "start_time": "2026-05-24T21:17:45.770129Z", + "start_time": "2026-05-24T22:36:50.640487Z", "run_branch": "fabro/run/01KSDXK5DJ61CFCK9YSDR8AETQ", "base_sha": "f5e5f9c75cd1225cdc4ff1b2ddf2fe55fe02fd52" }, "status": { - "kind": "starting" + "kind": "running" }, - "status_updated_at": "2026-05-24T22:36:49.913702Z", - "last_event_at": "2026-05-24T22:36:50.154490Z", + "status_updated_at": "2026-05-24T22:36:50.640545Z", + "last_event_at": "2026-05-24T22:46:08.041313Z", "pending_control": null, "checkpoints": [ { @@ -836,10 +836,10 @@ } ], "conclusion": { - "timestamp": "2026-05-24T22:01:19.243568Z", + "timestamp": "2026-05-24T22:46:08.146953Z", "status": "failed", "timing": { - "wall_time_ms": 2611699, + "wall_time_ms": 557588, "inference_time_ms": 0, "tool_time_ms": 0, "active_time_ms": 0 @@ -851,24 +851,73 @@ "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, + "input_tokens": 7670112, + "output_tokens": 98164, + "total_tokens": 44467011, "reasoning_tokens": 12040, - "cache_read_tokens": 27049622, - "cache_write_tokens": 28639 + "cache_read_tokens": 35447468, + "cache_write_tokens": 1239227 }, "total_retries": 0, - "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 - } - } + "diff": {} }, "sandbox": { "provider": "daytona", @@ -904,26 +953,68 @@ "script_timing": null, "parallel_results": null, "output": null, - "started_at": "2026-05-24T22:00:10.538178Z", + "started_at": "2026-05-24T22:36:52.977844Z", "handler": "agent", - "timing": { - "wall_time_ms": 68705, - "inference_time_ms": 0, - "tool_time_ms": 0, - "active_time_ms": 0 - }, "usage": { - "input_tokens": 17766, - "output_tokens": 1124, - "total_tokens": 192575, + "input_tokens": 339949, + "output_tokens": 53331, + "total_tokens": 10175399, "reasoning_tokens": 0, - "cache_read_tokens": 145046, - "cache_write_tokens": 28639 + "cache_read_tokens": 8542892, + "cache_write_tokens": 1239227 }, "model": { "provider": "anthropic", "model_id": "claude-opus-4-7" }, + "todos": { + "kind": "anthropic_tasks", + "list_id": "anthropic_tasks:6314493e-a7f7-4b9c-be2e-0172cbe441af", + "items": [ + { + "id": "1", + "status": "pending", + "order": 0, + "subject": "Fix clippy errors in fabro-automation", + "description": "Code quality review found 16 clippy errors blocking compilation under -D warnings. Need to fix absolute_paths, disallowed_methods (blocking std::fs), and Option> uses.", + "active_form": "Fixing clippy errors in fabro-automation" + }, + { + "id": "2", + "status": "pending", + "order": 1, + "subject": "Review changes for reuse, quality, efficiency", + "description": "Launch 3 parallel agents to review the diff, then aggregate findings.", + "active_form": "Running review agents" + } + ] + }, + "subagents": [ + { + "agent_id": "f7d9399a", + "depth": 1, + "task": "You are a code reuse reviewer for a Rust workspace. The branch adds an \"automations\" backend (file-backed TOML store, REST API, run metadata threading). \n\nWorking directory: /home/daytona/workspace/fabro\n\nGet the diff with:\n```\ngit diff f5e5f9c75..HEAD -- ':!Cargo.lock'\n```\n\nFocus your review on the new files:\n- lib/crates/fabro-automation/src/{error,id,model,store,lib}.rs\n- lib/crates/fabro-server/src/server/handler/automations.rs\n- lib/crates/fabro-server/tests/it/api/automations.rs\n- lib/crates/fabro-api/tests/automation_round_trip.rs\n- lib/crates/fabro-api/build.rs (additions)\n- docs/public/api-reference/fabro-api.yaml (additions)\n- changes to fabro-types/fabro-workflow/fabro-store to thread automation metadata through RunSpec/RunCreatedProps\n\nFor each new function or block of code, search the codebase with grep/glob for existing helpers that could replace it. Common candidates:\n- Existing ID newtypes with the same validation pattern (lib/crates/fabro-types, fabro-config, etc.) — does the workspace already have a \"kebab-case identifier with length cap\" pattern?\n- Existing repository-slug/owner-repo validation (look in fabro-config, fabro-manifest, fabro-github, fabro-server, fabro-workflow for \"owner/repo\" or slug parsing)\n- Existing git ref validation (fabro-workflow, fabro-checkpoint, fabro-github)\n- Existing workflow selector validation (fabro_config::project::WorkflowLocation)\n- Existing TOML atomic-write helpers (fabro-store, fabro-config) - is there a shared \"write_atomic\" or \"write_temp_then_rename\" utility?\n- Existing SHA-256 hex revision helpers\n- Existing ETag/If-Match parsing in fabro-server (other handlers like /runs, /sessions)\n- Existing \"parse JSON body then map serde error → 422\" helpers in fabro-server (look in other handlers, error.rs)\n- Existing paginated list envelope helpers for `{ data, meta }`\n- Existing in-memory file-backed stores guarded by tokio RwLock (fabro-store, fabro-config)\n- Existing croner schedule helpers (anywhere in the workspace using croner)\n\nBe thorough — grep for similar function names and patterns. For each finding, give:\n1. The duplicated/hand-rolled code (file:line)\n2. The existing utility it should use (file:line)\n3. A one-line suggestion\n\nOutput a concise bulleted list ordered by impact. Do not modify any files; only review.", + "status": { + "kind": "closed" + } + }, + { + "agent_id": "d590cee7", + "depth": 1, + "task": "You are a code-quality reviewer for a Rust workspace. The branch adds an \"automations\" backend.\n\nWorking directory: /home/daytona/workspace/fabro\n\nGet the diff with:\n```\ngit diff f5e5f9c75..HEAD -- ':!Cargo.lock'\n```\n\nFocus on these new/modified files for hacky patterns:\n- lib/crates/fabro-automation/src/{error,id,model,store,lib}.rs\n- lib/crates/fabro-server/src/server/handler/automations.rs\n- lib/crates/fabro-server/tests/it/api/automations.rs\n- lib/crates/fabro-api/tests/automation_round_trip.rs\n- lib/crates/fabro-api/build.rs additions\n- lib/crates/fabro-types/src/run_summary.rs\n- lib/crates/fabro-types/src/run.rs\n- lib/crates/fabro-types/src/run_event/run.rs\n- lib/crates/fabro-workflow/src/operations/create.rs\n- lib/crates/fabro-workflow/src/event/convert.rs\n- lib/crates/fabro-store/src/run_state.rs\n\nLook for:\n\n1. Redundant state: anything cached that could be derived. E.g., is `revision` stored AND computed from bytes? Is there an in-memory map indexed by id when files-on-disk is the source of truth?\n\n2. Parameter sprawl: e.g., did `CreateRunInput` get a new `automation` field instead of generalizing run-source metadata? Did old call sites get cluttered with `automation: None` boilerplate?\n\n3. Copy-paste with slight variation: the three Draft/Replace/Patch request types may share most fields - is there an opportunity to unify with a common AutomationFields struct? Are there near-identical parse_json/error-mapping blocks in each handler?\n\n4. Leaky abstractions: does AutomationStoreError leak (filesystem paths, raw toml errors) into HTTP responses? Does store.rs expose AutomationRevision internals that handlers reach into? Does the handler use `serde_json::json!({...})` ad-hoc instead of a typed response struct that matches OpenAPI?\n\n5. Stringly-typed code: \n- AutomationRef.id / AutomationRef.trigger_id are `String` but the domain has AutomationId / AutomationTriggerId newtypes — should the wire-level types use the strong types?\n- AutomationRevision wraps String but is essentially a SHA-256 hex digest — is there a shared digest type?\n- repository slug, git ref, workflow selector — each are wrapped String newtypes; do they share validation with existing types?\n\n6. Hand-written serde gymnastics: model.rs has `deserialize_with` and `Option>` patterns. Are they necessary, or is there a simpler pattern (e.g., struct flatten, or just `Option` semantics)?\n\n7. Test fixture pollution: `automation: None` added to many test files for fixtures — is there a builder/default that would avoid the spread?\n\n8. Boilerplate match arms: automation_store_error has many arms; could the AutomationStoreError type expose a `.status_code()` method or implement IntoResponse directly?\n\nFor each finding give:\n1. File:line reference\n2. What's wrong / hacky\n3. A concrete suggestion to clean it up\n\nBe aggressive — this is a greenfield codebase. Output a prioritized list. Do not modify files; only review.", + "status": { + "kind": "closed" + } + }, + { + "agent_id": "80f242d7", + "depth": 1, + "task": "You are an efficiency reviewer for a Rust workspace. The branch adds an \"automations\" backend (file-backed TOML store, REST API).\n\nWorking directory: /home/daytona/workspace/fabro\n\nGet the diff with:\n```\ngit diff f5e5f9c75..HEAD -- ':!Cargo.lock'\n```\n\nFocus on these files for efficiency issues:\n- lib/crates/fabro-automation/src/store.rs (in-memory store, file I/O)\n- lib/crates/fabro-automation/src/model.rs (validation, parsing)\n- lib/crates/fabro-server/src/server/handler/automations.rs\n- lib/crates/fabro-server/src/server.rs (startup additions)\n\nLook for:\n\n1. Unnecessary work:\n- Does the store re-parse TOML on every read or only on disk-load?\n- Is the same hash computed twice (e.g., once when loading and again on write)?\n- Does list() clone the whole map every call when a snapshot iterator would do?\n- Is canonical TOML serialized more than once per write?\n\n2. Missed concurrency:\n- During startup load, are files read+parsed sequentially when they could be parallelized with `tokio::task::JoinSet`?\n- Are validators (croner schedule parse, repository slug check, ref check) running serially on a Vec of triggers when they're independent?\n\n3. Hot-path bloat in startup:\n- Does AppState construction now block on synchronous filesystem enumeration that could be deferred or run concurrently with other initializers?\n- Look at server.rs for any blocking calls on the startup path\n\n4. Unnecessary existence checks / TOCTOU:\n- Does store.rs `stat()` or check existence before `read`/`rename`? Should just attempt and handle the error.\n- Does the handler check \"exists\" then \"get\" when one call suffices?\n\n5. Memory:\n- Does the store hold full automation TOML bytes in memory beyond what's needed?\n- Is there an unbounded growth pattern in the in-memory map (no eviction, but bounded by files on disk — probably OK)?\n- Does load() keep both raw bytes AND parsed structs in memory?\n\n6. Overly broad operations:\n- Are `automations` cloned in full when listing, instead of references?\n- Does serialization happen twice on patch (validate → re-serialize)?\n\n7. File I/O efficiency:\n- store.rs uses std::fs::read in an async context (mixing blocking with tokio).\n- Are fsync/durable rename used correctly, or oversynced?\n\nFor each finding give:\n1. File:line reference\n2. The inefficiency\n3. A concrete suggestion\n\nOutput a prioritized list (highest impact first). Do not modify files; only review.", + "status": { + "kind": "closed" + } + } + ], "permission_level": "full", "agent_tools": [ { @@ -942,7 +1033,7 @@ "kind": "native" }, "category": "other", - "invoked": false + "invoked": true }, { "name": "TaskGet", @@ -987,7 +1078,7 @@ "kind": "native" }, "category": "write", - "invoked": false + "invoked": true }, { "name": "glob", @@ -996,7 +1087,7 @@ "kind": "native" }, "category": "read", - "invoked": false + "invoked": true }, { "name": "grep", @@ -1005,7 +1096,7 @@ "kind": "native" }, "category": "read", - "invoked": false + "invoked": true }, { "name": "read_file", @@ -1014,7 +1105,7 @@ "kind": "native" }, "category": "read", - "invoked": false + "invoked": true }, { "name": "send_input", @@ -1041,7 +1132,7 @@ "kind": "native" }, "category": "subagent", - "invoked": false + "invoked": true }, { "name": "wait", @@ -1084,42 +1175,42 @@ "provider": "anthropic", "model": "claude-opus-4-7", "context_window_tokens": 1000000, - "input_tokens": 29810, - "usage_percent": 2.981, + "input_tokens": 88388, + "usage_percent": 8.8388, "count_method": "response_usage_scaled_breakdown", "staleness": "live", - "generated_at": "2026-05-24T22:01:17.190992Z", - "event_seq": 900, + "generated_at": "2026-05-24T22:45:53.406297Z", + "event_seq": 1375, "breakdown": [ { "category": "system_prompt", - "tokens": 2797, - "usage_percent": 0.2797 + "tokens": 2402, + "usage_percent": 0.2402 }, { "category": "tools", - "tokens": 3174, - "usage_percent": 0.3174 + "tokens": 2727, + "usage_percent": 0.2727 }, { "category": "memory", - "tokens": 6631, - "usage_percent": 0.6631 + "tokens": 5696, + "usage_percent": 0.5696 }, { "category": "conversation", - "tokens": 17200, - "usage_percent": 1.72 + "tokens": 77556, + "usage_percent": 7.7556 }, { "category": "other", - "tokens": 8, - "usage_percent": 0.0008 + "tokens": 7, + "usage_percent": 0.0007 } ], "warnings": [] }, - "state": "cancelled" + "state": "running" }, "toolchain@1": { "first_event_seq": 21,