diff --git a/docs/public/administration/server-configuration.mdx b/docs/public/administration/server-configuration.mdx index 12e7b03ce..ecdd7cb73 100644 --- a/docs/public/administration/server-configuration.mdx +++ b/docs/public/administration/server-configuration.mdx @@ -17,7 +17,7 @@ Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.tom | Scope | Examples | |---|---| -| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.sandbox]`, `[server.storage]`, `[server.artifacts]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` | +| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.sandbox]`, `[server.storage]`, `[server.artifacts]`, `[server.scheduler]`, `[server.approvals]`, `[server.logging]`, `[server.integrations]` | | Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.environment]`, `[environments.]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` | The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing. @@ -76,6 +76,11 @@ region = "us-east-1" [server.scheduler] max_concurrent_runs = 8 +# Server half of the child auto-approval opt-in; a parent workflow must also +# set `[run.children] auto_approve = true` for its children to skip approval. +[server.approvals] +allow_child_auto_approval = true + [server.logging] level = "info" @@ -279,10 +284,20 @@ The `[run.*]` sections in `settings.toml` act as defaults for every run. Run creation uses the registered workflow version's `workflow.toml` above the server defaults, with explicit `RunIntent` arguments at highest precedence. The same rules apply to local and remote clients. Project and user config are not submitted to the create endpoint. Manifest-based preflight, validation, and graph previews still accept their bundled configuration layers. -Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, and `[server.scheduler]` come from the server machine's `settings.toml` or `fabro server start` flags. Environments and MCP server references resolve against the server's catalogs. +Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, `[server.scheduler]`, and `[server.approvals]` come from the server machine's `settings.toml` or `fabro server start` flags. Environments and MCP server references resolve against the server's catalogs. Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, explicit intent inputs merge per key at highest precedence, environment `env` and `labels` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging. +### `[server.approvals]` section + +Server-owned approval policy for worker-started child runs. + +| Key | Description | Default | +|---|---|---| +| `allow_child_auto_approval` | Server half of the child auto-approval opt-in | `false` | + +Worker-started child runs enter `pending` with `approval_required` unless both this setting and the spawning parent workflow's `[run.children] auto_approve` are enabled. The policy only affects future start requests; a pending child is never retroactively approved by a configuration change. See [Child Runs — Start and approval](/execution/child-runs#start-and-approval). + ### `[server.logging]` section Configure the server log level and destination. diff --git a/docs/public/api-reference/fabro-api.yaml b/docs/public/api-reference/fabro-api.yaml index e5319e4f4..bbc811b92 100644 --- a/docs/public/api-reference/fabro-api.yaml +++ b/docs/public/api-reference/fabro-api.yaml @@ -9463,6 +9463,7 @@ components: enum: - start_requested - approved + - auto_approved FailureCategory: description: Product-level classification for grouping and retry policy. @@ -15446,6 +15447,7 @@ components: - storage - artifacts - scheduler + - approvals - logging - integrations properties: @@ -15465,6 +15467,8 @@ components: $ref: "#/components/schemas/ServerArtifactsSettings" scheduler: $ref: "#/components/schemas/ServerSchedulerSettings" + approvals: + $ref: "#/components/schemas/ServerApprovalsSettings" logging: $ref: "#/components/schemas/ServerLoggingSettings" integrations: @@ -15638,6 +15642,19 @@ components: max_concurrent_runs: type: integer + ServerApprovalsSettings: + description: | + Server-owned approval policy. `allow_child_auto_approval` is the + server half of the child auto-approval opt-in; the workflow half is + `[run.children] auto_approve` in the parent workflow's + `workflow.toml`, and a worker-started child is auto-approved only + when both are enabled. + type: object + required: [allow_child_auto_approval] + properties: + allow_child_auto_approval: + type: boolean + ServerLoggingSettings: type: object required: [level, destination] @@ -15792,6 +15809,7 @@ components: - pull_request - artifacts - integrations + - children properties: goal: oneOf: @@ -15845,6 +15863,19 @@ components: $ref: "#/components/schemas/ArtifactsSettings" integrations: $ref: "#/components/schemas/RunIntegrationsSettings" + children: + $ref: "#/components/schemas/RunChildrenSettings" + + RunChildrenSettings: + description: | + How this workflow treats the child runs its workers start. Governs + the child's lifecycle start decision, not the child's own + agent-interaction approval posture (`run.execution.approval`). + type: object + required: [auto_approve] + properties: + auto_approve: + type: boolean RunIntegrationsSettings: type: object diff --git a/docs/public/execution/child-runs.mdx b/docs/public/execution/child-runs.mdx index 95881df18..dc88b0f01 100644 --- a/docs/public/execution/child-runs.mdx +++ b/docs/public/execution/child-runs.mdx @@ -121,7 +121,34 @@ Child run creation and child run execution are separate steps: 4. **Schedule** - after approval, the child becomes `runnable` and the scheduler starts it when capacity is available. 5. **Execute** - the child runs its own workflow and writes its own events, checkpoints, artifacts, and outputs. -The approval step prevents a workflow agent from recursively launching executing runs without an operator checkpoint. Approve or deny pending child runs from the web UI or through the run lifecycle API. +By default, the approval step keeps a workflow agent from recursively launching executing runs without an operator checkpoint. Approve or deny pending child runs from the web UI or through the run lifecycle API. + +### Auto-approval + +A parent workflow can opt its child starts out of the operator checkpoint with two keys that must both be enabled: + +```toml title=".fabro/workflows/dispatcher/workflow.toml" +[run.children] +auto_approve = true +``` + +```toml title="settings.toml (server)" +[server.approvals] +allow_child_auto_approval = true +``` + +- `[run.children] auto_approve` belongs to the **parent** workflow: it governs children dispatched by that workflow. It is read from the registered workflow configuration (and the server's `[run]` defaults below it), never from run inputs or `args`. +- `[server.approvals] allow_child_auto_approval` is the server-owned half, default `false`. + +When both are enabled, a child start records an `auto_approved` lifecycle transition and becomes `runnable` in one pass: its approval state reads `approved` with a decision timestamp, and it never projects a transient `pending`. Any other combination keeps the operator checkpoint. + +The policy is evaluated when the start request arrives. Turning either key off only affects future starts; already-pending children stay pending and already-runnable children are not revoked. + +A worker start of a run that is not its own child always requires operator approval, whatever the policy says: a run-management worker can request the start, but never past the approval predicate. + + +`args.auto_approve` on `fabro_run_create` is a different knob. It maps to the child run's own `[run.execution] approval` setting and controls the child's *agent-interaction* approval posture, not its lifecycle start. Setting `args.auto_approve` alone never skips the child-start checkpoint. + ## Supervise children diff --git a/docs/public/execution/run-configuration.mdx b/docs/public/execution/run-configuration.mdx index b4bc7b4f5..d14628877 100644 --- a/docs/public/execution/run-configuration.mdx +++ b/docs/public/execution/run-configuration.mdx @@ -108,6 +108,9 @@ type = "sandbox" command = ["npx", "@playwright/mcp@latest", "--port", "3100", "--headless"] port = 3100 +[run.children] +auto_approve = true + [run.pull_request] enabled = true draft = false @@ -489,6 +492,23 @@ Each collection reflects the post-stage workspace state rather than filesystem m Fabro resolves the configured workspace root but does not recurse through symlinks below it while collecting artifacts. Dependency, cache, and build directories such as `.git`, `node_modules`, `target`, `.venv`, `.cache`, and `dist` are pruned. A collection is limited to 100 files, 10 MB per file, and 50 MB total. +### `[run.children]` + +Configure how this workflow treats the [child runs](/execution/child-runs) its workers start. + +```toml title="run.toml" +[run.children] +auto_approve = true +``` + +| Field | Description | Default | +|---|---|---| +| `auto_approve` | Auto-approve children this workflow spawns at start | `false` | + +This setting belongs to the parent workflow and is the workflow half of the child auto-approval opt-in. A worker-started child only skips the operator approval checkpoint when the server also enables `[server.approvals] allow_child_auto_approval`; any other combination leaves the child pending approval. See [Child Runs — Start and approval](/execution/child-runs#start-and-approval). + +`auto_approve` here governs the child's lifecycle start. It is distinct from `args.auto_approve` on `fabro_run_create` (the child's own `[run.execution] approval` posture for agent interactions) and from `[run.execution] approval`, which applies to this run's own agent stages. + ### `[run.agent]` Configure workflow agent behavior that is not tied to a single stage. diff --git a/lib/apps/fabro-server/src/server.rs b/lib/apps/fabro-server/src/server.rs index 27164f760..99a6ec6f1 100644 --- a/lib/apps/fabro-server/src/server.rs +++ b/lib/apps/fabro-server/src/server.rs @@ -3769,6 +3769,7 @@ fn apply_lifecycle_to_managed_run(state: &AppState, run_id: RunId, record: &RunL RunLifecycleKind::Runnable | RunLifecycleKind::StartRequested | RunLifecycleKind::Approved + | RunLifecycleKind::AutoApproved | RunLifecycleKind::Denied | RunLifecycleKind::CancelRequested | RunLifecycleKind::PauseRequested diff --git a/lib/apps/fabro-server/src/server/handler/lifecycle.rs b/lib/apps/fabro-server/src/server/handler/lifecycle.rs index b5b535c93..4d2c01f1a 100644 --- a/lib/apps/fabro-server/src/server/handler/lifecycle.rs +++ b/lib/apps/fabro-server/src/server/handler/lifecycle.rs @@ -119,6 +119,59 @@ pub(in crate::server) async fn queue_run_start( queue_run(state, id, &run_state, resume, actor).await } +/// How a non-resume start is decided for the run's approval gate. +enum ChildStartDecision { + /// Not a worker-started child: queue as runnable, unchanged. + NotGated, + /// A worker-started child waiting for operator approval. + ApprovalRequired, + /// A worker-started child approved by policy. + AutoApproved, +} + +/// Decide whether a non-resume start of `run_state` needs operator approval. +/// +/// A worker-started child is auto-approved only when the server enables the +/// capability and the spawning parent's persisted workflow settings opt in. +/// A worker start of any other run fails closed to the approval gate: the +/// run-management path may still request it, but never past the predicate. +/// The global setting is read here, at the start decision, so a config +/// reload affects future starts only. +async fn child_start_decision( + state: &AppState, + run_state: &fabro_store::RunProjection, + actor: &Principal, +) -> Result { + let Principal::Worker { run_id } = actor else { + return Ok(ChildStartDecision::NotGated); + }; + if run_state.spec.run_id == *run_id { + // A worker's own run — a resume or self-management — keeps its path. + return Ok(ChildStartDecision::NotGated); + } + if run_state.parent_id != Some(*run_id) { + // Not this worker's child: there is no spawning parent whose policy + // could approve it, so operator approval is required. + return Ok(ChildStartDecision::ApprovalRequired); + } + if !state.server_settings().server.approvals.allow_child_auto_approval { + return Ok(ChildStartDecision::ApprovalRequired); + } + // The spawning parent owns the policy: read it from the parent's own + // persisted settings, not from the child's parent link, so reassigning + // the link cannot change the decision. A parent whose projection is gone + // fails closed to the approval gate. + let parent = run_records::projection(state, *run_id) + .await + .map_err(|err| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?; + let opted_in = parent.is_some_and(|parent| parent.spec.settings.run.children.auto_approve); + Ok(if opted_in { + ChildStartDecision::AutoApproved + } else { + ChildStartDecision::ApprovalRequired + }) +} + /// Queue the run for the scheduler: record that its start was requested /// and that it is runnable (or pending approval), and register it as a /// managed run in start or resume mode. The caller has checked that the run @@ -136,11 +189,12 @@ pub(super) async fn queue_run( .root() .to_path_buf(); let dot_source = run_state.spec.graph_source.clone().unwrap_or_default(); - let approval_required = !resume - && matches!( - &actor, - Principal::Worker { run_id } if run_state.parent_id == Some(*run_id) - ); + let decision = if resume { + ChildStartDecision::NotGated + } else { + child_start_decision(state, run_state, &actor).await? + }; + let approval_required = matches!(decision, ChildStartDecision::ApprovalRequired); let mut start_requested = RunLifecycleRecord::new(RunLifecycleKind::StartRequested); start_requested.source = Some(if resume { "resume" } else { "start" }.to_string()); let next_status = if approval_required { @@ -150,12 +204,25 @@ pub(super) async fn queue_run( } else { RunStatus::Runnable }; - let next = if approval_required { - run_records::transition(RunLifecycleKind::Pending, next_status) - } else { - run_records::runnable(RunRunnableSource::StartRequested) + let records = match decision { + ChildStartDecision::NotGated => vec![ + start_requested, + run_records::runnable(RunRunnableSource::StartRequested), + ], + ChildStartDecision::ApprovalRequired => vec![ + start_requested, + run_records::transition(RunLifecycleKind::Pending, next_status), + ], + // Policy approval is recorded as its own auditable transition before + // the runnable state, so the projection never shows a transitive + // pending state for an auto-approved child. + ChildStartDecision::AutoApproved => vec![ + start_requested, + RunLifecycleRecord::new(RunLifecycleKind::AutoApproved), + run_records::runnable(RunRunnableSource::AutoApproved), + ], }; - for record in [start_requested, next] { + for record in records { if let Err(err) = run_records::lifecycle(state, id, record).await { return Err(ApiError::new( StatusCode::INTERNAL_SERVER_ERROR, diff --git a/lib/apps/fabro-server/src/server/tests.rs b/lib/apps/fabro-server/src/server/tests.rs index a1f978590..5f4b740fe 100644 --- a/lib/apps/fabro-server/src/server/tests.rs +++ b/lib/apps/fabro-server/src/server/tests.rs @@ -7989,6 +7989,314 @@ async fn denying_pending_child_run_fails_with_approval_denied() { assert!(denied_body["lifecycle"]["approval"]["denial_reason"].is_null()); } +const CHILD_AUTO_APPROVE_TOML: &str = "_version = 1\n\n[run.children]\nauto_approve = true\n"; + +/// A JWT-authenticated app whose server settings carry the child +/// auto-approval opt-in. +fn child_policy_app(allow_child_auto_approval: bool) -> (Arc, Router) { + let source = format!( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.approvals] +allow_child_auto_approval = {allow_child_auto_approval} +"# + ); + let state = test_app_state_with_session_key( + server_settings_from_toml(&source), + manifest_run_defaults_from_toml(&source), + Some(TEST_SESSION_SECRET), + ); + let app = build_router(Arc::clone(&state), jwt_auth_mode()); + (state, app) +} + +async fn create_run_with_workflow_config( + app: &Router, + bearer: &str, + workflow_toml: Option<&str>, +) -> RunId { + let intent = test_intent_with_bearer( + app, + "workflow.fabro", + MINIMAL_DOT, + workflow_toml, + Some(bearer), + ) + .await; + let response = app + .clone() + .oneshot(json_bearer_request(Method::POST, "/runs", bearer, &intent)) + .await + .unwrap(); + let body = response_json!(response, StatusCode::CREATED).await; + body["id"].as_str().unwrap().parse::().unwrap() +} + +async fn create_child_as_worker( + app: &Router, + worker_token: &str, + parent_run_id: RunId, + registrar_bearer: &str, +) -> RunId { + let mut child_intent = test_intent_with_bearer( + app, + "workflow.fabro", + MINIMAL_DOT, + None, + Some(registrar_bearer), + ) + .await; + child_intent["parent_id"] = json!(parent_run_id.to_string()); + let response = app + .clone() + .oneshot(json_bearer_request( + Method::POST, + "/runs", + worker_token, + &child_intent, + )) + .await + .unwrap(); + let body = response_json!(response, StatusCode::CREATED).await; + body["id"].as_str().unwrap().parse::().unwrap() +} + +async fn start_run_as_worker(app: &Router, worker_token: &str, run_id: RunId) -> Response { + app.clone() + .oneshot(json_bearer_request( + Method::POST, + &format!("/runs/{run_id}/start"), + worker_token, + &json!({ "resume": false }), + )) + .await + .unwrap() +} + +async fn child_lifecycle_transitions(state: &AppState, run_id: RunId) -> Vec { + state + .stores + .run_summaries + .platform_records() + .read(&run_id) + .await + .unwrap() + .iter() + .filter_map(|stored| match &stored.record { + PlatformRecord::RunLifecycle(record) => Some(record.transition.to_string()), + _ => None, + }) + .collect() +} + +#[tokio::test] +async fn child_start_stays_pending_when_only_the_workflow_opts_in() { + let (state, app) = jwt_auth_app(); + let user_jwt = issue_test_user_jwt(); + let parent_run_id = + create_run_with_workflow_config(&app, &user_jwt, Some(CHILD_AUTO_APPROVE_TOML)).await; + let worker_token = issue_test_run_tools_worker_token(&parent_run_id); + let child_run_id = + create_child_as_worker(&app, &worker_token, parent_run_id, &user_jwt).await; + + let pending_body = response_json!( + start_run_as_worker(&app, &worker_token, child_run_id).await, + StatusCode::OK + ) + .await; + assert_eq!( + run_json_status(&pending_body), + &json!({ + "kind": "pending", + "reason": "approval_required" + }) + ); + assert_eq!( + pending_body["lifecycle"]["approval"]["state"].as_str(), + Some("pending") + ); + + let runs = state.runs.lock().expect("runs lock poisoned"); + assert_eq!( + runs.get(&child_run_id).map(|run| run.status), + Some(RunStatus::Pending { + reason: fabro_types::PendingReason::ApprovalRequired, + }) + ); +} + +#[tokio::test] +async fn child_start_stays_pending_when_only_the_server_opts_in() { + let (state, app) = child_policy_app(true); + let user_jwt = issue_test_user_jwt(); + let parent_run_id = create_run_with_workflow_config(&app, &user_jwt, None).await; + let worker_token = issue_test_run_tools_worker_token(&parent_run_id); + let child_run_id = + create_child_as_worker(&app, &worker_token, parent_run_id, &user_jwt).await; + + let pending_body = response_json!( + start_run_as_worker(&app, &worker_token, child_run_id).await, + StatusCode::OK + ) + .await; + assert_eq!( + run_json_status(&pending_body), + &json!({ + "kind": "pending", + "reason": "approval_required" + }) + ); + + let runs = state.runs.lock().expect("runs lock poisoned"); + assert_eq!( + runs.get(&child_run_id).map(|run| run.status), + Some(RunStatus::Pending { + reason: fabro_types::PendingReason::ApprovalRequired, + }) + ); +} + +#[tokio::test] +async fn child_start_is_auto_approved_when_both_opt_ins_are_enabled() { + let (state, app) = child_policy_app(true); + let user_jwt = issue_test_user_jwt(); + let parent_run_id = + create_run_with_workflow_config(&app, &user_jwt, Some(CHILD_AUTO_APPROVE_TOML)).await; + let worker_token = issue_test_run_tools_worker_token(&parent_run_id); + let child_run_id = + create_child_as_worker(&app, &worker_token, parent_run_id, &user_jwt).await; + + let approved_body = response_json!( + start_run_as_worker(&app, &worker_token, child_run_id).await, + StatusCode::OK + ) + .await; + assert_eq!( + run_json_status(&approved_body), + &json!({ "kind": "runnable" }) + ); + assert_eq!( + approved_body["lifecycle"]["approval"]["state"].as_str(), + Some("approved") + ); + assert!( + approved_body["lifecycle"]["approval"]["decided_at"] + .as_str() + .is_some() + ); + + // The lifecycle history records one deliberate policy approval; the + // projection never shows a transient pending state. + let transitions = child_lifecycle_transitions(state.as_ref(), child_run_id).await; + assert!(transitions.contains(&"auto_approved".to_string())); + assert!(!transitions.contains(&"pending".to_string())); + + let runs = state.runs.lock().expect("runs lock poisoned"); + assert_eq!( + runs.get(&child_run_id).map(|run| run.status), + Some(RunStatus::Runnable) + ); +} + +#[tokio::test] +async fn child_start_ignores_client_auto_approve_args_without_policy() { + let (_state, app) = jwt_auth_app(); + let user_jwt = issue_test_user_jwt(); + let parent_run_id = create_run_with_bearer(&app, &user_jwt).await; + let worker_token = issue_test_run_tools_worker_token(&parent_run_id); + + let mut child_intent = test_intent_with_bearer( + &app, + "workflow.fabro", + MINIMAL_DOT, + None, + Some(&user_jwt), + ) + .await; + child_intent["parent_id"] = json!(parent_run_id.to_string()); + child_intent["args"] = json!({ "auto_approve": true }); + let response = app + .clone() + .oneshot(json_bearer_request( + Method::POST, + "/runs", + &worker_token, + &child_intent, + )) + .await + .unwrap(); + let child_body = response_json!(response, StatusCode::CREATED).await; + let child_run_id = child_body["id"].as_str().unwrap().parse::().unwrap(); + + let pending_body = response_json!( + start_run_as_worker(&app, &worker_token, child_run_id).await, + StatusCode::OK + ) + .await; + assert_eq!( + run_json_status(&pending_body), + &json!({ + "kind": "pending", + "reason": "approval_required" + }) + ); +} + +#[tokio::test] +async fn worker_start_of_runs_it_did_not_spawn_fails_closed_to_approval() { + let (state, app) = jwt_auth_app(); + let user_jwt = issue_test_user_jwt(); + let parent_run_id = create_run_with_bearer(&app, &user_jwt).await; + let unrelated_run_id = create_run_with_bearer(&app, &user_jwt).await; + let worker_token = issue_test_run_tools_worker_token(&parent_run_id); + + // A worker can still create an unparented run through the generic route + // (the run tools link parents through the management route), but the + // start decision treats it as any other unrelated run. + let created = create_run_with_bearer(&app, &worker_token).await; + let pending_body = response_json!( + start_run_as_worker(&app, &worker_token, created).await, + StatusCode::OK + ) + .await; + assert_eq!( + run_json_status(&pending_body), + &json!({ + "kind": "pending", + "reason": "approval_required" + }) + ); + + // Starting a run the worker did not spawn fails closed to the approval + // gate instead of skipping it. + let pending_body = response_json!( + start_run_as_worker(&app, &worker_token, unrelated_run_id).await, + StatusCode::OK + ) + .await; + assert_eq!( + run_json_status(&pending_body), + &json!({ + "kind": "pending", + "reason": "approval_required" + }) + ); + let projection = state + .load_run_projection(&unrelated_run_id) + .await + .unwrap(); + assert_eq!( + projection.status, + RunStatus::Pending { + reason: fabro_types::PendingReason::ApprovalRequired, + } + ); +} + #[tokio::test] async fn patch_run_title_rejects_invalid_titles() { let app = test_app_with(); diff --git a/lib/components/fabro-petri/src/projection/platform.rs b/lib/components/fabro-petri/src/projection/platform.rs index eb0519d8a..74a174539 100644 --- a/lib/components/fabro-petri/src/projection/platform.rs +++ b/lib/components/fabro-petri/src/projection/platform.rs @@ -221,6 +221,25 @@ fn fold_lifecycle(projection: &mut RunProjection, record: &RunLifecycleRecord, a approval.decided_at = Some(at); } } + Kind::AutoApproved => { + // A child start approved by policy: no request preceded the + // decision, so the approval is created approved in one step + // rather than projected through a transient pending state. + match projection.approval.as_mut() { + Some(approval) => { + approval.state = RunApprovalState::Approved; + approval.decided_at = Some(at); + } + None => { + projection.approval = Some(RunApproval { + state: RunApprovalState::Approved, + requested_at: at, + decided_at: Some(at), + denial_reason: None, + }); + } + } + } Kind::Denied => { if let Some(approval) = projection.approval.as_mut() { approval.state = RunApprovalState::Denied; diff --git a/lib/components/fabro-store/src/platform_records.rs b/lib/components/fabro-store/src/platform_records.rs index 2424740c9..ca5978399 100644 --- a/lib/components/fabro-store/src/platform_records.rs +++ b/lib/components/fabro-store/src/platform_records.rs @@ -310,6 +310,7 @@ pub enum RunLifecycleKind { StartRequested, Pending, Approved, + AutoApproved, Denied, Runnable, Starting, diff --git a/lib/foundation/fabro-api/build.rs b/lib/foundation/fabro-api/build.rs index 551968fc1..443d29f35 100644 --- a/lib/foundation/fabro-api/build.rs +++ b/lib/foundation/fabro-api/build.rs @@ -267,6 +267,11 @@ fn main() { "fabro_types::settings::run::RunModelControls", &[], ), + ( + "RunChildrenSettings", + "fabro_types::settings::run::RunChildrenSettings", + &[], + ), ( "ServerSandboxSettings", "fabro_types::settings::server::ServerSandboxSettings", @@ -307,6 +312,11 @@ fn main() { "fabro_types::settings::server::ServerSchedulerSettings", &[], ), + ( + "ServerApprovalsSettings", + "fabro_types::settings::server::ServerApprovalsSettings", + &[], + ), ( "ServerLoggingSettings", "fabro_types::settings::server::ServerLoggingSettings", diff --git a/lib/foundation/fabro-api/src/lib.rs b/lib/foundation/fabro-api/src/lib.rs index 8f4707739..0b2c3a1dc 100644 --- a/lib/foundation/fabro-api/src/lib.rs +++ b/lib/foundation/fabro-api/src/lib.rs @@ -20,16 +20,17 @@ pub mod types { }; pub use fabro_environment::Environment; pub use fabro_types::settings::run::{ - McpHttpProtocol, RunIntegrationsGithubSettings, RunIntegrationsSettings, RunModelControls, - RunModelSettings, + McpHttpProtocol, RunChildrenSettings, RunIntegrationsGithubSettings, + RunIntegrationsSettings, RunModelControls, RunModelSettings, }; pub use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, - LogDestination, ObjectStoreSettings, ServerApiSettings, ServerArtifactsSettings, - ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, ServerIntegrationsSettings, - ServerListenSettings, ServerLoggingSettings, ServerSandboxProviderSettings, - ServerSandboxProvidersSettings, ServerSandboxSettings, ServerSchedulerSettings, - ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, WebhookStrategy, + LogDestination, ObjectStoreSettings, ServerApiSettings, ServerApprovalsSettings, + ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, + ServerIntegrationsSettings, ServerListenSettings, ServerLoggingSettings, + ServerSandboxProviderSettings, ServerSandboxProvidersSettings, ServerSandboxSettings, + ServerSchedulerSettings, ServerStorageSettings, ServerWebSettings, + SlackIntegrationSettings, WebhookStrategy, }; pub use fabro_types::settings::{McpTransport, ServerNamespace}; pub use fabro_types::status::{ diff --git a/lib/foundation/fabro-api/tests/server_settings_round_trip.rs b/lib/foundation/fabro-api/tests/server_settings_round_trip.rs index d96002d8f..5455cee0f 100644 --- a/lib/foundation/fabro-api/tests/server_settings_round_trip.rs +++ b/lib/foundation/fabro-api/tests/server_settings_round_trip.rs @@ -2,7 +2,7 @@ use std::any::{TypeId, type_name}; use fabro_api::types::{ LogDestination as ApiLogDestination, ObjectStoreSettings as ApiObjectStoreSettings, - ServerNamespace as ApiServerNamespace, + ServerApprovalsSettings as ApiServerApprovalsSettings, ServerNamespace as ApiServerNamespace, ServerSandboxProviderSettings as ApiServerSandboxProviderSettings, ServerSandboxProvidersSettings as ApiServerSandboxProvidersSettings, ServerSandboxSettings as ApiServerSandboxSettings, ServerSettings as ApiServerSettings, @@ -11,7 +11,7 @@ use fabro_config::ServerSettingsBuilder; use fabro_types::ServerSettings; use fabro_types::settings::ServerNamespace; use fabro_types::settings::server::{ - LogDestination, ObjectStoreSettings, ServerSandboxProviderSettings, + LogDestination, ObjectStoreSettings, ServerApprovalsSettings, ServerSandboxProviderSettings, ServerSandboxProvidersSettings, ServerSandboxSettings, }; @@ -24,6 +24,7 @@ fn server_settings_family_reuses_domain_types() { assert_same_type::(); assert_same_type::(); assert_same_type::(); + assert_same_type::(); } #[test] @@ -76,6 +77,10 @@ strategy = "tailscale_funnel" assert_eq!(json["server"]["listen"]["address"], "127.0.0.1:32276"); assert_eq!(json["server"]["storage"]["root"], "/srv/fabro"); assert_eq!(json["server"]["logging"]["destination"], "stdout"); + assert_eq!( + json["server"]["approvals"]["allow_child_auto_approval"], + false + ); assert_eq!( json["server"]["sandbox"]["providers"]["local"]["enabled"], true diff --git a/lib/foundation/fabro-api/tests/workflow_settings_round_trip.rs b/lib/foundation/fabro-api/tests/workflow_settings_round_trip.rs index c253f217b..8f0860fda 100644 --- a/lib/foundation/fabro-api/tests/workflow_settings_round_trip.rs +++ b/lib/foundation/fabro-api/tests/workflow_settings_round_trip.rs @@ -1,8 +1,11 @@ use std::any::{TypeId, type_name}; -use fabro_api::types::WorkflowSettings as ApiWorkflowSettings; +use fabro_api::types::{ + RunChildrenSettings as ApiRunChildrenSettings, WorkflowSettings as ApiWorkflowSettings, +}; use fabro_config::{EnvironmentLayer, MergeMap, RunLayer, SettingsLayer, WorkflowSettingsBuilder}; use fabro_types::WorkflowSettings; +use fabro_types::settings::run::RunChildrenSettings; fn seeded_environment_catalog() -> MergeMap { r#" @@ -29,6 +32,7 @@ fn workflow_settings_from_toml(source: &str) -> WorkflowSettings { #[test] fn workflow_settings_family_reuses_domain_types() { assert_same_type::(); + assert_same_type::(); } #[test] @@ -105,6 +109,42 @@ fn workflow_settings_default_run_checkpoint_skip_git_hooks_is_false() { assert_eq!(json["run"]["checkpoint"]["skip_git_hooks"], false); } +#[test] +fn workflow_settings_run_children_auto_approve_defaults_to_false() { + let settings = workflow_settings_from_toml("_version = 1\n"); + let json = serde_json::to_value(&settings).expect("workflow settings should serialize"); + assert_eq!(json["run"]["children"]["auto_approve"], false); + assert!(!settings.run.children.auto_approve); +} + +#[test] +fn workflow_settings_run_children_auto_approve_round_trips() { + let settings = workflow_settings_from_toml( + r#" +_version = 1 + +[run.children] +auto_approve = true +"#, + ); + + let json = serde_json::to_value(&settings).expect("workflow settings should serialize"); + assert_eq!(json["run"]["children"]["auto_approve"], true); + + let round_trip: ApiWorkflowSettings = + serde_json::from_value(json).expect("workflow settings should deserialize"); + assert_eq!(round_trip, settings); + assert!(round_trip.run.children.auto_approve); +} + +#[test] +fn persisted_run_settings_without_children_field_deserialize_to_default() { + // Run specs persisted before `[run.children]` existed omit the field. + let legacy: RunChildrenSettings = serde_json::from_value(serde_json::json!({})) + .expect("legacy run children settings should deserialize"); + assert!(!legacy.auto_approve); +} + fn assert_same_type() { assert_eq!( TypeId::of::(), diff --git a/lib/foundation/fabro-config/src/defaults.toml b/lib/foundation/fabro-config/src/defaults.toml index 2a62cfd1f..3a1b8c04b 100644 --- a/lib/foundation/fabro-config/src/defaults.toml +++ b/lib/foundation/fabro-config/src/defaults.toml @@ -39,6 +39,9 @@ url = "http://localhost:3000" [server.scheduler] max_concurrent_runs = 5 +[server.approvals] +allow_child_auto_approval = false + [server.artifacts] provider = "local" prefix = "" diff --git a/lib/foundation/fabro-config/src/layers/mod.rs b/lib/foundation/fabro-config/src/layers/mod.rs index cbb0c70de..ff578e184 100644 --- a/lib/foundation/fabro-config/src/layers/mod.rs +++ b/lib/foundation/fabro-config/src/layers/mod.rs @@ -28,15 +28,15 @@ pub use run::{ GitAuthorLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, InterviewsLayer, McpEntryLayer, ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, PrepareStep, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, - RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, - RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, - RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, - StringOrSplice, + RunChildrenLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, + RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, + RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, + RunScmLayer, ScmGitHubLayer, StringOrSplice, }; pub use server::{ GithubIntegrationLayer, IntegrationWebhooksLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, - ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, - ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, + ServerApiLayer, ServerApprovalsLayer, ServerArtifactsLayer, ServerAuthGithubLayer, + ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer, SlackIntegrationLayer, }; diff --git a/lib/foundation/fabro-config/src/layers/run.rs b/lib/foundation/fabro-config/src/layers/run.rs index 9f9056cb2..0bfb305b8 100644 --- a/lib/foundation/fabro-config/src/layers/run.rs +++ b/lib/foundation/fabro-config/src/layers/run.rs @@ -62,6 +62,21 @@ pub struct RunLayer { pub artifacts: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub integrations: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub children: Option, +} + +/// `[run.children]` — how this workflow treats the child runs its workers +/// start. Belongs to the parent workflow: it governs children dispatched by +/// that workflow, never the run's own agent interactions +/// (`[run.execution] approval`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct RunChildrenLayer { + /// Workflow half of the child auto-approval opt-in; also requires the + /// server's `[server.approvals] allow_child_auto_approval`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auto_approve: Option, } /// `[run.integrations]` — run-level integration knobs. diff --git a/lib/foundation/fabro-config/src/layers/server.rs b/lib/foundation/fabro-config/src/layers/server.rs index e15892707..d9e099e9b 100644 --- a/lib/foundation/fabro-config/src/layers/server.rs +++ b/lib/foundation/fabro-config/src/layers/server.rs @@ -33,6 +33,8 @@ pub struct ServerLayer { #[serde(default, skip_serializing_if = "Option::is_none")] pub scheduler: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub approvals: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub logging: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub integrations: Option, @@ -195,6 +197,16 @@ pub struct ServerSchedulerLayer { pub max_concurrent_runs: Option, } +/// `[server.approvals]` — server-owned approval policy. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] +#[serde(deny_unknown_fields)] +pub struct ServerApprovalsLayer { + /// Server half of the child auto-approval opt-in. The workflow half is + /// `[run.children] auto_approve` in the parent workflow's `workflow.toml`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allow_child_auto_approval: Option, +} + /// `[server.logging]` — process-owned logging configuration for the server. #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)] #[serde(deny_unknown_fields)] diff --git a/lib/foundation/fabro-config/src/lib.rs b/lib/foundation/fabro-config/src/lib.rs index d94b046da..31dd029ac 100644 --- a/lib/foundation/fabro-config/src/lib.rs +++ b/lib/foundation/fabro-config/src/lib.rs @@ -47,12 +47,13 @@ pub use layers::{ HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer, InterviewsLayer, LlmLayer, LogFilter, McpEntryLayer, MergeMap, ModelRefOrSplice, NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer, PrepareStep, ProjectLayer, - ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, - RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, - RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, RunModelControlsLayer, RunModelLayer, - RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, ScmGitHubLayer, - ServerApiLayer, ServerArtifactsLayer, ServerAuthGithubLayer, ServerAuthLayer, - ServerIntegrationsLayer, ServerLayer, ServerListenLayer, ServerLoggingLayer, + ReplaceMap, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunChildrenLayer, + RunCloneLayer, RunEnvironmentLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, + RunIntegrationsGithubLayer, RunIntegrationsLayer, RunLayer, RunMetaBranchLayer, + RunModelControlsLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, + RunScmLayer, ScmGitHubLayer, ServerApiLayer, ServerApprovalsLayer, ServerArtifactsLayer, + ServerAuthGithubLayer, ServerAuthLayer, ServerIntegrationsLayer, ServerLayer, + ServerListenLayer, ServerLoggingLayer, ServerSandboxLayer, ServerSandboxProviderLayer, ServerSandboxProvidersLayer, ServerSchedulerLayer, ServerStorageLayer, ServerWebLayer, SettingsLayer, SlackIntegrationLayer, StickyMap, StringOrSplice, WorkflowLayer, diff --git a/lib/foundation/fabro-config/src/resolve/run.rs b/lib/foundation/fabro-config/src/resolve/run.rs index 61343f1ec..efaf38f08 100644 --- a/lib/foundation/fabro-config/src/resolve/run.rs +++ b/lib/foundation/fabro-config/src/resolve/run.rs @@ -7,10 +7,11 @@ use fabro_types::settings::run::{ ArtifactsSettings, GitAuthorSettings, HookDefinition, HookType, InterviewProviderSettings, McpServerSettings, McpTransport, MergeStrategy, NotificationProviderSettings, NotificationRouteSettings, PreparedStep, PreparedStepRun, PullRequestSettings, - ResolvedMcpEntry, RunAgentSettings, RunBranchSettings, RunCheckpointSettings, RunCloneSettings, - RunExecutionSettings, RunGitSettings, RunGoal, RunIntegrationsGithubSettings, - RunIntegrationsSettings, RunInterviewsSettings, RunModelControls, RunModelSettings, - RunNamespace, RunPrepareSettings, RunScmSettings, ScmGitHubSettings, TlsMode, + ResolvedMcpEntry, RunAgentSettings, RunBranchSettings, RunCheckpointSettings, + RunChildrenSettings, RunCloneSettings, RunExecutionSettings, RunGitSettings, RunGoal, + RunIntegrationsGithubSettings, RunIntegrationsSettings, RunInterviewsSettings, + RunModelControls, RunModelSettings, RunNamespace, RunPrepareSettings, RunScmSettings, + ScmGitHubSettings, TlsMode, }; use fabro_util::workspace_glob::WorkspaceGlob; @@ -18,10 +19,10 @@ use super::{ResolveError, resolve_run_environment}; use crate::{ EnvironmentLayer, HookAgentMarker, HookEntry, HookTlsMode, InterviewProviderLayer, InterviewsLayer, McpEntryLayer, MergeMap, ModelRefOrSplice, NotificationProviderLayer, - NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, - RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, RunIntegrationsLayer, - RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, RunRunBranchLayer, RunScmLayer, - StickyMap, StringOrSplice, + NotificationRouteLayer, RunAgentLayer, RunArtifactsLayer, RunCheckpointLayer, RunChildrenLayer, + RunCloneLayer, RunExecutionLayer, RunGitLayer, RunGoalLayer, RunIntegrationsGithubLayer, + RunIntegrationsLayer, RunLayer, RunModelLayer, RunPrepareLayer, RunPullRequestLayer, + RunRunBranchLayer, RunScmLayer, StickyMap, StringOrSplice, }; pub fn resolve_run( @@ -74,6 +75,15 @@ pub fn resolve_run( pull_request, artifacts: resolve_artifacts(layer.artifacts.as_ref(), errors), integrations: resolve_integrations(layer.integrations.as_ref(), errors), + children: resolve_children(layer.children.as_ref()), + } +} + +fn resolve_children(children: Option<&RunChildrenLayer>) -> RunChildrenSettings { + RunChildrenSettings { + auto_approve: children + .and_then(|children| children.auto_approve) + .unwrap_or(false), } } diff --git a/lib/foundation/fabro-config/src/resolve/server.rs b/lib/foundation/fabro-config/src/resolve/server.rs index 8ce62c516..7b60f9ac9 100644 --- a/lib/foundation/fabro-config/src/resolve/server.rs +++ b/lib/foundation/fabro-config/src/resolve/server.rs @@ -5,11 +5,11 @@ use fabro_types::SandboxProviderKind; use fabro_types::settings::server::{ GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings, ObjectStoreProvider, ObjectStoreSettings, SandboxPluginSettings, ServerApiSettings, - ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, ServerAuthSettings, - ServerIntegrationsSettings, ServerListenSettings, ServerLoggingSettings, ServerNamespace, - ServerSandboxProviderSettings, ServerSandboxProvidersSettings, ServerSandboxSettings, - ServerSchedulerSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings, - WebhookStrategy, + ServerApprovalsSettings, ServerArtifactsSettings, ServerAuthGithubSettings, ServerAuthMethod, + ServerAuthSettings, ServerIntegrationsSettings, ServerListenSettings, ServerLoggingSettings, + ServerNamespace, ServerSandboxProviderSettings, ServerSandboxProvidersSettings, + ServerSandboxSettings, ServerSchedulerSettings, ServerStorageSettings, ServerWebSettings, + SlackIntegrationSettings, WebhookStrategy, }; use fabro_util::Home; @@ -50,6 +50,13 @@ pub fn resolve_server(layer: &ServerLayer, errors: &mut Vec) -> Se .and_then(|scheduler| scheduler.max_concurrent_runs) .expect("defaults.toml should provide server.scheduler.max_concurrent_runs"), }, + approvals: ServerApprovalsSettings { + allow_child_auto_approval: layer + .approvals + .as_ref() + .and_then(|approvals| approvals.allow_child_auto_approval) + .unwrap_or(false), + }, logging: ServerLoggingSettings { level: layer .logging diff --git a/lib/foundation/fabro-config/src/tests/resolve_run.rs b/lib/foundation/fabro-config/src/tests/resolve_run.rs index 8aa76566f..a3774e558 100644 --- a/lib/foundation/fabro-config/src/tests/resolve_run.rs +++ b/lib/foundation/fabro-config/src/tests/resolve_run.rs @@ -2000,3 +2000,28 @@ command = ["fs-server"] ); } } + +#[test] +fn run_children_auto_approve_defaults_to_false() { + let settings = super::workflow_settings_from_layer(SettingsLayer::default()) + .expect("empty settings should resolve") + .run; + + assert!(!settings.children.auto_approve); +} + +#[test] +fn run_children_auto_approve_resolves_from_workflow_toml() { + let settings = super::workflow_settings_from_toml( + r#" +_version = 1 + +[run.children] +auto_approve = true +"#, + ) + .expect("[run.children] should resolve") + .run; + + assert!(settings.children.auto_approve); +} diff --git a/lib/foundation/fabro-config/src/tests/resolve_server.rs b/lib/foundation/fabro-config/src/tests/resolve_server.rs index 33995be1f..58497e78f 100644 --- a/lib/foundation/fabro-config/src/tests/resolve_server.rs +++ b/lib/foundation/fabro-config/src/tests/resolve_server.rs @@ -681,3 +681,27 @@ methods = ["dev-token", "github"] assert!(dev_token_auth_enabled(&both)); assert!(!dev_token_auth_enabled(&SettingsLayer::default())); } + +#[test] +fn child_auto_approval_is_disabled_by_default() { + let settings = resolve_server(&empty_settings_with_auth_methods()); + + assert!(!settings.approvals.allow_child_auto_approval); +} + +#[test] +fn child_auto_approval_resolves_from_server_config() { + let layer = parse( + r#" +_version = 1 + +[server.auth] +methods = ["dev-token"] + +[server.approvals] +allow_child_auto_approval = true +"#, + ); + + assert!(resolve_server(&layer).approvals.allow_child_auto_approval); +} diff --git a/lib/foundation/fabro-types/src/settings/run.rs b/lib/foundation/fabro-types/src/settings/run.rs index 5faefc4cc..1862a8470 100644 --- a/lib/foundation/fabro-types/src/settings/run.rs +++ b/lib/foundation/fabro-types/src/settings/run.rs @@ -45,6 +45,7 @@ pub struct RunNamespace { pub pull_request: Option, pub artifacts: ArtifactsSettings, pub integrations: RunIntegrationsSettings, + pub children: RunChildrenSettings, } #[expect( @@ -74,6 +75,7 @@ impl Default for RunNamespace { pull_request: None, artifacts: ArtifactsSettings::default(), integrations: RunIntegrationsSettings::default(), + children: RunChildrenSettings::default(), } } } @@ -597,6 +599,24 @@ pub struct RunIntegrationsSettings { pub github: RunIntegrationsGithubSettings, } +/// `[run.children]` — how this workflow treats the child runs its workers +/// start. +/// +/// This governs the child's *lifecycle* start decision (whether the child +/// waits for operator approval), not the child's own agent-interaction +/// approval posture (`run.execution.approval`, which `RunIntentArgs +/// .auto_approve` maps to). It is the workflow half of the child +/// auto-approval opt-in; the server half is `server.approvals +/// .allow_child_auto_approval`, and both must be enabled. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RunChildrenSettings { + /// Whether children this workflow spawns are auto-approved at start. + /// Absent or `false` keeps every worker-started child pending operator + /// approval. + pub auto_approve: bool, +} + /// `[run.integrations.github]` — runtime GitHub token shape. /// /// `permissions` is empty when no token should be requested. The diff --git a/lib/foundation/fabro-types/src/settings/server.rs b/lib/foundation/fabro-types/src/settings/server.rs index d66a83154..7395cdf90 100644 --- a/lib/foundation/fabro-types/src/settings/server.rs +++ b/lib/foundation/fabro-types/src/settings/server.rs @@ -30,6 +30,7 @@ pub struct ServerNamespace { pub storage: ServerStorageSettings, pub artifacts: ServerArtifactsSettings, pub scheduler: ServerSchedulerSettings, + pub approvals: ServerApprovalsSettings, pub logging: ServerLoggingSettings, pub integrations: ServerIntegrationsSettings, } @@ -50,6 +51,7 @@ impl ServerNamespace { storage: ServerStorageSettings::default(), artifacts: ServerArtifactsSettings::default(), scheduler: ServerSchedulerSettings::default(), + approvals: ServerApprovalsSettings::default(), logging: ServerLoggingSettings::default(), integrations: ServerIntegrationsSettings::default(), } @@ -256,6 +258,19 @@ pub struct ServerSchedulerSettings { pub max_concurrent_runs: usize, } +/// `[server.approvals]` — server-owned approval policy. +/// +/// The policy is only half of the child auto-approval opt-in: a workflow must +/// also declare `[run.children] auto_approve` for a worker-started child to +/// skip the operator approval gate. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerApprovalsSettings { + /// Whether policy approval of worker-started child runs is available on + /// this server at all. Absent or `false` keeps every child start on the + /// pending-approval path. + pub allow_child_auto_approval: bool, +} + #[derive( Debug, Clone, diff --git a/lib/foundation/fabro-types/src/status.rs b/lib/foundation/fabro-types/src/status.rs index 1a7db7061..b9eb4eeb4 100644 --- a/lib/foundation/fabro-types/src/status.rs +++ b/lib/foundation/fabro-types/src/status.rs @@ -600,7 +600,8 @@ mod tests { } } -/// What made a run runnable: its start request, or its approval. +/// What made a run runnable: its start request, its approval, or a policy +/// that approved its child start. #[derive( Debug, Clone, @@ -618,4 +619,5 @@ mod tests { pub enum RunRunnableSource { StartRequested, Approved, + AutoApproved, } diff --git a/lib/packages/fabro-api-client/src/.openapi-generator/FILES b/lib/packages/fabro-api-client/src/.openapi-generator/FILES index c97704a3c..5dcf5a945 100644 --- a/lib/packages/fabro-api-client/src/.openapi-generator/FILES +++ b/lib/packages/fabro-api-client/src/.openapi-generator/FILES @@ -380,6 +380,7 @@ models/run-artifact.ts models/run-branch-settings.ts models/run-checkpoint-settings.ts models/run-checkpoint.ts +models/run-children-settings.ts models/run-client-provenance.ts models/run-clone-settings.ts models/run-commit-parent.ts @@ -485,6 +486,7 @@ models/secret-list-response.ts models/secret-metadata.ts models/secret-type.ts models/server-api-settings.ts +models/server-approvals-settings.ts models/server-artifacts-settings.ts models/server-auth-github-settings.ts models/server-auth-method.ts diff --git a/lib/packages/fabro-api-client/src/models/index.ts b/lib/packages/fabro-api-client/src/models/index.ts index f57e1de38..5000cc41f 100644 --- a/lib/packages/fabro-api-client/src/models/index.ts +++ b/lib/packages/fabro-api-client/src/models/index.ts @@ -351,6 +351,7 @@ export * from './run-artifact-list-response'; export * from './run-branch-settings'; export * from './run-checkpoint'; export * from './run-checkpoint-settings'; +export * from './run-children-settings'; export * from './run-client-provenance'; export * from './run-clone-settings'; export * from './run-commit'; @@ -455,6 +456,7 @@ export * from './secret-list-response'; export * from './secret-metadata'; export * from './secret-type'; export * from './server-api-settings'; +export * from './server-approvals-settings'; export * from './server-artifacts-settings'; export * from './server-auth-github-settings'; export * from './server-auth-method'; diff --git a/lib/packages/fabro-api-client/src/models/run-children-settings.ts b/lib/packages/fabro-api-client/src/models/run-children-settings.ts new file mode 100644 index 000000000..c9a1c648f --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/run-children-settings.ts @@ -0,0 +1,22 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * How this workflow treats the child runs its workers start. Governs the child\'s lifecycle start decision, not the child\'s own agent-interaction approval posture (`run.execution.approval`). + */ +export interface RunChildrenSettings { + 'auto_approve': boolean; +} diff --git a/lib/packages/fabro-api-client/src/models/run-namespace.ts b/lib/packages/fabro-api-client/src/models/run-namespace.ts index 56093d25d..b7be42f68 100644 --- a/lib/packages/fabro-api-client/src/models/run-namespace.ts +++ b/lib/packages/fabro-api-client/src/models/run-namespace.ts @@ -36,6 +36,9 @@ import type { RunBranchSettings } from './run-branch-settings'; import type { RunCheckpointSettings } from './run-checkpoint-settings'; // May contain unused imports in some cases // @ts-ignore +import type { RunChildrenSettings } from './run-children-settings'; +// May contain unused imports in some cases +// @ts-ignore import type { RunCloneSettings } from './run-clone-settings'; // May contain unused imports in some cases // @ts-ignore @@ -89,4 +92,5 @@ export interface RunNamespace { 'pull_request': PullRequestSettings | null; 'artifacts': ArtifactsSettings; 'integrations': RunIntegrationsSettings; + 'children': RunChildrenSettings; } diff --git a/lib/packages/fabro-api-client/src/models/run-runnable-source.ts b/lib/packages/fabro-api-client/src/models/run-runnable-source.ts index 35d0db75b..8e55ede5b 100644 --- a/lib/packages/fabro-api-client/src/models/run-runnable-source.ts +++ b/lib/packages/fabro-api-client/src/models/run-runnable-source.ts @@ -20,7 +20,8 @@ export const RunRunnableSource = { START_REQUESTED: 'start_requested', - APPROVED: 'approved' + APPROVED: 'approved', + AUTO_APPROVED: 'auto_approved' } as const; export type RunRunnableSource = typeof RunRunnableSource[keyof typeof RunRunnableSource]; diff --git a/lib/packages/fabro-api-client/src/models/server-approvals-settings.ts b/lib/packages/fabro-api-client/src/models/server-approvals-settings.ts new file mode 100644 index 000000000..d73084bad --- /dev/null +++ b/lib/packages/fabro-api-client/src/models/server-approvals-settings.ts @@ -0,0 +1,22 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Fabro Run API + * HTTP API for managing Fabro workflow run executions. + * + * The version of the OpenAPI document: 0.2.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + + + +/** + * Server-owned approval policy. `allow_child_auto_approval` is the server half of the child auto-approval opt-in; the workflow half is `[run.children] auto_approve` in the parent workflow\'s `workflow.toml`, and a worker-started child is auto-approved only when both are enabled. + */ +export interface ServerApprovalsSettings { + 'allow_child_auto_approval': boolean; +} diff --git a/lib/packages/fabro-api-client/src/models/server-namespace.ts b/lib/packages/fabro-api-client/src/models/server-namespace.ts index 43cbd5202..539a547b2 100644 --- a/lib/packages/fabro-api-client/src/models/server-namespace.ts +++ b/lib/packages/fabro-api-client/src/models/server-namespace.ts @@ -18,6 +18,9 @@ import type { ServerApiSettings } from './server-api-settings'; // May contain unused imports in some cases // @ts-ignore +import type { ServerApprovalsSettings } from './server-approvals-settings'; +// May contain unused imports in some cases +// @ts-ignore import type { ServerArtifactsSettings } from './server-artifacts-settings'; // May contain unused imports in some cases // @ts-ignore @@ -53,6 +56,7 @@ export interface ServerNamespace { 'storage': ServerStorageSettings; 'artifacts': ServerArtifactsSettings; 'scheduler': ServerSchedulerSettings; + 'approvals': ServerApprovalsSettings; 'logging': ServerLoggingSettings; 'integrations': ServerIntegrationsSettings; }