mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-23 05:35:40 +00:00
feat(approvals): auto-approve child starts by parent workflow policy
A workflow agent that spawns child runs forces every dispatch through an operator checkpoint, even for dispatcher workflows whose whole purpose is unattended fan-out. The only workaround today is out-of-band approval hooks that poll the runs API and click approve from the host, which needs a server token inside the worker host and leaves the decision recorded nowhere near the run it approved. Give the platform a first-class policy path instead: a child start is auto-approved only when the server enables `[server.approvals] allow_child_auto_approval` and the spawning parent's registered `workflow.toml` opts in with `[run.children] auto_approve`. Both defaults stay false, so nothing changes for existing workflows. The decision is read from the parent's persisted spec at start time, so a config reload affects future starts only, reassigning a run's parent link cannot change the policy, and `RunIntentArgs.auto_approve` keeps its existing meaning (the child's own agent-interaction posture) without ever bypassing the lifecycle checkpoint. A worker start outside its own parentage fails closed to the operator gate. Auto-approved children record a distinct `auto_approved` lifecycle transition and project `approval.state = approved` with a decision timestamp in one pass, so lifecycle history is auditable and no transient pending state is ever observed. Note: this commit depends on lithoscomputer/petri accepting the platform-only `[run.children]` workflow.toml section; the Cargo.lock bump to the merged rev lands with that PR.
This commit is contained in:
parent
18eb72c857
commit
465cc66283
32 changed files with 786 additions and 50 deletions
|
|
@ -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.<slug>]`, `[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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
<Note>
|
||||
`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.
|
||||
</Note>
|
||||
|
||||
## Supervise children
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ChildStartDecision, ApiError> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<AppState>, 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::<RunId>().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::<RunId>().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<String> {
|
||||
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::<RunId>().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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -310,6 +310,7 @@ pub enum RunLifecycleKind {
|
|||
StartRequested,
|
||||
Pending,
|
||||
Approved,
|
||||
AutoApproved,
|
||||
Denied,
|
||||
Runnable,
|
||||
Starting,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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::<ApiServerSandboxSettings, ServerSandboxSettings>();
|
||||
assert_same_type::<ApiServerSandboxProvidersSettings, ServerSandboxProvidersSettings>();
|
||||
assert_same_type::<ApiServerSandboxProviderSettings, ServerSandboxProviderSettings>();
|
||||
assert_same_type::<ApiServerApprovalsSettings, ServerApprovalsSettings>();
|
||||
}
|
||||
|
||||
#[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
|
||||
|
|
|
|||
|
|
@ -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<EnvironmentLayer> {
|
||||
r#"
|
||||
|
|
@ -29,6 +32,7 @@ fn workflow_settings_from_toml(source: &str) -> WorkflowSettings {
|
|||
#[test]
|
||||
fn workflow_settings_family_reuses_domain_types() {
|
||||
assert_same_type::<ApiWorkflowSettings, WorkflowSettings>();
|
||||
assert_same_type::<ApiRunChildrenSettings, RunChildrenSettings>();
|
||||
}
|
||||
|
||||
#[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<T: 'static, U: 'static>() {
|
||||
assert_eq!(
|
||||
TypeId::of::<T>(),
|
||||
|
|
|
|||
|
|
@ -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 = ""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -62,6 +62,21 @@ pub struct RunLayer {
|
|||
pub artifacts: Option<RunArtifactsLayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub integrations: Option<RunIntegrationsLayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub children: Option<RunChildrenLayer>,
|
||||
}
|
||||
|
||||
/// `[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<bool>,
|
||||
}
|
||||
|
||||
/// `[run.integrations]` — run-level integration knobs.
|
||||
|
|
|
|||
|
|
@ -33,6 +33,8 @@ pub struct ServerLayer {
|
|||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub scheduler: Option<ServerSchedulerLayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub approvals: Option<ServerApprovalsLayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub logging: Option<ServerLoggingLayer>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub integrations: Option<ServerIntegrationsLayer>,
|
||||
|
|
@ -195,6 +197,16 @@ pub struct ServerSchedulerLayer {
|
|||
pub max_concurrent_runs: Option<usize>,
|
||||
}
|
||||
|
||||
/// `[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<bool>,
|
||||
}
|
||||
|
||||
/// `[server.logging]` — process-owned logging configuration for the server.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, fabro_macros::Combine)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<ResolveError>) -> 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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ pub struct RunNamespace {
|
|||
pub pull_request: Option<PullRequestSettings>,
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
22
lib/packages/fabro-api-client/src/models/run-children-settings.ts
generated
Normal file
22
lib/packages/fabro-api-client/src/models/run-children-settings.ts
generated
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
22
lib/packages/fabro-api-client/src/models/server-approvals-settings.ts
generated
Normal file
22
lib/packages/fabro-api-client/src/models/server-approvals-settings.ts
generated
Normal file
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue