@feltdb/core 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-identity.json +13 -4
- package/dist/cli/commands.js +96 -45
- package/dist/cli/workspace-integration.js +8 -0
- package/dist/collection.d.ts +41 -4
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +116 -47
- package/dist/composition.d.ts.map +1 -1
- package/dist/composition.js +11 -1
- package/dist/create/build-identity.json +13 -4
- package/dist/create/cli.js +49 -40
- package/dist/create/create.js +32 -19
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +18 -3
- package/dist/create/server-source/Cargo.toml +4 -2
- package/dist/create/server-source/crates/feltdb/Cargo.toml +20 -0
- package/dist/create/server-source/crates/feltdb/LICENSE +21 -0
- package/dist/create/server-source/crates/feltdb/README.md +75 -0
- package/dist/create/server-source/crates/feltdb/src/agent_state.rs +320 -0
- package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +1545 -0
- package/dist/create/server-source/crates/feltdb/src/authority.rs +145 -15
- package/dist/create/server-source/crates/feltdb/src/lib.rs +227 -23
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +204 -1
- package/dist/create/server-source/crates/feltdb/src/workload.rs +685 -53
- package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +1188 -0
- package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +2884 -0
- package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +534 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +23340 -0
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +29 -0
- package/dist/create/server-source/crates/feltdb-client/LICENSE +21 -0
- package/dist/create/server-source/crates/feltdb-client/README.md +70 -0
- package/dist/create/server-source/crates/feltdb-client/src/application.rs +527 -0
- package/dist/create/server-source/crates/feltdb-client/src/authority.rs +679 -0
- package/dist/create/server-source/crates/feltdb-client/src/control.rs +292 -0
- package/dist/create/server-source/crates/feltdb-client/src/error.rs +227 -0
- package/dist/create/server-source/crates/feltdb-client/src/events.rs +146 -0
- package/dist/create/server-source/crates/feltdb-client/src/lib.rs +466 -0
- package/dist/create/server-source/crates/feltdb-client/tests/fixtures/README.md +4 -0
- package/dist/create/server-source/crates/feltdb-client/tests/fixtures/private-ca.pem +20 -0
- package/dist/create/server-source/crates/feltdb-client/tests/fixtures/server-key.pem +28 -0
- package/dist/create/server-source/crates/feltdb-client/tests/fixtures/server.pem +20 -0
- package/dist/create/server-source/crates/feltdb-client/tests/private_ca.rs +95 -0
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +20 -1
- package/dist/create/server-source/crates/feltdb-server/LICENSE +21 -0
- package/dist/create/server-source/crates/feltdb-server/README.md +12 -1
- package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +28 -0
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +6 -2
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +21 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +845 -138
- package/dist/create/server-source/crates/feltdb-server/tests/rust_client.rs +683 -0
- package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +3 -1
- package/dist/create/setup-summary.js +40 -0
- package/dist/create/template/default-project/README.md +12 -7
- package/dist/create/template/default-project/src/pages/Dashboard.tsx +1 -1
- package/dist/create/template/dot-feltdb-README.md +3 -3
- package/dist/db.d.ts +27 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +60 -21
- package/dist/development-runtime-bridge.d.ts +20 -0
- package/dist/development-runtime-bridge.d.ts.map +1 -1
- package/dist/development-runtime-bridge.js +74 -4
- package/dist/embedded-transaction.d.ts +75 -8
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +124 -18
- package/dist/error-codes.d.ts +2 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +2 -0
- package/dist/feltdb.d.ts +1 -11
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +6 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +43 -11
- package/dist/http-db.d.ts +13 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +33 -4
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +65 -41
- package/dist/memory-db.d.ts.map +1 -1
- package/dist/memory-db.js +29 -8
- package/dist/studio/{KeyManagementPanel-DZuSeWBK.js → KeyManagementPanel-skUNnKRw.js} +36 -28
- package/dist/studio/app.d.ts +8 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.js +1 -1
- package/dist/studio/components/RouteErrorBoundary.d.ts +5 -0
- package/dist/studio/components/RouteErrorBoundary.d.ts.map +1 -0
- package/dist/studio/components/index.js +2 -2
- package/dist/studio/{components-Bivm2lX6.js → components-BSGmHfif.js} +6 -6
- package/dist/studio/control-plane/Agents.d.ts.map +1 -1
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Instance.d.ts +4 -1
- package/dist/studio/control-plane/Instance.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts +4 -1
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/Overview.d.ts.map +1 -1
- package/dist/studio/control-plane/Schema.d.ts.map +1 -1
- package/dist/studio/control-plane/context.d.ts +4 -1
- package/dist/studio/control-plane/context.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane/primitives.d.ts +24 -0
- package/dist/studio/control-plane/primitives.d.ts.map +1 -1
- package/dist/studio/{control-plane-CLm69XwX.js → control-plane-CaBA7Rgv.js} +1374 -1298
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +415 -237
- package/dist/studio/navigation.d.ts +57 -0
- package/dist/studio/navigation.d.ts.map +1 -0
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +2 -0
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.js +9 -4
- package/dist/studio/utils/service-api.d.ts.map +1 -1
- package/dist/studio/utils/service-api.js +5 -4
- package/dist/studio-app/assets/{feltdb_wasm-DACcD-zs.js → feltdb_wasm-3hDngl1h.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +30 -0
- package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
- package/dist/studio-app/index.html +2 -2
- package/dist/telemetry.d.ts +1 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +4 -3
- package/dist/transaction-identity.d.ts +62 -0
- package/dist/transaction-identity.d.ts.map +1 -0
- package/dist/transaction-identity.js +138 -0
- package/dist/transaction.d.ts +50 -5
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +28 -0
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workspace/local-development-authority.d.ts +19 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +335 -15
- package/package.json +1 -1
- package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
- package/dist/studio-app/assets/feltdb_wasm_bg-BejThWID.wasm +0 -0
- package/dist/studio-app/assets/index-CoZaxnlm.js +0 -30
|
@@ -67,6 +67,7 @@ use feltdb::{
|
|
|
67
67
|
PeerAdvertisement, PeerId, RecordPrecondition, StateRevision, StateTriggerStore, StoredRow,
|
|
68
68
|
};
|
|
69
69
|
use feltdb_server::{
|
|
70
|
+
agent_execution::{self, CompletionCriteria, RunFailure, RunKind, RunPrincipal},
|
|
70
71
|
app_state::{AppState, BoundedQueryCursor},
|
|
71
72
|
application_contract::{ApplicationContractStore, ContractPatch},
|
|
72
73
|
artifacts::{ArtifactKind, ArtifactLifecycle, ArtifactMetadata, ArtifactStore, ProducerRef},
|
|
@@ -1476,6 +1477,9 @@ struct WorkloadContextRequest {
|
|
|
1476
1477
|
#[derive(Deserialize)]
|
|
1477
1478
|
struct WorkloadControlRequest {
|
|
1478
1479
|
grant: feltdb::authorization::SignedGrant,
|
|
1480
|
+
/// Why an operator retried the workload; recorded in its history.
|
|
1481
|
+
#[serde(default)]
|
|
1482
|
+
reason: Option<String>,
|
|
1479
1483
|
}
|
|
1480
1484
|
#[derive(Deserialize)]
|
|
1481
1485
|
struct WorkerHeartbeatRequest {
|
|
@@ -1498,7 +1502,10 @@ fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
|
|
|
1498
1502
|
"WORKLOAD_STALE_FENCE"
|
|
1499
1503
|
| "WORKLOAD_TRANSITION_DENIED"
|
|
1500
1504
|
| "WORKLOAD_NOT_READY"
|
|
1501
|
-
| "WORKLOAD_NOT_YET_ELIGIBLE"
|
|
1505
|
+
| "WORKLOAD_NOT_YET_ELIGIBLE"
|
|
1506
|
+
| "WORKLOAD_ATTEMPTS_EXHAUSTED"
|
|
1507
|
+
| "WORKLOAD_RETRY_EXHAUSTED"
|
|
1508
|
+
| "WORKLOAD_CONFLICT" => StatusCode::CONFLICT,
|
|
1502
1509
|
"WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
|
|
1503
1510
|
"WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
|
|
1504
1511
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
@@ -1589,7 +1596,7 @@ async fn create_workload(
|
|
|
1589
1596
|
State(state): State<AppState>,
|
|
1590
1597
|
Extension(principal): Extension<Principal>,
|
|
1591
1598
|
Json(mut input): Json<CreateWorkload>,
|
|
1592
|
-
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
1599
|
+
) -> Result<(StatusCode, HeaderMap, Json<Value>), ApiError> {
|
|
1593
1600
|
let subject = grant_subject(&principal);
|
|
1594
1601
|
input.requested_by = subject.clone();
|
|
1595
1602
|
input.session_id = principal.session_id.clone();
|
|
@@ -1603,7 +1610,7 @@ async fn create_workload(
|
|
|
1603
1610
|
"workload:create",
|
|
1604
1611
|
&input.authorization_snapshot,
|
|
1605
1612
|
)?;
|
|
1606
|
-
let workload = state
|
|
1613
|
+
let (workload, created) = state
|
|
1607
1614
|
.workloads
|
|
1608
1615
|
.lock()
|
|
1609
1616
|
.map_err(|_| {
|
|
@@ -1612,17 +1619,34 @@ async fn create_workload(
|
|
|
1612
1619
|
"workload store unavailable".into(),
|
|
1613
1620
|
)
|
|
1614
1621
|
})?
|
|
1615
|
-
.
|
|
1616
|
-
.map_err(
|
|
1622
|
+
.admit(input)
|
|
1623
|
+
.map_err(|error| {
|
|
1624
|
+
if error.code == "IDEMPOTENCY_KEY_REUSED" {
|
|
1625
|
+
// AppPort's contract for a reused key (§13.1): CONFLICT, with
|
|
1626
|
+
// the reason, not retryable.
|
|
1627
|
+
ApiError::structured(
|
|
1628
|
+
StatusCode::CONFLICT,
|
|
1629
|
+
json!({"code": "CONFLICT", "message": error.message,
|
|
1630
|
+
"details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false}}),
|
|
1631
|
+
)
|
|
1632
|
+
} else {
|
|
1633
|
+
workload_error(error)
|
|
1634
|
+
}
|
|
1635
|
+
})?;
|
|
1617
1636
|
record_authorization_audit(
|
|
1618
1637
|
&state,
|
|
1619
1638
|
&principal.key_id,
|
|
1620
|
-
"workload.created",
|
|
1639
|
+
if created { "workload.created" } else { "workload.replayed" },
|
|
1621
1640
|
&workload.workload_id,
|
|
1622
1641
|
"ready",
|
|
1623
1642
|
201,
|
|
1624
1643
|
);
|
|
1625
|
-
|
|
1644
|
+
// A replay answers with the stored response, marked as a replay.
|
|
1645
|
+
let mut headers = HeaderMap::new();
|
|
1646
|
+
if !created {
|
|
1647
|
+
headers.insert("idempotent-replay", axum::http::HeaderValue::from_static("true"));
|
|
1648
|
+
}
|
|
1649
|
+
Ok((StatusCode::CREATED, headers, Json(json!(workload))))
|
|
1626
1650
|
}
|
|
1627
1651
|
async fn list_workloads(
|
|
1628
1652
|
State(state): State<AppState>,
|
|
@@ -1664,14 +1688,27 @@ async fn get_workload(
|
|
|
1664
1688
|
.get(&id)
|
|
1665
1689
|
.map_err(workload_error)?
|
|
1666
1690
|
.clone();
|
|
1667
|
-
|
|
1668
|
-
&state,
|
|
1669
|
-
&principal.key_id,
|
|
1670
|
-
&workload.application_id,
|
|
1671
|
-
"workload:read",
|
|
1672
|
-
)?;
|
|
1691
|
+
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
1673
1692
|
Ok(Json(json!(workload)))
|
|
1674
1693
|
}
|
|
1694
|
+
/// Authorize reading one workload. A caller who may not read the
|
|
1695
|
+
/// application's work gets the same answer as for a workload that does not
|
|
1696
|
+
/// exist, so workload ids cannot be probed.
|
|
1697
|
+
fn workload_read_scope(
|
|
1698
|
+
state: &AppState,
|
|
1699
|
+
principal: &Principal,
|
|
1700
|
+
application_id: &str,
|
|
1701
|
+
) -> Result<(), ApiError> {
|
|
1702
|
+
application_scope(state, &principal.key_id, application_id, "workload:read")
|
|
1703
|
+
.map(|_| ())
|
|
1704
|
+
.map_err(|_| {
|
|
1705
|
+
// Exactly the body `workload_error` gives a missing workload.
|
|
1706
|
+
ApiError::structured(
|
|
1707
|
+
StatusCode::NOT_FOUND,
|
|
1708
|
+
json!({"code": "WORKLOAD_NOT_FOUND", "message": "workload not found"}),
|
|
1709
|
+
)
|
|
1710
|
+
})
|
|
1711
|
+
}
|
|
1675
1712
|
async fn claim_workload(
|
|
1676
1713
|
State(state): State<AppState>,
|
|
1677
1714
|
Extension(principal): Extension<Principal>,
|
|
@@ -1971,7 +2008,7 @@ async fn retry_workload(
|
|
|
1971
2008
|
.workloads
|
|
1972
2009
|
.lock()
|
|
1973
2010
|
.unwrap()
|
|
1974
|
-
.
|
|
2011
|
+
.retry_with_reason(&id, &principal.key_id, unix_seconds_i64(), input.reason.as_deref())
|
|
1975
2012
|
.map_err(workload_error)?;
|
|
1976
2013
|
Ok(StatusCode::NO_CONTENT)
|
|
1977
2014
|
}
|
|
@@ -1987,12 +2024,7 @@ async fn workload_history(
|
|
|
1987
2024
|
.get(&id)
|
|
1988
2025
|
.map_err(workload_error)?
|
|
1989
2026
|
.clone();
|
|
1990
|
-
|
|
1991
|
-
&state,
|
|
1992
|
-
&principal.key_id,
|
|
1993
|
-
&workload.application_id,
|
|
1994
|
-
"workload:read",
|
|
1995
|
-
)?;
|
|
2027
|
+
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
1996
2028
|
Ok(Json(
|
|
1997
2029
|
json!({"events":workload.execution_history,"attempts":workload.attempts,"checkpoints":workload.checkpoints}),
|
|
1998
2030
|
))
|
|
@@ -2009,12 +2041,7 @@ async fn workload_result(
|
|
|
2009
2041
|
.get(&id)
|
|
2010
2042
|
.map_err(workload_error)?
|
|
2011
2043
|
.clone();
|
|
2012
|
-
|
|
2013
|
-
&state,
|
|
2014
|
-
&principal.key_id,
|
|
2015
|
-
&workload.application_id,
|
|
2016
|
-
"workload:read",
|
|
2017
|
-
)?;
|
|
2044
|
+
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
2018
2045
|
Ok(Json(json!(workload.result)))
|
|
2019
2046
|
}
|
|
2020
2047
|
|
|
@@ -2290,12 +2317,7 @@ async fn eligible_workers(
|
|
|
2290
2317
|
.get(&id)
|
|
2291
2318
|
.map_err(workload_error)?
|
|
2292
2319
|
.clone();
|
|
2293
|
-
|
|
2294
|
-
&state,
|
|
2295
|
-
&principal.key_id,
|
|
2296
|
-
&workload.application_id,
|
|
2297
|
-
"workload:read",
|
|
2298
|
-
)?;
|
|
2320
|
+
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
2299
2321
|
let workers = state
|
|
2300
2322
|
.mesh
|
|
2301
2323
|
.lock()
|
|
@@ -8418,6 +8440,10 @@ struct StartAgentRun {
|
|
|
8418
8440
|
goal: String,
|
|
8419
8441
|
#[serde(default)]
|
|
8420
8442
|
input: Value,
|
|
8443
|
+
/// What the runtime must observe for the run to complete. Without it the
|
|
8444
|
+
/// run can execute but never completes: the goal text is not evaluated.
|
|
8445
|
+
#[serde(default)]
|
|
8446
|
+
completion: Option<CompletionCriteria>,
|
|
8421
8447
|
}
|
|
8422
8448
|
|
|
8423
8449
|
#[derive(Deserialize)]
|
|
@@ -8846,6 +8872,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8846
8872
|
};
|
|
8847
8873
|
start_peer_sessions(&state, &config)?;
|
|
8848
8874
|
start_membership_recovery(&state);
|
|
8875
|
+
start_workload_recovery(&state);
|
|
8849
8876
|
start_autonomous_worker(&state);
|
|
8850
8877
|
|
|
8851
8878
|
let protected = Router::new()
|
|
@@ -11198,6 +11225,37 @@ fn start_autonomous_worker(state: &AppState) {
|
|
|
11198
11225
|
});
|
|
11199
11226
|
}
|
|
11200
11227
|
|
|
11228
|
+
/// Recover Durable Work whose lease expired, on every node, whether or not it
|
|
11229
|
+
/// runs the autonomous worker: external workers depend on it too.
|
|
11230
|
+
///
|
|
11231
|
+
/// It is the mesh's claim recovery (`recover_expired_work`, the part of
|
|
11232
|
+
/// `detect_failures` that recovers work): expired work goes to
|
|
11233
|
+
/// `WorkloadStore::recover_expired`, bounded by each workload's attempt budget,
|
|
11234
|
+
/// and its claim is released from the worker that held it. Worker liveness —
|
|
11235
|
+
/// marking silent workers offline — is deliberately not run here; it would lock
|
|
11236
|
+
/// out registered workers that do not heartbeat on the mesh's schedule. It takes
|
|
11237
|
+
/// the mesh lock before the workload lock, the order the claim route uses.
|
|
11238
|
+
fn start_workload_recovery(state: &AppState) {
|
|
11239
|
+
let state = state.clone();
|
|
11240
|
+
tokio::spawn(async move {
|
|
11241
|
+
loop {
|
|
11242
|
+
if state.cluster.get().is_member {
|
|
11243
|
+
let outcome = match (state.mesh.lock(), state.workloads.lock()) {
|
|
11244
|
+
(Ok(mut mesh), Ok(mut workloads)) => mesh
|
|
11245
|
+
.recover_expired_work(unix_seconds_i64(), &mut workloads)
|
|
11246
|
+
.map(|_| ())
|
|
11247
|
+
.map_err(|error| error.message),
|
|
11248
|
+
_ => Err("worker mesh or workload store unavailable".to_string()),
|
|
11249
|
+
};
|
|
11250
|
+
if let Err(error) = outcome {
|
|
11251
|
+
tracing::warn!(%error, "durable work recovery pass incomplete");
|
|
11252
|
+
}
|
|
11253
|
+
}
|
|
11254
|
+
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
11255
|
+
}
|
|
11256
|
+
});
|
|
11257
|
+
}
|
|
11258
|
+
|
|
11201
11259
|
fn start_membership_recovery(state: &AppState) {
|
|
11202
11260
|
let state = state.clone();
|
|
11203
11261
|
tokio::spawn(async move {
|
|
@@ -11218,13 +11276,18 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
11218
11276
|
return Ok(());
|
|
11219
11277
|
}
|
|
11220
11278
|
let worker = state.db.instance_id()?;
|
|
11279
|
+
// One run's failure never stops the pass: each is recorded on its own run,
|
|
11280
|
+
// and an infrastructure fault is logged and retried on the next pass.
|
|
11221
11281
|
for row in state.db.list_collection("_flow_workflow_runs")? {
|
|
11282
|
+
if row.value["status"] != "running" {
|
|
11283
|
+
continue;
|
|
11284
|
+
}
|
|
11222
11285
|
let Some(steps) = row.value["steps"].as_array() else {
|
|
11223
11286
|
continue;
|
|
11224
11287
|
};
|
|
11225
11288
|
let now = lease_now_ms(state)?;
|
|
11226
11289
|
let Some(step) = steps.iter().find(|step| {
|
|
11227
|
-
step["status"] == "ready"
|
|
11290
|
+
(step["status"] == "ready" && step["not_before_ms"].as_u64().unwrap_or(0) <= now)
|
|
11228
11291
|
|| (step["status"] == "running"
|
|
11229
11292
|
&& step["lease_expires_ms"].as_u64().unwrap_or(0) <= now)
|
|
11230
11293
|
}) else {
|
|
@@ -11233,42 +11296,33 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
11233
11296
|
let Some(step_id) = step["id"].as_str() else {
|
|
11234
11297
|
continue;
|
|
11235
11298
|
};
|
|
11299
|
+
// A step that names no capability here is an external step, claimed
|
|
11300
|
+
// by an external worker through the claim route.
|
|
11236
11301
|
if !is_executable_capability(state, step_id)? {
|
|
11237
11302
|
continue;
|
|
11238
11303
|
}
|
|
11239
11304
|
let run_id = row.value["id"].as_str().unwrap_or_default().to_string();
|
|
11240
|
-
let
|
|
11241
|
-
let
|
|
11242
|
-
state,
|
|
11243
|
-
|
|
11244
|
-
step_id,
|
|
11245
|
-
|
|
11246
|
-
None,
|
|
11247
|
-
)
|
|
11248
|
-
.await?;
|
|
11249
|
-
let claim_id = claimed.0.value["steps"]
|
|
11250
|
-
.as_array()
|
|
11251
|
-
.and_then(|steps| steps.iter().find(|step| step["id"] == step_id))
|
|
11252
|
-
.and_then(|step| step["claim_id"].as_str())
|
|
11253
|
-
.ok_or_else(|| {
|
|
11254
|
-
ApiError(
|
|
11255
|
-
StatusCode::CONFLICT,
|
|
11256
|
-
"worker claim was not materialized".to_string(),
|
|
11257
|
-
)
|
|
11258
|
-
})?
|
|
11259
|
-
.to_string();
|
|
11260
|
-
let result = execute_named_capability(state, step_id, &input)?;
|
|
11261
|
-
let _ = transition_workflow_step(state, &run_id, step_id, None, Some((claim_id, result)))
|
|
11262
|
-
.await?;
|
|
11305
|
+
let attempts = step["attempt"].as_u64().unwrap_or(0);
|
|
11306
|
+
if let Err(error) =
|
|
11307
|
+
execute_workflow_step(state, &row.value, &run_id, step_id, attempts, &worker).await
|
|
11308
|
+
{
|
|
11309
|
+
tracing::warn!(run = %run_id, step = %step_id, status = error.0.as_u16(), "workflow step attempt did not finish");
|
|
11310
|
+
}
|
|
11263
11311
|
}
|
|
11264
11312
|
|
|
11265
11313
|
for row in state.db.list_collection("_flow_agent_runs")? {
|
|
11266
|
-
let
|
|
11267
|
-
|
|
11268
|
-
|
|
11314
|
+
let now = lease_now_ms(state)?;
|
|
11315
|
+
let status = row.value["status"].as_str().unwrap_or_default();
|
|
11316
|
+
let lease_expired = row.value["lease_expires_ms"].as_u64().unwrap_or(0) <= now;
|
|
11317
|
+
if !agent_execution::agent_run_claimable(status, lease_expired)
|
|
11318
|
+
|| row.value["not_before_ms"].as_u64().unwrap_or(0) > now
|
|
11319
|
+
{
|
|
11269
11320
|
continue;
|
|
11270
11321
|
}
|
|
11271
|
-
execute_agent_run(state, &row.value, &worker).await
|
|
11322
|
+
if let Err(error) = execute_agent_run(state, &row.value, &worker).await {
|
|
11323
|
+
let run = row.value["id"].as_str().unwrap_or_default();
|
|
11324
|
+
tracing::warn!(%run, status = error.0.as_u16(), "agent run attempt did not finish");
|
|
11325
|
+
}
|
|
11272
11326
|
}
|
|
11273
11327
|
|
|
11274
11328
|
evaluate_state_triggers(state)?;
|
|
@@ -11372,7 +11426,7 @@ fn worker_capabilities(state: &AppState) -> Result<BTreeSet<String>, ApiError> {
|
|
|
11372
11426
|
fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiError> {
|
|
11373
11427
|
let capabilities = worker_capabilities(state)?;
|
|
11374
11428
|
let now = unix_seconds_i64();
|
|
11375
|
-
let candidates: Vec<(String, String, Value)> = {
|
|
11429
|
+
let candidates: Vec<(String, String, Value, feltdb::workload::Workload)> = {
|
|
11376
11430
|
let store = workload_store(state)?;
|
|
11377
11431
|
store
|
|
11378
11432
|
.workloads
|
|
@@ -11391,12 +11445,13 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
|
|
|
11391
11445
|
workload.workload_id.clone(),
|
|
11392
11446
|
workload.definition_id.clone(),
|
|
11393
11447
|
workload.input.clone(),
|
|
11448
|
+
workload.clone(),
|
|
11394
11449
|
)
|
|
11395
11450
|
})
|
|
11396
11451
|
.collect()
|
|
11397
11452
|
};
|
|
11398
11453
|
|
|
11399
|
-
for (id, definition, input) in candidates {
|
|
11454
|
+
for (id, definition, input, workload) in candidates {
|
|
11400
11455
|
// Unlike the filter above, this one is enforcement: nothing downstream
|
|
11401
11456
|
// knows whether `definition_id` names something runnable. Claiming a
|
|
11402
11457
|
// workload only to dead-letter it would consume work another node could
|
|
@@ -11427,7 +11482,13 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
|
|
|
11427
11482
|
)
|
|
11428
11483
|
.map_err(workload_error)?;
|
|
11429
11484
|
|
|
11430
|
-
|
|
11485
|
+
let access = CapabilityAccess::Workload {
|
|
11486
|
+
requested_by: &workload.requested_by,
|
|
11487
|
+
tenant_id: &workload.tenant_id,
|
|
11488
|
+
application_id: &workload.application_id,
|
|
11489
|
+
grant: &workload.authorization_snapshot,
|
|
11490
|
+
};
|
|
11491
|
+
match execute_named_capability(state, &definition, &input, &access) {
|
|
11431
11492
|
Ok(output) => {
|
|
11432
11493
|
workload_store(state)?
|
|
11433
11494
|
.complete(
|
|
@@ -11464,6 +11525,157 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
|
|
|
11464
11525
|
Ok(())
|
|
11465
11526
|
}
|
|
11466
11527
|
|
|
11528
|
+
/// Debug builds only: `FELTDB_TEST_TRANSIENT_CAPABILITY=<name>[:<count>]` makes
|
|
11529
|
+
/// executing `<name>` fail with a server error — every time, or for its first
|
|
11530
|
+
/// `<count>` executions — so retry bounds can be exercised at the HTTP
|
|
11531
|
+
/// boundary. Compiled out of release builds, like `FELTDB_TEST_CLOCK_OFFSET_MS`.
|
|
11532
|
+
#[cfg(debug_assertions)]
|
|
11533
|
+
fn injected_capability_fault(name: &str) -> Option<ApiError> {
|
|
11534
|
+
static INJECTED: AtomicU64 = AtomicU64::new(0);
|
|
11535
|
+
let setting = std::env::var("FELTDB_TEST_TRANSIENT_CAPABILITY").ok()?;
|
|
11536
|
+
let (target, limit) = match setting.split_once(':') {
|
|
11537
|
+
Some((target, count)) => (target.to_string(), count.parse::<u64>().ok()),
|
|
11538
|
+
None => (setting, None),
|
|
11539
|
+
};
|
|
11540
|
+
if target != name {
|
|
11541
|
+
return None;
|
|
11542
|
+
}
|
|
11543
|
+
let injected = INJECTED.fetch_add(1, Ordering::SeqCst);
|
|
11544
|
+
if limit.is_some_and(|limit| injected >= limit) {
|
|
11545
|
+
return None;
|
|
11546
|
+
}
|
|
11547
|
+
Some(ApiError(
|
|
11548
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
11549
|
+
"injected transient capability failure".into(),
|
|
11550
|
+
))
|
|
11551
|
+
}
|
|
11552
|
+
|
|
11553
|
+
/// The authority a capability reads under.
|
|
11554
|
+
///
|
|
11555
|
+
/// Every collection a capability touches is authorized against it in
|
|
11556
|
+
/// [`authorize_capability_read`], the single point all capability reads pass
|
|
11557
|
+
/// through, so no route, program step, workflow, or agent reaches state with
|
|
11558
|
+
/// more authority than the principal it acts for.
|
|
11559
|
+
enum CapabilityAccess<'a> {
|
|
11560
|
+
/// The request's own principal, on the direct capability routes.
|
|
11561
|
+
Principal(&'a Principal),
|
|
11562
|
+
/// An application actor on the `/v1` Service API.
|
|
11563
|
+
Application {
|
|
11564
|
+
actor: &'a str,
|
|
11565
|
+
application_id: &'a str,
|
|
11566
|
+
},
|
|
11567
|
+
/// A run's verified delegated authority, on the autonomous worker.
|
|
11568
|
+
Delegated(&'a RunPrincipal),
|
|
11569
|
+
/// A durable workload's signed authorization snapshot. Reads are allowed
|
|
11570
|
+
/// only when that grant, re-verified at execution, authorizes `state:read`
|
|
11571
|
+
/// for the workload's requester — so a workload never reads with the
|
|
11572
|
+
/// node's authority, and a row injected without a server-signed grant
|
|
11573
|
+
/// reads nothing.
|
|
11574
|
+
Workload {
|
|
11575
|
+
requested_by: &'a GrantSubject,
|
|
11576
|
+
tenant_id: &'a str,
|
|
11577
|
+
application_id: &'a str,
|
|
11578
|
+
grant: &'a feltdb::authorization::SignedGrant,
|
|
11579
|
+
},
|
|
11580
|
+
}
|
|
11581
|
+
|
|
11582
|
+
/// Whether a principal, described the way the authenticator describes it, holds
|
|
11583
|
+
/// `capability`. The same decision the request middleware makes.
|
|
11584
|
+
fn principal_grants(
|
|
11585
|
+
state: &AppState,
|
|
11586
|
+
kind: &str,
|
|
11587
|
+
id: &str,
|
|
11588
|
+
scopes: &[String],
|
|
11589
|
+
capability: &str,
|
|
11590
|
+
) -> bool {
|
|
11591
|
+
let subject = if kind == "human" {
|
|
11592
|
+
Subject::User(id.to_string())
|
|
11593
|
+
} else {
|
|
11594
|
+
Subject::Service(id.to_string())
|
|
11595
|
+
};
|
|
11596
|
+
authorize(&AuthorizationRequest {
|
|
11597
|
+
subject: &subject,
|
|
11598
|
+
tenant_id: &state.namespace,
|
|
11599
|
+
application_id: Some(&state.namespace),
|
|
11600
|
+
capability,
|
|
11601
|
+
direct_grants: scopes,
|
|
11602
|
+
memberships: &[],
|
|
11603
|
+
roles: &[],
|
|
11604
|
+
})
|
|
11605
|
+
.allowed
|
|
11606
|
+
}
|
|
11607
|
+
|
|
11608
|
+
fn request_principal_grants(state: &AppState, principal: &Principal, capability: &str) -> bool {
|
|
11609
|
+
principal_grants(
|
|
11610
|
+
state,
|
|
11611
|
+
&principal.subject_type,
|
|
11612
|
+
&principal.key_id,
|
|
11613
|
+
&principal.scopes,
|
|
11614
|
+
capability,
|
|
11615
|
+
)
|
|
11616
|
+
}
|
|
11617
|
+
|
|
11618
|
+
fn delegated_grants(state: &AppState, principal: &RunPrincipal, capability: &str) -> bool {
|
|
11619
|
+
let scopes: Vec<String> = principal.scopes.iter().cloned().collect();
|
|
11620
|
+
principal_grants(state, &principal.kind, &principal.id, &scopes, capability)
|
|
11621
|
+
}
|
|
11622
|
+
|
|
11623
|
+
fn capability_read_denied(message: String) -> ApiError {
|
|
11624
|
+
ApiError::structured(
|
|
11625
|
+
StatusCode::FORBIDDEN,
|
|
11626
|
+
json!({"code": agent_execution::AUTHORIZATION_DENIED, "message": message}),
|
|
11627
|
+
)
|
|
11628
|
+
}
|
|
11629
|
+
|
|
11630
|
+
/// Authorize one collection read made by a capability.
|
|
11631
|
+
///
|
|
11632
|
+
/// Reading records through a capability is reading state, so it needs the
|
|
11633
|
+
/// `state:read` a direct read needs. System collections (`_`-prefixed) hold
|
|
11634
|
+
/// runs, definitions and other server records and are never readable this way.
|
|
11635
|
+
fn authorize_capability_read(
|
|
11636
|
+
state: &AppState,
|
|
11637
|
+
access: &CapabilityAccess<'_>,
|
|
11638
|
+
collection: &str,
|
|
11639
|
+
) -> Result<(), ApiError> {
|
|
11640
|
+
if agent_execution::is_system_collection(collection) {
|
|
11641
|
+
return Err(capability_read_denied(format!(
|
|
11642
|
+
"capabilities cannot read system collection {collection}"
|
|
11643
|
+
)));
|
|
11644
|
+
}
|
|
11645
|
+
let allowed = match access {
|
|
11646
|
+
CapabilityAccess::Principal(principal) => {
|
|
11647
|
+
request_principal_grants(state, principal, "state:read")
|
|
11648
|
+
}
|
|
11649
|
+
CapabilityAccess::Application {
|
|
11650
|
+
actor,
|
|
11651
|
+
application_id,
|
|
11652
|
+
} => application_scope(state, actor, application_id, "state:read").is_ok(),
|
|
11653
|
+
CapabilityAccess::Delegated(principal) => delegated_grants(state, principal, "state:read"),
|
|
11654
|
+
CapabilityAccess::Workload {
|
|
11655
|
+
requested_by,
|
|
11656
|
+
tenant_id,
|
|
11657
|
+
application_id,
|
|
11658
|
+
grant,
|
|
11659
|
+
} => workload_decision(
|
|
11660
|
+
state,
|
|
11661
|
+
(*requested_by).clone(),
|
|
11662
|
+
tenant_id,
|
|
11663
|
+
application_id,
|
|
11664
|
+
format!("flow://{application_id}/state/{collection}"),
|
|
11665
|
+
"state:read",
|
|
11666
|
+
grant,
|
|
11667
|
+
)
|
|
11668
|
+
.is_ok(),
|
|
11669
|
+
};
|
|
11670
|
+
if allowed {
|
|
11671
|
+
Ok(())
|
|
11672
|
+
} else {
|
|
11673
|
+
Err(capability_read_denied(format!(
|
|
11674
|
+
"the caller is not authorized to read {collection}"
|
|
11675
|
+
)))
|
|
11676
|
+
}
|
|
11677
|
+
}
|
|
11678
|
+
|
|
11467
11679
|
fn is_builtin_capability(name: &str) -> bool {
|
|
11468
11680
|
matches!(name, "search" | "identity")
|
|
11469
11681
|
}
|
|
@@ -11480,6 +11692,7 @@ fn execute_builtin_capability(
|
|
|
11480
11692
|
state: &AppState,
|
|
11481
11693
|
name: &str,
|
|
11482
11694
|
input: &Value,
|
|
11695
|
+
access: &CapabilityAccess<'_>,
|
|
11483
11696
|
) -> Result<Value, ApiError> {
|
|
11484
11697
|
match name {
|
|
11485
11698
|
"identity" => Ok(input.clone()),
|
|
@@ -11491,6 +11704,7 @@ fn execute_builtin_capability(
|
|
|
11491
11704
|
)
|
|
11492
11705
|
})?;
|
|
11493
11706
|
validate_segment(collection)?;
|
|
11707
|
+
authorize_capability_read(state, access, collection)?;
|
|
11494
11708
|
let needle = input["query"].as_str().unwrap_or_default().to_lowercase();
|
|
11495
11709
|
let limit = input["limit"].as_u64().unwrap_or(50).min(500) as usize;
|
|
11496
11710
|
Ok(Value::Array(
|
|
@@ -11511,80 +11725,369 @@ fn execute_builtin_capability(
|
|
|
11511
11725
|
}
|
|
11512
11726
|
}
|
|
11513
11727
|
|
|
11728
|
+
/// The text of an [`ApiError`] without its wire envelope: the `message` of a
|
|
11729
|
+
/// structured error, or the plain message.
|
|
11730
|
+
fn api_error_message(error: &ApiError) -> String {
|
|
11731
|
+
serde_json::from_str::<Value>(&error.1)
|
|
11732
|
+
.ok()
|
|
11733
|
+
.and_then(|value| value["message"].as_str().map(str::to_string))
|
|
11734
|
+
.unwrap_or_else(|| error.1.clone())
|
|
11735
|
+
}
|
|
11736
|
+
|
|
11737
|
+
/// Everything an agent run needs before the worker may lease it.
|
|
11738
|
+
///
|
|
11739
|
+
/// Checked on every attempt, so authority revoked after the run was created,
|
|
11740
|
+
/// a definition removed, or a capability unregistered ends the run here,
|
|
11741
|
+
/// before anything executes.
|
|
11742
|
+
fn prepare_agent_run(
|
|
11743
|
+
state: &AppState,
|
|
11744
|
+
run: &Value,
|
|
11745
|
+
id: &str,
|
|
11746
|
+
attempt: u64,
|
|
11747
|
+
) -> Result<(String, RunPrincipal, Option<CompletionCriteria>), RunFailure> {
|
|
11748
|
+
if attempt > agent_execution::max_run_attempts() {
|
|
11749
|
+
return Err(RunFailure::permanent(
|
|
11750
|
+
agent_execution::RETRY_EXHAUSTED,
|
|
11751
|
+
format!(
|
|
11752
|
+
"run was claimed {} times without finishing",
|
|
11753
|
+
attempt - 1
|
|
11754
|
+
),
|
|
11755
|
+
));
|
|
11756
|
+
}
|
|
11757
|
+
let principal = resolve_run_principal(state, run, RunKind::Agent, id)?;
|
|
11758
|
+
if !delegated_grants(state, &principal, "agents:run") {
|
|
11759
|
+
return Err(RunFailure::permanent(
|
|
11760
|
+
agent_execution::AUTHORIZATION_DENIED,
|
|
11761
|
+
"the run's principal may not run agents",
|
|
11762
|
+
));
|
|
11763
|
+
}
|
|
11764
|
+
let agent = run["agent"].as_str().unwrap_or_default();
|
|
11765
|
+
let definition = match validate_segment(agent)
|
|
11766
|
+
.and_then(|_| required_value(state, "_flow_agents", agent, "agent not found"))
|
|
11767
|
+
{
|
|
11768
|
+
Ok(definition) => definition,
|
|
11769
|
+
Err(_) => {
|
|
11770
|
+
return Err(RunFailure::permanent(
|
|
11771
|
+
agent_execution::AGENT_NOT_FOUND,
|
|
11772
|
+
"the run's agent is not defined",
|
|
11773
|
+
))
|
|
11774
|
+
}
|
|
11775
|
+
};
|
|
11776
|
+
let capability = agent_capability(state, &definition)?;
|
|
11777
|
+
let criteria = match &run["completion"] {
|
|
11778
|
+
Value::Null => None,
|
|
11779
|
+
value => Some(
|
|
11780
|
+
serde_json::from_value::<CompletionCriteria>(value.clone())
|
|
11781
|
+
.ok()
|
|
11782
|
+
.filter(|criteria| criteria.validate().is_ok())
|
|
11783
|
+
.ok_or_else(|| {
|
|
11784
|
+
RunFailure::permanent(
|
|
11785
|
+
agent_execution::RUN_INVALID,
|
|
11786
|
+
"the run's completion criteria are invalid",
|
|
11787
|
+
)
|
|
11788
|
+
})?,
|
|
11789
|
+
),
|
|
11790
|
+
};
|
|
11791
|
+
Ok((capability, principal, criteria))
|
|
11792
|
+
}
|
|
11793
|
+
|
|
11794
|
+
fn mark_agent_run_failed(run: &mut Value, failure: &RunFailure, attempt: u64) {
|
|
11795
|
+
run["status"] = json!(agent_execution::STATUS_FAILED);
|
|
11796
|
+
run["failure"] = failure.to_record(attempt);
|
|
11797
|
+
run["attempt"] = json!(attempt);
|
|
11798
|
+
}
|
|
11799
|
+
|
|
11800
|
+
/// Execute one attempt of an agent run.
|
|
11801
|
+
///
|
|
11802
|
+
/// The run completes only when its declared criteria hold for the result this
|
|
11803
|
+
/// worker observed. Every other outcome is explicit: a permanent error fails
|
|
11804
|
+
/// the run, a transient one returns it to `ready` after a backoff until the
|
|
11805
|
+
/// attempt bound, and a run without criteria fails as unverifiable. An `Err`
|
|
11806
|
+
/// is an infrastructure fault that left the run untouched.
|
|
11514
11807
|
async fn execute_agent_run(state: &AppState, run: &Value, worker: &str) -> Result<(), ApiError> {
|
|
11515
11808
|
let id = run["id"]
|
|
11516
11809
|
.as_str()
|
|
11517
11810
|
.ok_or_else(|| ApiError(StatusCode::CONFLICT, "agent run has no id".to_string()))?;
|
|
11518
|
-
let
|
|
11519
|
-
ApiError(
|
|
11520
|
-
StatusCode::CONFLICT,
|
|
11521
|
-
"agent run has no definition".to_string(),
|
|
11522
|
-
)
|
|
11523
|
-
})?;
|
|
11524
|
-
let definition = required_value(state, "_flow_agents", agent_name, "agent not found")?;
|
|
11525
|
-
let capability = definition["capabilities"].as_array().and_then(|values| {
|
|
11526
|
-
values
|
|
11527
|
-
.iter()
|
|
11528
|
-
.filter_map(Value::as_str)
|
|
11529
|
-
.find(|name| is_executable_capability(state, name).unwrap_or(false))
|
|
11530
|
-
});
|
|
11531
|
-
let Some(capability) = capability else {
|
|
11532
|
-
return Ok(());
|
|
11533
|
-
};
|
|
11534
|
-
let claim_id = next_id(state, "claim");
|
|
11535
|
-
let expires_ms = lease_now_ms(state)?.saturating_add(30_000);
|
|
11536
|
-
let lease = LeaseRequest {
|
|
11537
|
-
claim_id: claim_id.clone(),
|
|
11538
|
-
owner: worker.to_string(),
|
|
11539
|
-
expires_ms,
|
|
11540
|
-
membership_epoch: state.cluster.get().epoch,
|
|
11541
|
-
};
|
|
11542
|
-
{
|
|
11811
|
+
let (capability, principal, criteria, claim_id, attempt, input) = {
|
|
11543
11812
|
let _guard = state.lifecycle_lock.lock().await;
|
|
11544
|
-
reserve_quorum_lease(state, &format!("agent-{id}"), &lease).await?;
|
|
11545
11813
|
let mut current = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
|
|
11546
|
-
let
|
|
11547
|
-
|
|
11548
|
-
|
|
11814
|
+
let now = lease_now_ms(state)?;
|
|
11815
|
+
let status = current["status"].as_str().unwrap_or_default();
|
|
11816
|
+
let lease_expired = current["lease_expires_ms"].as_u64().unwrap_or(0) <= now;
|
|
11817
|
+
if !agent_execution::agent_run_claimable(status, lease_expired)
|
|
11818
|
+
|| current["not_before_ms"].as_u64().unwrap_or(0) > now
|
|
11819
|
+
{
|
|
11549
11820
|
return Ok(());
|
|
11550
11821
|
}
|
|
11551
|
-
current["
|
|
11822
|
+
let attempt = current["attempt"].as_u64().unwrap_or(0) + 1;
|
|
11823
|
+
let (capability, principal, criteria) =
|
|
11824
|
+
match prepare_agent_run(state, ¤t, id, attempt) {
|
|
11825
|
+
Ok(prepared) => prepared,
|
|
11826
|
+
Err(failure) => {
|
|
11827
|
+
mark_agent_run_failed(&mut current, &failure, attempt);
|
|
11828
|
+
put_canonical(state, "_flow_agent_runs", id, current)?;
|
|
11829
|
+
return Ok(());
|
|
11830
|
+
}
|
|
11831
|
+
};
|
|
11832
|
+
let claim_id = next_id(state, "claim");
|
|
11833
|
+
let expires_ms = now.saturating_add(30_000);
|
|
11834
|
+
let lease = LeaseRequest {
|
|
11835
|
+
claim_id: claim_id.clone(),
|
|
11836
|
+
owner: worker.to_string(),
|
|
11837
|
+
expires_ms,
|
|
11838
|
+
membership_epoch: state.cluster.get().epoch,
|
|
11839
|
+
};
|
|
11840
|
+
reserve_quorum_lease(state, &format!("agent-{id}"), &lease).await?;
|
|
11841
|
+
current["status"] = json!(agent_execution::STATUS_RUNNING);
|
|
11552
11842
|
current["worker"] = json!(worker);
|
|
11553
11843
|
current["claim_id"] = json!(claim_id);
|
|
11554
11844
|
current["lease_expires_ms"] = json!(expires_ms);
|
|
11555
|
-
|
|
11556
|
-
|
|
11557
|
-
|
|
11845
|
+
current["attempt"] = json!(attempt);
|
|
11846
|
+
let input = current["input"].clone();
|
|
11847
|
+
put_canonical(state, "_flow_agent_runs", id, current)?;
|
|
11848
|
+
(capability, principal, criteria, claim_id, attempt, input)
|
|
11849
|
+
};
|
|
11850
|
+
|
|
11851
|
+
let outcome = execute_named_capability(
|
|
11852
|
+
state,
|
|
11853
|
+
&capability,
|
|
11854
|
+
&input,
|
|
11855
|
+
&CapabilityAccess::Delegated(&principal),
|
|
11856
|
+
);
|
|
11857
|
+
|
|
11558
11858
|
let _guard = state.lifecycle_lock.lock().await;
|
|
11559
11859
|
let mut current = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
|
|
11560
|
-
|
|
11561
|
-
|
|
11860
|
+
let now = lease_now_ms(state)?;
|
|
11861
|
+
// Publish only while this claim still owns the run. A claim that lost its
|
|
11862
|
+
// lease publishes nothing; the next claim counts as another attempt.
|
|
11863
|
+
if current["claim_id"] != claim_id.as_str()
|
|
11864
|
+
|| current["status"] != agent_execution::STATUS_RUNNING
|
|
11865
|
+
|| current["lease_expires_ms"].as_u64().unwrap_or(0) <= now
|
|
11562
11866
|
{
|
|
11563
|
-
return
|
|
11564
|
-
|
|
11565
|
-
|
|
11566
|
-
)
|
|
11867
|
+
return Ok(());
|
|
11868
|
+
}
|
|
11869
|
+
match outcome {
|
|
11870
|
+
Ok(result) => {
|
|
11871
|
+
let satisfied = criteria
|
|
11872
|
+
.as_ref()
|
|
11873
|
+
.map(|criteria| criteria.satisfied_by(&result));
|
|
11874
|
+
let digest = format!(
|
|
11875
|
+
"sha256:{:x}",
|
|
11876
|
+
Sha256::digest(serde_json::to_vec(&result).unwrap_or_default())
|
|
11877
|
+
);
|
|
11878
|
+
let evidence = json!({
|
|
11879
|
+
"source": "runtime", "attempt": attempt, "claim_id": claim_id,
|
|
11880
|
+
"capability": capability, "result_items": result.as_array().map(Vec::len),
|
|
11881
|
+
"result_digest": digest, "criteria": criteria, "satisfied": satisfied,
|
|
11882
|
+
"observed_at_ms": now,
|
|
11883
|
+
});
|
|
11884
|
+
for (field, value) in [
|
|
11885
|
+
("observations", result.clone()),
|
|
11886
|
+
(
|
|
11887
|
+
"decisions",
|
|
11888
|
+
json!({"capability": capability, "result": result, "source": "runtime"}),
|
|
11889
|
+
),
|
|
11890
|
+
("evidence", evidence),
|
|
11891
|
+
] {
|
|
11892
|
+
if !current[field].is_array() {
|
|
11893
|
+
current[field] = json!([]);
|
|
11894
|
+
}
|
|
11895
|
+
current[field]
|
|
11896
|
+
.as_array_mut()
|
|
11897
|
+
.expect("normalized to an array")
|
|
11898
|
+
.push(value);
|
|
11899
|
+
}
|
|
11900
|
+
match satisfied {
|
|
11901
|
+
Some(true) => {
|
|
11902
|
+
current["status"] = json!(agent_execution::STATUS_COMPLETED);
|
|
11903
|
+
current["completed_at_ms"] = json!(now);
|
|
11904
|
+
}
|
|
11905
|
+
Some(false) => mark_agent_run_failed(
|
|
11906
|
+
&mut current,
|
|
11907
|
+
&RunFailure::permanent(
|
|
11908
|
+
agent_execution::COMPLETION_CRITERIA_UNMET,
|
|
11909
|
+
"the observed result does not satisfy the run's completion criteria",
|
|
11910
|
+
),
|
|
11911
|
+
attempt,
|
|
11912
|
+
),
|
|
11913
|
+
None => mark_agent_run_failed(
|
|
11914
|
+
&mut current,
|
|
11915
|
+
&RunFailure::permanent(
|
|
11916
|
+
agent_execution::COMPLETION_UNVERIFIABLE,
|
|
11917
|
+
"the run declares no completion criteria the runtime can verify; its goal text is not evaluated",
|
|
11918
|
+
),
|
|
11919
|
+
attempt,
|
|
11920
|
+
),
|
|
11921
|
+
}
|
|
11922
|
+
}
|
|
11923
|
+
Err(error) => {
|
|
11924
|
+
let failure = agent_execution::classify_capability_error(
|
|
11925
|
+
error.0.as_u16(),
|
|
11926
|
+
&api_error_message(&error),
|
|
11927
|
+
);
|
|
11928
|
+
if failure.retryable && attempt < agent_execution::max_run_attempts() {
|
|
11929
|
+
// The failed attempt's lease stays reserved until it lapses, so
|
|
11930
|
+
// the retry waits for that as well as for the backoff: a new
|
|
11931
|
+
// claim before then would be refused by the lease quorum.
|
|
11932
|
+
let lease_expires = current["lease_expires_ms"].as_u64().unwrap_or(0);
|
|
11933
|
+
current["status"] = json!(agent_execution::STATUS_READY);
|
|
11934
|
+
current["not_before_ms"] = json!(now
|
|
11935
|
+
.saturating_add(agent_execution::retry_delay_ms(attempt + 1))
|
|
11936
|
+
.max(lease_expires));
|
|
11937
|
+
current["last_failure"] = failure.to_record(attempt);
|
|
11938
|
+
} else if failure.retryable {
|
|
11939
|
+
mark_agent_run_failed(
|
|
11940
|
+
&mut current,
|
|
11941
|
+
&RunFailure::permanent(
|
|
11942
|
+
agent_execution::RETRY_EXHAUSTED,
|
|
11943
|
+
format!("{} after {attempt} attempts", failure.message),
|
|
11944
|
+
),
|
|
11945
|
+
attempt,
|
|
11946
|
+
);
|
|
11947
|
+
} else {
|
|
11948
|
+
mark_agent_run_failed(&mut current, &failure, attempt);
|
|
11949
|
+
}
|
|
11950
|
+
}
|
|
11951
|
+
}
|
|
11952
|
+
put_canonical(state, "_flow_agent_runs", id, current)?;
|
|
11953
|
+
Ok(())
|
|
11954
|
+
}
|
|
11955
|
+
|
|
11956
|
+
/// Fail a workflow step and its run. `claim_id`, when given, must still own
|
|
11957
|
+
/// the step; otherwise another claim has taken over and nothing changes.
|
|
11958
|
+
async fn fail_workflow_step(
|
|
11959
|
+
state: &AppState,
|
|
11960
|
+
run_id: &str,
|
|
11961
|
+
step_id: &str,
|
|
11962
|
+
claim_id: Option<&str>,
|
|
11963
|
+
failure: &RunFailure,
|
|
11964
|
+
) -> Result<(), ApiError> {
|
|
11965
|
+
let _guard = state.lifecycle_lock.lock().await;
|
|
11966
|
+
let mut run = required_value(state, "_flow_workflow_runs", run_id, "workflow run not found")?;
|
|
11967
|
+
if run["status"] != "running" {
|
|
11968
|
+
return Ok(());
|
|
11567
11969
|
}
|
|
11568
|
-
|
|
11970
|
+
let Some(step) = run["steps"]
|
|
11569
11971
|
.as_array_mut()
|
|
11570
|
-
.
|
|
11571
|
-
|
|
11572
|
-
|
|
11573
|
-
|
|
11574
|
-
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
|
|
11972
|
+
.and_then(|steps| steps.iter_mut().find(|step| step["id"] == step_id))
|
|
11973
|
+
else {
|
|
11974
|
+
return Ok(());
|
|
11975
|
+
};
|
|
11976
|
+
if claim_id.is_some_and(|claim| step["claim_id"] != claim) {
|
|
11977
|
+
return Ok(());
|
|
11978
|
+
}
|
|
11979
|
+
let attempt = step["attempt"].as_u64().unwrap_or(0);
|
|
11980
|
+
step["status"] = json!(agent_execution::STATUS_FAILED);
|
|
11981
|
+
step["failure"] = failure.to_record(attempt);
|
|
11982
|
+
run["status"] = json!(agent_execution::STATUS_FAILED);
|
|
11983
|
+
run["failure"] = json!({"step": step_id, "code": failure.code, "message": failure.message});
|
|
11984
|
+
put_canonical(state, "_flow_workflow_runs", run_id, run)?;
|
|
11985
|
+
Ok(())
|
|
11986
|
+
}
|
|
11987
|
+
|
|
11988
|
+
/// Return a step that failed transiently to `ready` after a backoff.
|
|
11989
|
+
async fn release_workflow_step(
|
|
11990
|
+
state: &AppState,
|
|
11991
|
+
run_id: &str,
|
|
11992
|
+
step_id: &str,
|
|
11993
|
+
claim_id: &str,
|
|
11994
|
+
failure: &RunFailure,
|
|
11995
|
+
) -> Result<(), ApiError> {
|
|
11996
|
+
let _guard = state.lifecycle_lock.lock().await;
|
|
11997
|
+
let mut run = required_value(state, "_flow_workflow_runs", run_id, "workflow run not found")?;
|
|
11998
|
+
let now = lease_now_ms(state)?;
|
|
11999
|
+
let Some(step) = run["steps"]
|
|
11578
12000
|
.as_array_mut()
|
|
12001
|
+
.and_then(|steps| steps.iter_mut().find(|step| step["id"] == step_id))
|
|
12002
|
+
else {
|
|
12003
|
+
return Ok(());
|
|
12004
|
+
};
|
|
12005
|
+
if step["claim_id"] != claim_id || step["status"] != "running" {
|
|
12006
|
+
return Ok(());
|
|
12007
|
+
}
|
|
12008
|
+
let attempt = step["attempt"].as_u64().unwrap_or(0);
|
|
12009
|
+
let lease_expires = step["lease_expires_ms"].as_u64().unwrap_or(0);
|
|
12010
|
+
step["status"] = json!(agent_execution::STATUS_READY);
|
|
12011
|
+
// Not before the failed attempt's lease lapses: until then the lease
|
|
12012
|
+
// quorum refuses any other claim.
|
|
12013
|
+
step["not_before_ms"] = json!(now
|
|
12014
|
+
.saturating_add(agent_execution::retry_delay_ms(attempt + 1))
|
|
12015
|
+
.max(lease_expires));
|
|
12016
|
+
step["last_failure"] = failure.to_record(attempt);
|
|
12017
|
+
put_canonical(state, "_flow_workflow_runs", run_id, run)?;
|
|
12018
|
+
Ok(())
|
|
12019
|
+
}
|
|
12020
|
+
|
|
12021
|
+
/// Execute one autonomous workflow step under the run's delegated authority.
|
|
12022
|
+
async fn execute_workflow_step(
|
|
12023
|
+
state: &AppState,
|
|
12024
|
+
run: &Value,
|
|
12025
|
+
run_id: &str,
|
|
12026
|
+
step_id: &str,
|
|
12027
|
+
attempts: u64,
|
|
12028
|
+
worker: &str,
|
|
12029
|
+
) -> Result<(), ApiError> {
|
|
12030
|
+
if attempts >= agent_execution::max_run_attempts() {
|
|
12031
|
+
let failure = RunFailure::permanent(
|
|
12032
|
+
agent_execution::RETRY_EXHAUSTED,
|
|
12033
|
+
format!("step was claimed {attempts} times without finishing"),
|
|
12034
|
+
);
|
|
12035
|
+
return fail_workflow_step(state, run_id, step_id, None, &failure).await;
|
|
12036
|
+
}
|
|
12037
|
+
let principal = match resolve_run_principal(state, run, RunKind::Workflow, run_id) {
|
|
12038
|
+
Ok(principal) if delegated_grants(state, &principal, "workflows:run") => principal,
|
|
12039
|
+
Ok(_) => {
|
|
12040
|
+
let failure = RunFailure::permanent(
|
|
12041
|
+
agent_execution::AUTHORIZATION_DENIED,
|
|
12042
|
+
"the run's principal may not run workflows",
|
|
12043
|
+
);
|
|
12044
|
+
return fail_workflow_step(state, run_id, step_id, None, &failure).await;
|
|
12045
|
+
}
|
|
12046
|
+
Err(failure) => return fail_workflow_step(state, run_id, step_id, None, &failure).await,
|
|
12047
|
+
};
|
|
12048
|
+
let claimed = transition_workflow_step(
|
|
12049
|
+
state,
|
|
12050
|
+
run_id,
|
|
12051
|
+
step_id,
|
|
12052
|
+
Some((worker.to_string(), 30_000)),
|
|
12053
|
+
None,
|
|
12054
|
+
)
|
|
12055
|
+
.await?;
|
|
12056
|
+
let claim_id = claimed.0.value["steps"]
|
|
12057
|
+
.as_array()
|
|
12058
|
+
.and_then(|steps| steps.iter().find(|step| step["id"] == step_id))
|
|
12059
|
+
.and_then(|step| step["claim_id"].as_str())
|
|
11579
12060
|
.ok_or_else(|| {
|
|
11580
12061
|
ApiError(
|
|
11581
12062
|
StatusCode::CONFLICT,
|
|
11582
|
-
"
|
|
12063
|
+
"worker claim was not materialized".to_string(),
|
|
11583
12064
|
)
|
|
11584
12065
|
})?
|
|
11585
|
-
.
|
|
11586
|
-
|
|
11587
|
-
|
|
12066
|
+
.to_string();
|
|
12067
|
+
let input = run["input"].clone();
|
|
12068
|
+
match execute_named_capability(state, step_id, &input, &CapabilityAccess::Delegated(&principal))
|
|
12069
|
+
{
|
|
12070
|
+
Ok(result) => {
|
|
12071
|
+
transition_workflow_step(state, run_id, step_id, None, Some((claim_id, result))).await?;
|
|
12072
|
+
}
|
|
12073
|
+
Err(error) => {
|
|
12074
|
+
let failure = agent_execution::classify_capability_error(
|
|
12075
|
+
error.0.as_u16(),
|
|
12076
|
+
&api_error_message(&error),
|
|
12077
|
+
);
|
|
12078
|
+
if failure.retryable && attempts + 1 < agent_execution::max_run_attempts() {
|
|
12079
|
+
release_workflow_step(state, run_id, step_id, &claim_id, &failure).await?;
|
|
12080
|
+
} else if failure.retryable {
|
|
12081
|
+
let exhausted = RunFailure::permanent(
|
|
12082
|
+
agent_execution::RETRY_EXHAUSTED,
|
|
12083
|
+
format!("{} after {} attempts", failure.message, attempts + 1),
|
|
12084
|
+
);
|
|
12085
|
+
fail_workflow_step(state, run_id, step_id, Some(&claim_id), &exhausted).await?;
|
|
12086
|
+
} else {
|
|
12087
|
+
fail_workflow_step(state, run_id, step_id, Some(&claim_id), &failure).await?;
|
|
12088
|
+
}
|
|
12089
|
+
}
|
|
12090
|
+
}
|
|
11588
12091
|
Ok(())
|
|
11589
12092
|
}
|
|
11590
12093
|
|
|
@@ -12563,10 +13066,12 @@ async fn get_provenance(
|
|
|
12563
13066
|
|
|
12564
13067
|
async fn search_collection(
|
|
12565
13068
|
State(state): State<AppState>,
|
|
13069
|
+
Extension(principal): Extension<Principal>,
|
|
12566
13070
|
Path(collection): Path<String>,
|
|
12567
13071
|
Json(request): Json<SearchRequest>,
|
|
12568
13072
|
) -> Result<Json<Vec<RecordResponse>>, ApiError> {
|
|
12569
13073
|
validate_segment(&collection)?;
|
|
13074
|
+
authorize_capability_read(&state, &CapabilityAccess::Principal(&principal), &collection)?;
|
|
12570
13075
|
if request.query.len() > 512 || request.limit == 0 || request.limit > 500 {
|
|
12571
13076
|
return Err(ApiError(
|
|
12572
13077
|
StatusCode::BAD_REQUEST,
|
|
@@ -12616,7 +13121,12 @@ async fn execute_capability(
|
|
|
12616
13121
|
));
|
|
12617
13122
|
}
|
|
12618
13123
|
validate_segment(&name)?;
|
|
12619
|
-
Ok(Json(execute_named_capability(
|
|
13124
|
+
Ok(Json(execute_named_capability(
|
|
13125
|
+
&state,
|
|
13126
|
+
&name,
|
|
13127
|
+
&input,
|
|
13128
|
+
&CapabilityAccess::Principal(&principal),
|
|
13129
|
+
)?))
|
|
12620
13130
|
}
|
|
12621
13131
|
|
|
12622
13132
|
async fn execute_application_capability(
|
|
@@ -12663,7 +13173,15 @@ async fn execute_application_capability(
|
|
|
12663
13173
|
"capability is not externally invocable".into(),
|
|
12664
13174
|
));
|
|
12665
13175
|
}
|
|
12666
|
-
Ok(Json(execute_named_capability(
|
|
13176
|
+
Ok(Json(execute_named_capability(
|
|
13177
|
+
&state,
|
|
13178
|
+
&name,
|
|
13179
|
+
&payload,
|
|
13180
|
+
&CapabilityAccess::Application {
|
|
13181
|
+
actor: &principal.key_id,
|
|
13182
|
+
application_id: &input.application_id,
|
|
13183
|
+
},
|
|
13184
|
+
)?))
|
|
12667
13185
|
}
|
|
12668
13186
|
|
|
12669
13187
|
fn validate_capability_program(program: &CapabilityProgram) -> Result<(), ApiError> {
|
|
@@ -12722,9 +13240,14 @@ fn execute_named_capability(
|
|
|
12722
13240
|
state: &AppState,
|
|
12723
13241
|
name: &str,
|
|
12724
13242
|
input: &Value,
|
|
13243
|
+
access: &CapabilityAccess<'_>,
|
|
12725
13244
|
) -> Result<Value, ApiError> {
|
|
13245
|
+
#[cfg(debug_assertions)]
|
|
13246
|
+
if let Some(fault) = injected_capability_fault(name) {
|
|
13247
|
+
return Err(fault);
|
|
13248
|
+
}
|
|
12726
13249
|
if is_builtin_capability(name) {
|
|
12727
|
-
return execute_builtin_capability(state, name, input);
|
|
13250
|
+
return execute_builtin_capability(state, name, input, access);
|
|
12728
13251
|
}
|
|
12729
13252
|
let value = required_value(state, "_flow_capabilities", name, "capability not found")?;
|
|
12730
13253
|
let program: CapabilityProgram = serde_json::from_value(value).map_err(|error| {
|
|
@@ -12758,6 +13281,7 @@ fn execute_named_capability(
|
|
|
12758
13281
|
state,
|
|
12759
13282
|
"search",
|
|
12760
13283
|
&json!({"collection": collection, "query": query, "limit": limit.unwrap_or(50)}),
|
|
13284
|
+
access,
|
|
12761
13285
|
)?
|
|
12762
13286
|
}
|
|
12763
13287
|
CapabilityInstruction::FilterEq {
|
|
@@ -12873,12 +13397,14 @@ async fn start_workflow_run(
|
|
|
12873
13397
|
let step_states: Vec<Value> = steps.iter().enumerate().map(|(index, step)| json!({
|
|
12874
13398
|
"id": step, "status": if index == 0 { "ready" } else { "pending" }, "worker": null, "result": null
|
|
12875
13399
|
})).collect();
|
|
13400
|
+
let (authority, attribution) = run_authority(&state, RunKind::Workflow, &id, &principal)?;
|
|
12876
13401
|
let response = insert_canonical(
|
|
12877
13402
|
&state,
|
|
12878
13403
|
"_flow_workflow_runs",
|
|
12879
13404
|
&id,
|
|
12880
13405
|
json!({
|
|
12881
|
-
"id": id, "workflow": name, "status": "running", "input": request.input, "steps": step_states
|
|
13406
|
+
"id": id, "workflow": name, "status": "running", "input": request.input, "steps": step_states,
|
|
13407
|
+
"authority": authority, "principal": attribution,
|
|
12882
13408
|
}),
|
|
12883
13409
|
)?;
|
|
12884
13410
|
Ok((StatusCode::CREATED, Json(response)))
|
|
@@ -12886,9 +13412,12 @@ async fn start_workflow_run(
|
|
|
12886
13412
|
|
|
12887
13413
|
async fn get_workflow_run(
|
|
12888
13414
|
State(state): State<AppState>,
|
|
13415
|
+
Extension(principal): Extension<Principal>,
|
|
12889
13416
|
Path(id): Path<String>,
|
|
12890
13417
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
12891
|
-
get_canonical(&state, "_flow_workflow_runs", &id, "workflow run not found")
|
|
13418
|
+
let mut response = get_canonical(&state, "_flow_workflow_runs", &id, "workflow run not found")?;
|
|
13419
|
+
redact_run_results(&state, &principal, &mut response.0.value);
|
|
13420
|
+
Ok(response)
|
|
12892
13421
|
}
|
|
12893
13422
|
|
|
12894
13423
|
async fn claim_workflow_step(
|
|
@@ -12940,6 +13469,12 @@ async fn transition_workflow_step(
|
|
|
12940
13469
|
validate_segment(step)?;
|
|
12941
13470
|
let _guard = state.lifecycle_lock.lock().await;
|
|
12942
13471
|
let mut run = required_value(state, "_flow_workflow_runs", id, "workflow run not found")?;
|
|
13472
|
+
if run["status"] == agent_execution::STATUS_FAILED {
|
|
13473
|
+
return Err(ApiError::structured(
|
|
13474
|
+
StatusCode::CONFLICT,
|
|
13475
|
+
json!({"code": "WORKFLOW_RUN_TERMINAL", "message": "workflow run has failed"}),
|
|
13476
|
+
));
|
|
13477
|
+
}
|
|
12943
13478
|
let steps = run["steps"]
|
|
12944
13479
|
.as_array_mut()
|
|
12945
13480
|
.ok_or_else(|| ApiError(StatusCode::CONFLICT, "workflow run is invalid".to_string()))?;
|
|
@@ -12968,10 +13503,12 @@ async fn transition_workflow_step(
|
|
|
12968
13503
|
membership_epoch: state.cluster.get().epoch,
|
|
12969
13504
|
};
|
|
12970
13505
|
reserve_quorum_lease(state, &resource, &lease).await?;
|
|
13506
|
+
let attempt = steps[index]["attempt"].as_u64().unwrap_or(0) + 1;
|
|
12971
13507
|
steps[index]["status"] = json!("running");
|
|
12972
13508
|
steps[index]["worker"] = json!(worker);
|
|
12973
13509
|
steps[index]["claim_id"] = json!(claim_id);
|
|
12974
13510
|
steps[index]["lease_expires_ms"] = json!(expires_ms);
|
|
13511
|
+
steps[index]["attempt"] = json!(attempt);
|
|
12975
13512
|
} else {
|
|
12976
13513
|
if status != "running" {
|
|
12977
13514
|
return Err(ApiError(
|
|
@@ -13029,31 +13566,169 @@ async fn start_agent_run(
|
|
|
13029
13566
|
));
|
|
13030
13567
|
}
|
|
13031
13568
|
validate_segment(&name)?;
|
|
13032
|
-
required_value(&state, "_flow_agents", &name, "agent not found")?;
|
|
13569
|
+
let definition = required_value(&state, "_flow_agents", &name, "agent not found")?;
|
|
13033
13570
|
if request.goal.trim().is_empty() {
|
|
13034
13571
|
return Err(ApiError(
|
|
13035
13572
|
StatusCode::BAD_REQUEST,
|
|
13036
13573
|
"agent goal is required".to_string(),
|
|
13037
13574
|
));
|
|
13038
13575
|
}
|
|
13576
|
+
if let Some(criteria) = &request.completion {
|
|
13577
|
+
criteria.validate().map_err(|message| {
|
|
13578
|
+
ApiError::structured(
|
|
13579
|
+
StatusCode::BAD_REQUEST,
|
|
13580
|
+
json!({"code": "COMPLETION_CRITERIA_INVALID", "message": message}),
|
|
13581
|
+
)
|
|
13582
|
+
})?;
|
|
13583
|
+
}
|
|
13584
|
+
// Refuse a run that could only ever fail, instead of creating one.
|
|
13585
|
+
agent_capability(&state, &definition).map_err(|failure| {
|
|
13586
|
+
ApiError::structured(
|
|
13587
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
13588
|
+
json!({"code": failure.code, "message": failure.message}),
|
|
13589
|
+
)
|
|
13590
|
+
})?;
|
|
13039
13591
|
let id = next_id(&state, "agent");
|
|
13592
|
+
let (authority, attribution) = run_authority(&state, RunKind::Agent, &id, &principal)?;
|
|
13040
13593
|
let response = insert_canonical(
|
|
13041
13594
|
&state,
|
|
13042
13595
|
"_flow_agent_runs",
|
|
13043
13596
|
&id,
|
|
13044
13597
|
json!({
|
|
13045
13598
|
"id": id, "agent": name, "goal": request.goal, "input": request.input,
|
|
13046
|
-
"status":
|
|
13599
|
+
"status": agent_execution::STATUS_READY, "observations": [], "decisions": [],
|
|
13600
|
+
"completion": request.completion, "evidence": [], "attempt": 0,
|
|
13601
|
+
"authority": authority, "principal": attribution,
|
|
13047
13602
|
}),
|
|
13048
13603
|
)?;
|
|
13049
13604
|
Ok((StatusCode::CREATED, Json(response)))
|
|
13050
13605
|
}
|
|
13051
13606
|
|
|
13607
|
+
/// Sign the requesting principal's authority into a new run, and the
|
|
13608
|
+
/// attribution recorded beside it.
|
|
13609
|
+
fn run_authority(
|
|
13610
|
+
state: &AppState,
|
|
13611
|
+
kind: RunKind,
|
|
13612
|
+
run_id: &str,
|
|
13613
|
+
principal: &Principal,
|
|
13614
|
+
) -> Result<(Value, Value), ApiError> {
|
|
13615
|
+
let run_principal = RunPrincipal {
|
|
13616
|
+
id: principal.key_id.clone(),
|
|
13617
|
+
kind: principal.subject_type.clone(),
|
|
13618
|
+
scopes: principal.scopes.iter().cloned().collect(),
|
|
13619
|
+
};
|
|
13620
|
+
let grant = agent_execution::issue_run_authority(
|
|
13621
|
+
&state.grant_signer,
|
|
13622
|
+
kind,
|
|
13623
|
+
run_id,
|
|
13624
|
+
&state.namespace,
|
|
13625
|
+
&run_principal,
|
|
13626
|
+
unix_seconds_i64(),
|
|
13627
|
+
)
|
|
13628
|
+
.map_err(|message| ApiError(StatusCode::INTERNAL_SERVER_ERROR, message))?;
|
|
13629
|
+
let authority = serde_json::to_value(grant)
|
|
13630
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
|
13631
|
+
Ok((
|
|
13632
|
+
authority,
|
|
13633
|
+
json!({"id": principal.key_id, "kind": principal.subject_type}),
|
|
13634
|
+
))
|
|
13635
|
+
}
|
|
13636
|
+
|
|
13637
|
+
/// The capability an agent run executes: the first one its definition
|
|
13638
|
+
/// declares. It must be executable now; one that is not is a permanent error,
|
|
13639
|
+
/// never a run left waiting for a capability that does not exist.
|
|
13640
|
+
fn agent_capability(state: &AppState, definition: &Value) -> Result<String, RunFailure> {
|
|
13641
|
+
let Some(name) = definition["capabilities"]
|
|
13642
|
+
.as_array()
|
|
13643
|
+
.and_then(|values| values.first())
|
|
13644
|
+
.and_then(Value::as_str)
|
|
13645
|
+
else {
|
|
13646
|
+
return Err(RunFailure::permanent(
|
|
13647
|
+
agent_execution::CAPABILITY_UNKNOWN,
|
|
13648
|
+
"agent declares no capability",
|
|
13649
|
+
));
|
|
13650
|
+
};
|
|
13651
|
+
match is_executable_capability(state, name) {
|
|
13652
|
+
Ok(true) => Ok(name.to_string()),
|
|
13653
|
+
_ => Err(RunFailure::permanent(
|
|
13654
|
+
agent_execution::CAPABILITY_UNKNOWN,
|
|
13655
|
+
format!("capability {name} is not defined on this node"),
|
|
13656
|
+
)),
|
|
13657
|
+
}
|
|
13658
|
+
}
|
|
13659
|
+
|
|
13660
|
+
/// The principal a run may act for at execution time.
|
|
13661
|
+
///
|
|
13662
|
+
/// The signed authority binds scopes to the run. When authentication is on,
|
|
13663
|
+
/// the key is also re-read, and only scopes it still holds remain: a revoked
|
|
13664
|
+
/// key acts for nothing, and a narrowed key never acts with its old breadth.
|
|
13665
|
+
fn resolve_run_principal(
|
|
13666
|
+
state: &AppState,
|
|
13667
|
+
run: &Value,
|
|
13668
|
+
kind: RunKind,
|
|
13669
|
+
run_id: &str,
|
|
13670
|
+
) -> Result<RunPrincipal, RunFailure> {
|
|
13671
|
+
let mut principal = agent_execution::verify_run_authority(
|
|
13672
|
+
&state.grant_signer,
|
|
13673
|
+
&run["authority"],
|
|
13674
|
+
kind,
|
|
13675
|
+
run_id,
|
|
13676
|
+
&state.namespace,
|
|
13677
|
+
unix_seconds_i64(),
|
|
13678
|
+
)?;
|
|
13679
|
+
if state.auth_enabled && principal.kind != "human" {
|
|
13680
|
+
let Some((current, current_kind)) = state.keys.active_scopes(&principal.id, &state.namespace)
|
|
13681
|
+
else {
|
|
13682
|
+
return Err(RunFailure::permanent(
|
|
13683
|
+
agent_execution::AUTHORIZATION_DENIED,
|
|
13684
|
+
"the run's principal is no longer active",
|
|
13685
|
+
));
|
|
13686
|
+
};
|
|
13687
|
+
if current_kind != principal.kind {
|
|
13688
|
+
return Err(RunFailure::permanent(
|
|
13689
|
+
agent_execution::AUTHORIZATION_DENIED,
|
|
13690
|
+
"the run's principal changed type",
|
|
13691
|
+
));
|
|
13692
|
+
}
|
|
13693
|
+
principal.scopes =
|
|
13694
|
+
agent_execution::effective_scopes(&principal.scopes, ¤t.into_iter().collect());
|
|
13695
|
+
}
|
|
13696
|
+
Ok(principal)
|
|
13697
|
+
}
|
|
13698
|
+
|
|
13052
13699
|
async fn get_agent_run(
|
|
13053
13700
|
State(state): State<AppState>,
|
|
13701
|
+
Extension(principal): Extension<Principal>,
|
|
13054
13702
|
Path(id): Path<String>,
|
|
13055
13703
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13056
|
-
get_canonical(&state, "_flow_agent_runs", &id, "agent run not found")
|
|
13704
|
+
let mut response = get_canonical(&state, "_flow_agent_runs", &id, "agent run not found")?;
|
|
13705
|
+
redact_run_results(&state, &principal, &mut response.0.value);
|
|
13706
|
+
Ok(response)
|
|
13707
|
+
}
|
|
13708
|
+
|
|
13709
|
+
/// Remove the state a run read from a response to a reader who may not read
|
|
13710
|
+
/// state. A run's observations hold records read under its caller's
|
|
13711
|
+
/// authority; `agents:read` alone must not disclose them.
|
|
13712
|
+
fn redact_run_results(state: &AppState, principal: &Principal, run: &mut Value) {
|
|
13713
|
+
if request_principal_grants(state, principal, "state:read") {
|
|
13714
|
+
return;
|
|
13715
|
+
}
|
|
13716
|
+
let Some(object) = run.as_object_mut() else {
|
|
13717
|
+
return;
|
|
13718
|
+
};
|
|
13719
|
+
for field in ["observations", "decisions"] {
|
|
13720
|
+
if object.contains_key(field) {
|
|
13721
|
+
object.insert(field.into(), json!([]));
|
|
13722
|
+
}
|
|
13723
|
+
}
|
|
13724
|
+
if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
|
|
13725
|
+
for step in steps {
|
|
13726
|
+
if step.get("result").is_some_and(|result| !result.is_null()) {
|
|
13727
|
+
step["result"] = Value::Null;
|
|
13728
|
+
}
|
|
13729
|
+
}
|
|
13730
|
+
}
|
|
13731
|
+
object.insert("results_redacted".into(), json!(true));
|
|
13057
13732
|
}
|
|
13058
13733
|
|
|
13059
13734
|
async fn add_agent_observation(
|
|
@@ -13061,12 +13736,11 @@ async fn add_agent_observation(
|
|
|
13061
13736
|
Path(id): Path<String>,
|
|
13062
13737
|
Json(request): Json<Observation>,
|
|
13063
13738
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13064
|
-
mutate_agent_run(&state, &id, |run| {
|
|
13739
|
+
mutate_agent_run(&state, &id, agent_execution::STATUS_DELIBERATING, |run| {
|
|
13065
13740
|
run["observations"]
|
|
13066
13741
|
.as_array_mut()
|
|
13067
13742
|
.expect("validated agent run")
|
|
13068
13743
|
.push(request.observation);
|
|
13069
|
-
run["status"] = json!("deliberating");
|
|
13070
13744
|
})
|
|
13071
13745
|
.await
|
|
13072
13746
|
}
|
|
@@ -13076,32 +13750,44 @@ async fn add_agent_decision(
|
|
|
13076
13750
|
Path(id): Path<String>,
|
|
13077
13751
|
Json(request): Json<Decision>,
|
|
13078
13752
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13079
|
-
|
|
13753
|
+
// Completion is decided by the runtime from criteria it verifies, never by
|
|
13754
|
+
// a participant saying so — whatever evidence the request carries.
|
|
13755
|
+
if request.completed {
|
|
13756
|
+
return Err(ApiError::structured(
|
|
13757
|
+
StatusCode::CONFLICT,
|
|
13758
|
+
json!({
|
|
13759
|
+
"code": "AGENT_COMPLETION_NOT_CLIENT_CONTROLLED",
|
|
13760
|
+
"message": "a run completes only when the runtime verifies its declared completion criteria",
|
|
13761
|
+
}),
|
|
13762
|
+
));
|
|
13763
|
+
}
|
|
13764
|
+
mutate_agent_run(&state, &id, agent_execution::STATUS_ACTING, |run| {
|
|
13080
13765
|
run["decisions"]
|
|
13081
13766
|
.as_array_mut()
|
|
13082
13767
|
.expect("validated agent run")
|
|
13083
|
-
.push(json!({"decision": request.decision, "action": request.action}));
|
|
13084
|
-
run["status"] = json!(if request.completed {
|
|
13085
|
-
"completed"
|
|
13086
|
-
} else {
|
|
13087
|
-
"acting"
|
|
13088
|
-
});
|
|
13768
|
+
.push(json!({"decision": request.decision, "action": request.action, "source": "client"}));
|
|
13089
13769
|
})
|
|
13090
13770
|
.await
|
|
13091
13771
|
}
|
|
13092
13772
|
|
|
13773
|
+
/// Append a participant's annotation to a run that is still open.
|
|
13774
|
+
///
|
|
13775
|
+
/// Terminal outcomes are never rewritten. While the worker holds the run, the
|
|
13776
|
+
/// annotation is kept but the status is the worker's.
|
|
13093
13777
|
async fn mutate_agent_run<F: FnOnce(&mut Value)>(
|
|
13094
13778
|
state: &AppState,
|
|
13095
13779
|
id: &str,
|
|
13780
|
+
annotation: &str,
|
|
13096
13781
|
change: F,
|
|
13097
13782
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13098
13783
|
validate_segment(id)?;
|
|
13099
13784
|
let _guard = state.lifecycle_lock.lock().await;
|
|
13100
13785
|
let mut run = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
|
|
13101
|
-
|
|
13102
|
-
|
|
13786
|
+
let status = run["status"].as_str().unwrap_or_default().to_string();
|
|
13787
|
+
if agent_execution::is_terminal(&status) {
|
|
13788
|
+
return Err(ApiError::structured(
|
|
13103
13789
|
StatusCode::CONFLICT,
|
|
13104
|
-
"agent run is already
|
|
13790
|
+
json!({"code": "AGENT_RUN_TERMINAL", "message": format!("agent run is already {status}")}),
|
|
13105
13791
|
));
|
|
13106
13792
|
}
|
|
13107
13793
|
if !run["observations"].is_array() || !run["decisions"].is_array() {
|
|
@@ -13111,6 +13797,9 @@ async fn mutate_agent_run<F: FnOnce(&mut Value)>(
|
|
|
13111
13797
|
));
|
|
13112
13798
|
}
|
|
13113
13799
|
change(&mut run);
|
|
13800
|
+
if status != agent_execution::STATUS_RUNNING {
|
|
13801
|
+
run["status"] = json!(annotation);
|
|
13802
|
+
}
|
|
13114
13803
|
put_canonical(state, "_flow_agent_runs", id, run)
|
|
13115
13804
|
}
|
|
13116
13805
|
|
|
@@ -14214,6 +14903,7 @@ async fn commit_transaction(
|
|
|
14214
14903
|
record_preconditions.push(precondition);
|
|
14215
14904
|
}
|
|
14216
14905
|
for operation in request.operations {
|
|
14906
|
+
ensure_not_runtime_owned(&operation.collection)?;
|
|
14217
14907
|
let key = record_key(&operation.collection, &operation.id)?;
|
|
14218
14908
|
if !seen.insert(key.clone()) {
|
|
14219
14909
|
return Err(ApiError(
|
|
@@ -14293,9 +14983,7 @@ async fn commit_transaction(
|
|
|
14293
14983
|
FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
|
|
14294
14984
|
ApiError(StatusCode::BAD_REQUEST, message)
|
|
14295
14985
|
}
|
|
14296
|
-
FlowError::
|
|
14297
|
-
if message.starts_with("TRANSACTION_ID_PAYLOAD_MISMATCH:") =>
|
|
14298
|
-
{
|
|
14986
|
+
FlowError::TransactionIdPayloadMismatch { .. } => {
|
|
14299
14987
|
ApiError::structured(
|
|
14300
14988
|
StatusCode::CONFLICT,
|
|
14301
14989
|
json!({
|
|
@@ -14336,6 +15024,21 @@ async fn commit_transaction(
|
|
|
14336
15024
|
))
|
|
14337
15025
|
}
|
|
14338
15026
|
|
|
15027
|
+
/// Refuse a raw write to a collection only the runtime writes.
|
|
15028
|
+
///
|
|
15029
|
+
/// A run's status, retry state, evidence and authority decide what the worker
|
|
15030
|
+
/// does and what the run reports. Letting `/collections` or `/transactions`
|
|
15031
|
+
/// write them would let any `state:write` caller declare a run completed.
|
|
15032
|
+
fn ensure_not_runtime_owned(collection: &str) -> Result<(), ApiError> {
|
|
15033
|
+
if agent_execution::is_runtime_owned_collection(collection) {
|
|
15034
|
+
return Err(ApiError::structured(
|
|
15035
|
+
StatusCode::FORBIDDEN,
|
|
15036
|
+
json!({"code": "RUNTIME_OWNED_COLLECTION", "message": "this collection is written only by the runtime"}),
|
|
15037
|
+
));
|
|
15038
|
+
}
|
|
15039
|
+
Ok(())
|
|
15040
|
+
}
|
|
15041
|
+
|
|
14339
15042
|
async fn create_record(
|
|
14340
15043
|
State(state): State<AppState>,
|
|
14341
15044
|
Path(collection): Path<String>,
|
|
@@ -14343,6 +15046,7 @@ async fn create_record(
|
|
|
14343
15046
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
14344
15047
|
let _handler =
|
|
14345
15048
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
15049
|
+
ensure_not_runtime_owned(&collection)?;
|
|
14346
15050
|
if collection == PROPOSAL_COLLECTION
|
|
14347
15051
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
14348
15052
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -14391,6 +15095,7 @@ async fn update_record(
|
|
|
14391
15095
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
14392
15096
|
let _handler =
|
|
14393
15097
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
15098
|
+
ensure_not_runtime_owned(&collection)?;
|
|
14394
15099
|
if collection == PROPOSAL_COLLECTION
|
|
14395
15100
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
14396
15101
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -14430,6 +15135,7 @@ async fn compare_and_set_record(
|
|
|
14430
15135
|
) -> Result<Response, ApiError> {
|
|
14431
15136
|
let _handler =
|
|
14432
15137
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
15138
|
+
ensure_not_runtime_owned(&collection)?;
|
|
14433
15139
|
let key = record_key(&collection, &id)?;
|
|
14434
15140
|
let result = state.db.compare_and_set_json(
|
|
14435
15141
|
&key,
|
|
@@ -14521,6 +15227,7 @@ async fn delete_record(
|
|
|
14521
15227
|
) -> Result<StatusCode, ApiError> {
|
|
14522
15228
|
let _handler =
|
|
14523
15229
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
15230
|
+
ensure_not_runtime_owned(&collection)?;
|
|
14524
15231
|
if collection == PROPOSAL_COLLECTION
|
|
14525
15232
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
14526
15233
|
|| collection == CONVERGENCE_COLLECTION
|