@feltdb/core 0.12.2 → 0.15.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/agent-runtime.d.ts +64 -2
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +121 -11
- package/dist/agent-state.d.ts +48 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +174 -12
- package/dist/agent.d.ts +10 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -1
- package/dist/build-identity.json +4 -4
- package/dist/cli/commands.js +34 -7
- package/dist/cli/workspace-integration.js +8 -0
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +30 -8
- package/dist/create/build-identity.json +4 -4
- package/dist/create/cli.js +47 -38
- package/dist/create/create.js +21 -8
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +6 -6
- package/dist/create/server-source/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +341 -2
- package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
- 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 +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +113 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
- package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
- package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
- package/dist/create/setup-summary.js +40 -0
- package/dist/db.d.ts +91 -10
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +170 -31
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- 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 +61 -12
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +31 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +41 -2
- package/dist/feltdb.d.ts +7 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +27 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +145 -7
- package/dist/http-client.d.ts +11 -3
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +27 -14
- package/dist/http-db.d.ts +40 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +285 -58
- package/dist/index.js +1 -1
- package/dist/local-state-lock.d.ts +34 -0
- package/dist/local-state-lock.d.ts.map +1 -0
- package/dist/local-state-lock.js +225 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
- 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/index.js +2 -2
- package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
- 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/index.js +2 -2
- package/dist/studio/control-plane/primitives.d.ts +16 -0
- package/dist/studio/control-plane/primitives.d.ts.map +1 -1
- package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +384 -247
- 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 +5 -1
- package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-BjM1ylL3.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
- package/dist/studio-app/assets/index-1wd0uKua.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/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +31 -3
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +28 -3
- 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/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
- package/dist/studio-app/assets/index-DoVvbIay.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},
|
|
@@ -87,9 +88,11 @@ use feltdb_server::{
|
|
|
87
88
|
cluster::{ClusterConfig, ClusterStore, MembershipProposal, ProposalPhase},
|
|
88
89
|
connections::{ConnectionProvider, ConnectionStatus, ConnectionStore, SecretReference},
|
|
89
90
|
content::ContentStore,
|
|
91
|
+
control_authorization::{self, PrincipalClass},
|
|
92
|
+
console_accounts::{self, ConsoleAccountStore},
|
|
90
93
|
control_plane,
|
|
91
94
|
identity::IdentityStore,
|
|
92
|
-
key_management::{create_key, list_keys, revoke_key},
|
|
95
|
+
key_management::{create_key, list_keys, revoke_key, rotate_key},
|
|
93
96
|
key_provider::provider_from_environment,
|
|
94
97
|
leases::LeaseStore,
|
|
95
98
|
metrics::Metrics,
|
|
@@ -199,11 +202,13 @@ struct HealthResponse<'a> {
|
|
|
199
202
|
public_contract: &'a str,
|
|
200
203
|
capability_fingerprint: String,
|
|
201
204
|
runtime: &'a str,
|
|
205
|
+
/// Replay outcome of the durable log at open (`clean` or
|
|
206
|
+
/// `recovered-incomplete-write`). Liveness only: it is not a probe of the
|
|
207
|
+
/// volume now and not a durability claim — see `/ready` and `/v1/status`.
|
|
202
208
|
storage: &'a str,
|
|
203
|
-
|
|
209
|
+
/// Configuration, not a probe: whether this process runs the autonomous
|
|
210
|
+
/// worker.
|
|
204
211
|
execution: &'a str,
|
|
205
|
-
workflows: &'a str,
|
|
206
|
-
agents: &'a str,
|
|
207
212
|
membership: &'a str,
|
|
208
213
|
}
|
|
209
214
|
|
|
@@ -1465,6 +1470,10 @@ struct WorkloadFailRequest {
|
|
|
1465
1470
|
fence: WorkloadFenceRequest,
|
|
1466
1471
|
kind: String,
|
|
1467
1472
|
error: String,
|
|
1473
|
+
/// `none` (failed before any external effect) or `unknown` (an effect may
|
|
1474
|
+
/// have happened). Omitted: the policy decides by `kind`.
|
|
1475
|
+
#[serde(default)]
|
|
1476
|
+
effect: Option<String>,
|
|
1468
1477
|
}
|
|
1469
1478
|
#[derive(Deserialize)]
|
|
1470
1479
|
struct WorkloadContextRequest {
|
|
@@ -1476,6 +1485,9 @@ struct WorkloadContextRequest {
|
|
|
1476
1485
|
#[derive(Deserialize)]
|
|
1477
1486
|
struct WorkloadControlRequest {
|
|
1478
1487
|
grant: feltdb::authorization::SignedGrant,
|
|
1488
|
+
/// Why an operator retried the workload; recorded in its history.
|
|
1489
|
+
#[serde(default)]
|
|
1490
|
+
reason: Option<String>,
|
|
1479
1491
|
}
|
|
1480
1492
|
#[derive(Deserialize)]
|
|
1481
1493
|
struct WorkerHeartbeatRequest {
|
|
@@ -1492,13 +1504,57 @@ struct WorkerReconcileRequest {
|
|
|
1492
1504
|
reconciliation: WorkerReconciliation,
|
|
1493
1505
|
grant: feltdb::authorization::SignedGrant,
|
|
1494
1506
|
}
|
|
1507
|
+
/// Whether this node commits Durable Work.
|
|
1508
|
+
///
|
|
1509
|
+
/// Durable Work's guarantees rest on one serialization point for each
|
|
1510
|
+
/// workload: the guarded commit of one store. Peers replicate by causal
|
|
1511
|
+
/// order with last-writer-wins for concurrent writes, so two nodes that both
|
|
1512
|
+
/// committed work would each pass their own guard and silently diverge. A node
|
|
1513
|
+
/// without peers is therefore always the authority (the single-node default);
|
|
1514
|
+
/// in a peered cluster exactly one node, started with `--work-authority`,
|
|
1515
|
+
/// commits Durable Work, and every other node serves it read-only from its
|
|
1516
|
+
/// replicated store. A node that learns a peer is also the authority stops
|
|
1517
|
+
/// being one.
|
|
1518
|
+
fn is_work_authority(state: &AppState) -> bool {
|
|
1519
|
+
if state
|
|
1520
|
+
.work_authority_conflict
|
|
1521
|
+
.load(std::sync::atomic::Ordering::SeqCst)
|
|
1522
|
+
{
|
|
1523
|
+
return false;
|
|
1524
|
+
}
|
|
1525
|
+
state.work_authority || state.cluster.get().peers.is_empty()
|
|
1526
|
+
}
|
|
1527
|
+
/// Refuse a Durable Work write on a node that is not the authority.
|
|
1528
|
+
fn require_work_authority(state: &AppState) -> Result<(), ApiError> {
|
|
1529
|
+
if is_work_authority(state) {
|
|
1530
|
+
return Ok(());
|
|
1531
|
+
}
|
|
1532
|
+
let conflict = state
|
|
1533
|
+
.work_authority_conflict
|
|
1534
|
+
.load(std::sync::atomic::Ordering::SeqCst);
|
|
1535
|
+
Err(ApiError::structured(
|
|
1536
|
+
StatusCode::MISDIRECTED_REQUEST,
|
|
1537
|
+
json!({"code": "WORKLOAD_NOT_AUTHORITY",
|
|
1538
|
+
"message": if conflict {
|
|
1539
|
+
"more than one node is configured as the Durable Work authority; Durable Work writes are refused until that is corrected"
|
|
1540
|
+
} else {
|
|
1541
|
+
"this node replicates Durable Work read-only; send the request to the node started with --work-authority"
|
|
1542
|
+
},
|
|
1543
|
+
"details": {"reason": if conflict { "authority_conflict" } else { "replica" }, "retryable": false}}),
|
|
1544
|
+
))
|
|
1545
|
+
}
|
|
1495
1546
|
fn workload_error(error: feltdb::workload::WorkloadError) -> ApiError {
|
|
1496
1547
|
let status = match error.code.as_str() {
|
|
1497
1548
|
"WORKLOAD_NOT_FOUND" => StatusCode::NOT_FOUND,
|
|
1498
1549
|
"WORKLOAD_STALE_FENCE"
|
|
1499
1550
|
| "WORKLOAD_TRANSITION_DENIED"
|
|
1500
1551
|
| "WORKLOAD_NOT_READY"
|
|
1501
|
-
| "WORKLOAD_NOT_YET_ELIGIBLE"
|
|
1552
|
+
| "WORKLOAD_NOT_YET_ELIGIBLE"
|
|
1553
|
+
| "WORKLOAD_ATTEMPTS_EXHAUSTED"
|
|
1554
|
+
| "WORKLOAD_RETRY_EXHAUSTED"
|
|
1555
|
+
| "WORKLOAD_RESULT_CONFLICT"
|
|
1556
|
+
| "WORKLOAD_TIMED_OUT"
|
|
1557
|
+
| "WORKLOAD_CONFLICT" => StatusCode::CONFLICT,
|
|
1502
1558
|
"WORKLOAD_UNAUTHORIZED" | "WORKLOAD_CAPABILITY_DENIED" => StatusCode::FORBIDDEN,
|
|
1503
1559
|
"WORKLOAD_STORAGE_FAILURE" => StatusCode::SERVICE_UNAVAILABLE,
|
|
1504
1560
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
@@ -1589,7 +1645,8 @@ async fn create_workload(
|
|
|
1589
1645
|
State(state): State<AppState>,
|
|
1590
1646
|
Extension(principal): Extension<Principal>,
|
|
1591
1647
|
Json(mut input): Json<CreateWorkload>,
|
|
1592
|
-
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
1648
|
+
) -> Result<(StatusCode, HeaderMap, Json<Value>), ApiError> {
|
|
1649
|
+
require_work_authority(&state)?;
|
|
1593
1650
|
let subject = grant_subject(&principal);
|
|
1594
1651
|
input.requested_by = subject.clone();
|
|
1595
1652
|
input.session_id = principal.session_id.clone();
|
|
@@ -1603,7 +1660,7 @@ async fn create_workload(
|
|
|
1603
1660
|
"workload:create",
|
|
1604
1661
|
&input.authorization_snapshot,
|
|
1605
1662
|
)?;
|
|
1606
|
-
let workload = state
|
|
1663
|
+
let (workload, created) = state
|
|
1607
1664
|
.workloads
|
|
1608
1665
|
.lock()
|
|
1609
1666
|
.map_err(|_| {
|
|
@@ -1612,17 +1669,42 @@ async fn create_workload(
|
|
|
1612
1669
|
"workload store unavailable".into(),
|
|
1613
1670
|
)
|
|
1614
1671
|
})?
|
|
1615
|
-
.
|
|
1616
|
-
.map_err(
|
|
1672
|
+
.admit(input)
|
|
1673
|
+
.map_err(|error| {
|
|
1674
|
+
if error.code == "IDEMPOTENCY_KEY_REUSED" {
|
|
1675
|
+
// AppPort's contract for a reused key (§13.1): CONFLICT, with
|
|
1676
|
+
// the reason, not retryable.
|
|
1677
|
+
// `fields` names the execution-affecting request fields that
|
|
1678
|
+
// differ from the admitted workload.
|
|
1679
|
+
let fields = error
|
|
1680
|
+
.details
|
|
1681
|
+
.as_ref()
|
|
1682
|
+
.and_then(|details| details.get("fields"))
|
|
1683
|
+
.cloned()
|
|
1684
|
+
.unwrap_or_else(|| json!([]));
|
|
1685
|
+
ApiError::structured(
|
|
1686
|
+
StatusCode::CONFLICT,
|
|
1687
|
+
json!({"code": "CONFLICT", "message": error.message,
|
|
1688
|
+
"details": {"reason": "IDEMPOTENCY_KEY_REUSED", "retryable": false, "fields": fields}}),
|
|
1689
|
+
)
|
|
1690
|
+
} else {
|
|
1691
|
+
workload_error(error)
|
|
1692
|
+
}
|
|
1693
|
+
})?;
|
|
1617
1694
|
record_authorization_audit(
|
|
1618
1695
|
&state,
|
|
1619
1696
|
&principal.key_id,
|
|
1620
|
-
"workload.created",
|
|
1697
|
+
if created { "workload.created" } else { "workload.replayed" },
|
|
1621
1698
|
&workload.workload_id,
|
|
1622
1699
|
"ready",
|
|
1623
1700
|
201,
|
|
1624
1701
|
);
|
|
1625
|
-
|
|
1702
|
+
// A replay answers with the stored response, marked as a replay.
|
|
1703
|
+
let mut headers = HeaderMap::new();
|
|
1704
|
+
if !created {
|
|
1705
|
+
headers.insert("idempotent-replay", axum::http::HeaderValue::from_static("true"));
|
|
1706
|
+
}
|
|
1707
|
+
Ok((StatusCode::CREATED, headers, Json(json!(workload))))
|
|
1626
1708
|
}
|
|
1627
1709
|
async fn list_workloads(
|
|
1628
1710
|
State(state): State<AppState>,
|
|
@@ -1644,7 +1726,8 @@ async fn list_workloads(
|
|
|
1644
1726
|
"workload store unavailable".into(),
|
|
1645
1727
|
)
|
|
1646
1728
|
})?
|
|
1647
|
-
.
|
|
1729
|
+
.fresh_list(&tenant, &target.application_id)
|
|
1730
|
+
.map_err(workload_error)?;
|
|
1648
1731
|
Ok(Json(json!({"workloads":values,"grant_id":target.grant_id})))
|
|
1649
1732
|
}
|
|
1650
1733
|
async fn get_workload(
|
|
@@ -1661,28 +1744,42 @@ async fn get_workload(
|
|
|
1661
1744
|
"workload store unavailable".into(),
|
|
1662
1745
|
)
|
|
1663
1746
|
})?
|
|
1664
|
-
.
|
|
1747
|
+
.fresh(&id)
|
|
1665
1748
|
.map_err(workload_error)?
|
|
1666
1749
|
.clone();
|
|
1667
|
-
|
|
1668
|
-
&state,
|
|
1669
|
-
&principal.key_id,
|
|
1670
|
-
&workload.application_id,
|
|
1671
|
-
"workload:read",
|
|
1672
|
-
)?;
|
|
1750
|
+
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
1673
1751
|
Ok(Json(json!(workload)))
|
|
1674
1752
|
}
|
|
1753
|
+
/// Authorize reading one workload. A caller who may not read the
|
|
1754
|
+
/// application's work gets the same answer as for a workload that does not
|
|
1755
|
+
/// exist, so workload ids cannot be probed.
|
|
1756
|
+
fn workload_read_scope(
|
|
1757
|
+
state: &AppState,
|
|
1758
|
+
principal: &Principal,
|
|
1759
|
+
application_id: &str,
|
|
1760
|
+
) -> Result<(), ApiError> {
|
|
1761
|
+
application_scope(state, &principal.key_id, application_id, "workload:read")
|
|
1762
|
+
.map(|_| ())
|
|
1763
|
+
.map_err(|_| {
|
|
1764
|
+
// Exactly the body `workload_error` gives a missing workload.
|
|
1765
|
+
ApiError::structured(
|
|
1766
|
+
StatusCode::NOT_FOUND,
|
|
1767
|
+
json!({"code": "WORKLOAD_NOT_FOUND", "message": "workload not found"}),
|
|
1768
|
+
)
|
|
1769
|
+
})
|
|
1770
|
+
}
|
|
1675
1771
|
async fn claim_workload(
|
|
1676
1772
|
State(state): State<AppState>,
|
|
1677
1773
|
Extension(principal): Extension<Principal>,
|
|
1678
1774
|
Path(id): Path<String>,
|
|
1679
1775
|
Json(input): Json<WorkloadClaimRequest>,
|
|
1680
1776
|
) -> Result<Json<Value>, ApiError> {
|
|
1777
|
+
require_work_authority(&state)?;
|
|
1681
1778
|
let workload = state
|
|
1682
1779
|
.workloads
|
|
1683
1780
|
.lock()
|
|
1684
1781
|
.unwrap()
|
|
1685
|
-
.
|
|
1782
|
+
.fresh(&id)
|
|
1686
1783
|
.map_err(workload_error)?
|
|
1687
1784
|
.clone();
|
|
1688
1785
|
let subject = grant_subject(&principal);
|
|
@@ -1736,7 +1833,7 @@ fn authorize_fence(
|
|
|
1736
1833
|
.workloads
|
|
1737
1834
|
.lock()
|
|
1738
1835
|
.unwrap()
|
|
1739
|
-
.
|
|
1836
|
+
.fresh(id)
|
|
1740
1837
|
.map_err(workload_error)?
|
|
1741
1838
|
.clone();
|
|
1742
1839
|
let subject = grant_subject(principal);
|
|
@@ -1763,6 +1860,7 @@ async fn start_workload(
|
|
|
1763
1860
|
Path(id): Path<String>,
|
|
1764
1861
|
Json(input): Json<WorkloadFenceRequest>,
|
|
1765
1862
|
) -> Result<StatusCode, ApiError> {
|
|
1863
|
+
require_work_authority(&state)?;
|
|
1766
1864
|
authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
|
|
1767
1865
|
state
|
|
1768
1866
|
.workloads
|
|
@@ -1784,6 +1882,7 @@ async fn heartbeat_workload(
|
|
|
1784
1882
|
Path(id): Path<String>,
|
|
1785
1883
|
Json(input): Json<WorkloadHeartbeatRequest>,
|
|
1786
1884
|
) -> Result<Json<Value>, ApiError> {
|
|
1885
|
+
require_work_authority(&state)?;
|
|
1787
1886
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1788
1887
|
let claim = state
|
|
1789
1888
|
.workloads
|
|
@@ -1806,6 +1905,7 @@ async fn checkpoint_workload(
|
|
|
1806
1905
|
Path(id): Path<String>,
|
|
1807
1906
|
Json(input): Json<WorkloadCheckpointRequest>,
|
|
1808
1907
|
) -> Result<Json<Value>, ApiError> {
|
|
1908
|
+
require_work_authority(&state)?;
|
|
1809
1909
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1810
1910
|
let checkpoint = state
|
|
1811
1911
|
.workloads
|
|
@@ -1828,6 +1928,7 @@ async fn complete_workload(
|
|
|
1828
1928
|
Path(id): Path<String>,
|
|
1829
1929
|
Json(input): Json<WorkloadCompleteRequest>,
|
|
1830
1930
|
) -> Result<Json<Value>, ApiError> {
|
|
1931
|
+
require_work_authority(&state)?;
|
|
1831
1932
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1832
1933
|
let result = state
|
|
1833
1934
|
.workloads
|
|
@@ -1854,18 +1955,20 @@ async fn fail_workload(
|
|
|
1854
1955
|
Path(id): Path<String>,
|
|
1855
1956
|
Json(input): Json<WorkloadFailRequest>,
|
|
1856
1957
|
) -> Result<Json<Value>, ApiError> {
|
|
1958
|
+
require_work_authority(&state)?;
|
|
1857
1959
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1858
1960
|
let status = state
|
|
1859
1961
|
.workloads
|
|
1860
1962
|
.lock()
|
|
1861
1963
|
.unwrap()
|
|
1862
|
-
.
|
|
1964
|
+
.fail_with_effect(
|
|
1863
1965
|
&id,
|
|
1864
1966
|
&input.fence.claim_id,
|
|
1865
1967
|
&input.fence.worker_id,
|
|
1866
1968
|
input.fence.fencing_token,
|
|
1867
1969
|
&input.kind,
|
|
1868
1970
|
&input.error,
|
|
1971
|
+
input.effect.as_deref(),
|
|
1869
1972
|
unix_seconds_i64(),
|
|
1870
1973
|
)
|
|
1871
1974
|
.map_err(workload_error)?;
|
|
@@ -1878,6 +1981,7 @@ async fn confirm_workload_cancelled(
|
|
|
1878
1981
|
Path(id): Path<String>,
|
|
1879
1982
|
Json(input): Json<WorkloadFenceRequest>,
|
|
1880
1983
|
) -> Result<StatusCode, ApiError> {
|
|
1984
|
+
require_work_authority(&state)?;
|
|
1881
1985
|
authorize_fence(&state, &principal, &id, &input, "workload:execute")?;
|
|
1882
1986
|
state
|
|
1883
1987
|
.workloads
|
|
@@ -1899,6 +2003,7 @@ async fn workload_execution_context(
|
|
|
1899
2003
|
Path(id): Path<String>,
|
|
1900
2004
|
Json(input): Json<WorkloadContextRequest>,
|
|
1901
2005
|
) -> Result<Json<Value>, ApiError> {
|
|
2006
|
+
require_work_authority(&state)?;
|
|
1902
2007
|
authorize_fence(&state, &principal, &id, &input.fence, "workload:execute")?;
|
|
1903
2008
|
let context = state
|
|
1904
2009
|
.workloads
|
|
@@ -1921,11 +2026,12 @@ async fn cancel_workload(
|
|
|
1921
2026
|
Path(id): Path<String>,
|
|
1922
2027
|
Json(input): Json<WorkloadControlRequest>,
|
|
1923
2028
|
) -> Result<Json<Value>, ApiError> {
|
|
2029
|
+
require_work_authority(&state)?;
|
|
1924
2030
|
let workload = state
|
|
1925
2031
|
.workloads
|
|
1926
2032
|
.lock()
|
|
1927
2033
|
.unwrap()
|
|
1928
|
-
.
|
|
2034
|
+
.fresh(&id)
|
|
1929
2035
|
.map_err(workload_error)?
|
|
1930
2036
|
.clone();
|
|
1931
2037
|
workload_decision(
|
|
@@ -1951,11 +2057,12 @@ async fn retry_workload(
|
|
|
1951
2057
|
Path(id): Path<String>,
|
|
1952
2058
|
Json(input): Json<WorkloadControlRequest>,
|
|
1953
2059
|
) -> Result<StatusCode, ApiError> {
|
|
2060
|
+
require_work_authority(&state)?;
|
|
1954
2061
|
let workload = state
|
|
1955
2062
|
.workloads
|
|
1956
2063
|
.lock()
|
|
1957
2064
|
.unwrap()
|
|
1958
|
-
.
|
|
2065
|
+
.fresh(&id)
|
|
1959
2066
|
.map_err(workload_error)?
|
|
1960
2067
|
.clone();
|
|
1961
2068
|
workload_decision(
|
|
@@ -1971,7 +2078,7 @@ async fn retry_workload(
|
|
|
1971
2078
|
.workloads
|
|
1972
2079
|
.lock()
|
|
1973
2080
|
.unwrap()
|
|
1974
|
-
.
|
|
2081
|
+
.retry_with_reason(&id, &principal.key_id, unix_seconds_i64(), input.reason.as_deref())
|
|
1975
2082
|
.map_err(workload_error)?;
|
|
1976
2083
|
Ok(StatusCode::NO_CONTENT)
|
|
1977
2084
|
}
|
|
@@ -1984,15 +2091,10 @@ async fn workload_history(
|
|
|
1984
2091
|
.workloads
|
|
1985
2092
|
.lock()
|
|
1986
2093
|
.unwrap()
|
|
1987
|
-
.
|
|
2094
|
+
.fresh(&id)
|
|
1988
2095
|
.map_err(workload_error)?
|
|
1989
2096
|
.clone();
|
|
1990
|
-
|
|
1991
|
-
&state,
|
|
1992
|
-
&principal.key_id,
|
|
1993
|
-
&workload.application_id,
|
|
1994
|
-
"workload:read",
|
|
1995
|
-
)?;
|
|
2097
|
+
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
1996
2098
|
Ok(Json(
|
|
1997
2099
|
json!({"events":workload.execution_history,"attempts":workload.attempts,"checkpoints":workload.checkpoints}),
|
|
1998
2100
|
))
|
|
@@ -2006,15 +2108,10 @@ async fn workload_result(
|
|
|
2006
2108
|
.workloads
|
|
2007
2109
|
.lock()
|
|
2008
2110
|
.unwrap()
|
|
2009
|
-
.
|
|
2111
|
+
.fresh(&id)
|
|
2010
2112
|
.map_err(workload_error)?
|
|
2011
2113
|
.clone();
|
|
2012
|
-
|
|
2013
|
-
&state,
|
|
2014
|
-
&principal.key_id,
|
|
2015
|
-
&workload.application_id,
|
|
2016
|
-
"workload:read",
|
|
2017
|
-
)?;
|
|
2114
|
+
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
2018
2115
|
Ok(Json(json!(workload.result)))
|
|
2019
2116
|
}
|
|
2020
2117
|
|
|
@@ -2196,7 +2293,8 @@ async fn reconcile_worker(
|
|
|
2196
2293
|
) -> Result<Json<Value>, ApiError> {
|
|
2197
2294
|
authorize_worker_action(&state, &principal, &id, &input.grant, "worker:heartbeat")?;
|
|
2198
2295
|
input.reconciliation.worker_id = id;
|
|
2199
|
-
let workloads = state.workloads.lock().unwrap();
|
|
2296
|
+
let mut workloads = state.workloads.lock().unwrap();
|
|
2297
|
+
workloads.refresh().map_err(workload_error)?;
|
|
2200
2298
|
let result = state
|
|
2201
2299
|
.mesh
|
|
2202
2300
|
.lock()
|
|
@@ -2287,15 +2385,10 @@ async fn eligible_workers(
|
|
|
2287
2385
|
.workloads
|
|
2288
2386
|
.lock()
|
|
2289
2387
|
.unwrap()
|
|
2290
|
-
.
|
|
2388
|
+
.fresh(&id)
|
|
2291
2389
|
.map_err(workload_error)?
|
|
2292
2390
|
.clone();
|
|
2293
|
-
|
|
2294
|
-
&state,
|
|
2295
|
-
&principal.key_id,
|
|
2296
|
-
&workload.application_id,
|
|
2297
|
-
"workload:read",
|
|
2298
|
-
)?;
|
|
2391
|
+
workload_read_scope(&state, &principal, &workload.application_id)?;
|
|
2299
2392
|
let workers = state
|
|
2300
2393
|
.mesh
|
|
2301
2394
|
.lock()
|
|
@@ -6935,6 +7028,139 @@ async fn auth_sign_up(
|
|
|
6935
7028
|
.map_err(control_error)?;
|
|
6936
7029
|
Ok((StatusCode::CREATED, auth_response(identity, session, token)))
|
|
6937
7030
|
}
|
|
7031
|
+
#[derive(Deserialize)]
|
|
7032
|
+
struct ConsolePasswordRequest {
|
|
7033
|
+
email: String,
|
|
7034
|
+
password: String,
|
|
7035
|
+
}
|
|
7036
|
+
#[derive(Deserialize)]
|
|
7037
|
+
struct ConsoleGithubRequest {
|
|
7038
|
+
github_id: Value,
|
|
7039
|
+
login: String,
|
|
7040
|
+
}
|
|
7041
|
+
|
|
7042
|
+
fn console_account_error(error: console_accounts::AccountError) -> ApiError {
|
|
7043
|
+
ApiError::structured(
|
|
7044
|
+
StatusCode::from_u16(error.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
|
|
7045
|
+
json!({ "code": error.code, "message": error.message }),
|
|
7046
|
+
)
|
|
7047
|
+
}
|
|
7048
|
+
|
|
7049
|
+
/// Authenticate a website call to `/v1/console/accounts/*` and parse its body.
|
|
7050
|
+
fn console_request<T: serde::de::DeserializeOwned>(
|
|
7051
|
+
state: &AppState,
|
|
7052
|
+
method: &Method,
|
|
7053
|
+
uri: &axum::http::Uri,
|
|
7054
|
+
headers: &HeaderMap,
|
|
7055
|
+
body: &[u8],
|
|
7056
|
+
) -> Result<T, ApiError> {
|
|
7057
|
+
let Some(secret) = state.console_accounts.secret() else {
|
|
7058
|
+
return Err(ApiError::structured(
|
|
7059
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
7060
|
+
json!({
|
|
7061
|
+
"code": "CONSOLE_ACCOUNTS_UNCONFIGURED",
|
|
7062
|
+
"message": "console accounts require FELTDB_SESSION_SECRET (at least 32 bytes) on the control plane",
|
|
7063
|
+
}),
|
|
7064
|
+
));
|
|
7065
|
+
};
|
|
7066
|
+
let header = |name: &str| headers.get(name).and_then(|value| value.to_str().ok());
|
|
7067
|
+
let now = std::time::SystemTime::now()
|
|
7068
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
7069
|
+
.unwrap_or_default()
|
|
7070
|
+
.as_secs();
|
|
7071
|
+
if let Err(error) = console_accounts::verify_console_request(
|
|
7072
|
+
secret,
|
|
7073
|
+
method.as_str(),
|
|
7074
|
+
uri.path(),
|
|
7075
|
+
header(console_accounts::TIMESTAMP_HEADER),
|
|
7076
|
+
header(console_accounts::SIGNATURE_HEADER),
|
|
7077
|
+
body,
|
|
7078
|
+
now,
|
|
7079
|
+
) {
|
|
7080
|
+
state.metrics.authentication_failure();
|
|
7081
|
+
audit(state, "anonymous", "console.accounts", uri.path(), "denied", 401);
|
|
7082
|
+
return Err(console_account_error(error));
|
|
7083
|
+
}
|
|
7084
|
+
serde_json::from_slice(body)
|
|
7085
|
+
.map_err(|error| ApiError(StatusCode::BAD_REQUEST, format!("invalid request body: {error}")))
|
|
7086
|
+
}
|
|
7087
|
+
|
|
7088
|
+
/// Argon2 work and the account file write run off the request worker.
|
|
7089
|
+
async fn console_blocking<F>(work: F) -> Result<Json<Value>, ApiError>
|
|
7090
|
+
where
|
|
7091
|
+
F: FnOnce() -> Result<console_accounts::ResolvedAccount, console_accounts::AccountError>
|
|
7092
|
+
+ Send
|
|
7093
|
+
+ 'static,
|
|
7094
|
+
{
|
|
7095
|
+
match tokio::task::spawn_blocking(work).await {
|
|
7096
|
+
Ok(Ok(account)) => Ok(Json(json!({
|
|
7097
|
+
"user_id": account.user_id,
|
|
7098
|
+
"identity": account.identity,
|
|
7099
|
+
"display_identity": account.display_identity,
|
|
7100
|
+
}))),
|
|
7101
|
+
Ok(Err(error)) => Err(console_account_error(error)),
|
|
7102
|
+
Err(_) => Err(ApiError(
|
|
7103
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
7104
|
+
"console account operation failed".into(),
|
|
7105
|
+
)),
|
|
7106
|
+
}
|
|
7107
|
+
}
|
|
7108
|
+
|
|
7109
|
+
async fn console_password_signup(
|
|
7110
|
+
State(state): State<AppState>,
|
|
7111
|
+
method: Method,
|
|
7112
|
+
uri: axum::http::Uri,
|
|
7113
|
+
headers: HeaderMap,
|
|
7114
|
+
body: axum::body::Bytes,
|
|
7115
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
7116
|
+
let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7117
|
+
let created = console_blocking(move || {
|
|
7118
|
+
state
|
|
7119
|
+
.console_accounts
|
|
7120
|
+
.password_signup(&state.tenancy, &input.email, &input.password)
|
|
7121
|
+
})
|
|
7122
|
+
.await?;
|
|
7123
|
+
Ok((StatusCode::CREATED, created))
|
|
7124
|
+
}
|
|
7125
|
+
|
|
7126
|
+
async fn console_password_signin(
|
|
7127
|
+
State(state): State<AppState>,
|
|
7128
|
+
method: Method,
|
|
7129
|
+
uri: axum::http::Uri,
|
|
7130
|
+
headers: HeaderMap,
|
|
7131
|
+
body: axum::body::Bytes,
|
|
7132
|
+
) -> Result<Json<Value>, ApiError> {
|
|
7133
|
+
let input: ConsolePasswordRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7134
|
+
console_blocking(move || {
|
|
7135
|
+
state
|
|
7136
|
+
.console_accounts
|
|
7137
|
+
.password_signin(&input.email, &input.password)
|
|
7138
|
+
})
|
|
7139
|
+
.await
|
|
7140
|
+
}
|
|
7141
|
+
|
|
7142
|
+
async fn console_github_resolve(
|
|
7143
|
+
State(state): State<AppState>,
|
|
7144
|
+
method: Method,
|
|
7145
|
+
uri: axum::http::Uri,
|
|
7146
|
+
headers: HeaderMap,
|
|
7147
|
+
body: axum::body::Bytes,
|
|
7148
|
+
) -> Result<Json<Value>, ApiError> {
|
|
7149
|
+
let input: ConsoleGithubRequest = console_request(&state, &method, &uri, &headers, &body)?;
|
|
7150
|
+
// GitHub user ids are numbers; accept the number or its decimal string.
|
|
7151
|
+
let github_id = match &input.github_id {
|
|
7152
|
+
Value::Number(number) if number.is_u64() => number.to_string(),
|
|
7153
|
+
Value::String(text) => text.clone(),
|
|
7154
|
+
_ => String::new(),
|
|
7155
|
+
};
|
|
7156
|
+
console_blocking(move || {
|
|
7157
|
+
state
|
|
7158
|
+
.console_accounts
|
|
7159
|
+
.github_resolve(&state.tenancy, &github_id, &input.login)
|
|
7160
|
+
})
|
|
7161
|
+
.await
|
|
7162
|
+
}
|
|
7163
|
+
|
|
6938
7164
|
async fn auth_sign_in(
|
|
6939
7165
|
State(state): State<AppState>,
|
|
6940
7166
|
Json(input): Json<SignInRequest>,
|
|
@@ -7573,7 +7799,9 @@ fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError
|
|
|
7573
7799
|
"PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
|
|
7574
7800
|
"UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
|
|
7575
7801
|
"SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
|
|
7576
|
-
"STORAGE_FAILURE" | "STATE_UNAVAILABLE" =>
|
|
7802
|
+
"STORAGE_FAILURE" | "STATE_UNAVAILABLE" | "DURABILITY_FAILURE" => {
|
|
7803
|
+
StatusCode::SERVICE_UNAVAILABLE
|
|
7804
|
+
}
|
|
7577
7805
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
7578
7806
|
};
|
|
7579
7807
|
ApiError(
|
|
@@ -8210,6 +8438,10 @@ struct SyncPullResponse {
|
|
|
8210
8438
|
operations: Vec<Operation>,
|
|
8211
8439
|
versions: HashMap<String, u64>,
|
|
8212
8440
|
catchup: CatchupStatus,
|
|
8441
|
+
/// Whether the answering node is configured as the Durable Work authority
|
|
8442
|
+
/// (`--work-authority`). Absent from older peers.
|
|
8443
|
+
#[serde(default)]
|
|
8444
|
+
durable_work_authority: bool,
|
|
8213
8445
|
}
|
|
8214
8446
|
|
|
8215
8447
|
#[derive(Serialize, Deserialize)]
|
|
@@ -8418,6 +8650,10 @@ struct StartAgentRun {
|
|
|
8418
8650
|
goal: String,
|
|
8419
8651
|
#[serde(default)]
|
|
8420
8652
|
input: Value,
|
|
8653
|
+
/// What the runtime must observe for the run to complete. Without it the
|
|
8654
|
+
/// run can execute but never completes: the goal text is not evaluated.
|
|
8655
|
+
#[serde(default)]
|
|
8656
|
+
completion: Option<CompletionCriteria>,
|
|
8421
8657
|
}
|
|
8422
8658
|
|
|
8423
8659
|
#[derive(Deserialize)]
|
|
@@ -8454,6 +8690,7 @@ struct Config {
|
|
|
8454
8690
|
audit: PathBuf,
|
|
8455
8691
|
allowed_origins: Vec<axum::http::HeaderValue>,
|
|
8456
8692
|
worker_enabled: bool,
|
|
8693
|
+
work_authority: bool,
|
|
8457
8694
|
advertise_url: Option<String>,
|
|
8458
8695
|
maximum_clock_drift_ms: u64,
|
|
8459
8696
|
}
|
|
@@ -8502,6 +8739,7 @@ impl Config {
|
|
|
8502
8739
|
.transpose()?
|
|
8503
8740
|
.unwrap_or_default();
|
|
8504
8741
|
let mut worker_enabled = std::env::var("FELTDB_WORKER_ENABLED").as_deref() == Ok("true");
|
|
8742
|
+
let mut work_authority = std::env::var("FELTDB_WORK_AUTHORITY").as_deref() == Ok("true");
|
|
8505
8743
|
let mut advertise_url = std::env::var("FELTDB_ADVERTISE_URL")
|
|
8506
8744
|
.ok()
|
|
8507
8745
|
.map(|value| value.trim_end_matches('/').to_string());
|
|
@@ -8554,6 +8792,7 @@ impl Config {
|
|
|
8554
8792
|
.map_err(|_| "invalid --allow-origin".to_string())?,
|
|
8555
8793
|
),
|
|
8556
8794
|
"--worker" => worker_enabled = true,
|
|
8795
|
+
"--work-authority" => work_authority = true,
|
|
8557
8796
|
"--advertise-url" => {
|
|
8558
8797
|
advertise_url = Some(value()?.trim_end_matches('/').to_string())
|
|
8559
8798
|
}
|
|
@@ -8568,7 +8807,7 @@ impl Config {
|
|
|
8568
8807
|
}
|
|
8569
8808
|
}
|
|
8570
8809
|
"--help" | "-h" => {
|
|
8571
|
-
println!("feltdb-server [--host 127.0.0.1] [--port 7700] [--advertise-url https://node.example] [--namespace default] [--data ./data/feltdb.log] [--auth] [--keys ./data/api-keys.json] [--audit ./data/feltdb.audit.log] [--allow-origin https://app.example] [--peer https://peer] [--peer-token-env FELTDB_PEER_TOKEN] [--sync-interval-ms 2000] [--worker]");
|
|
8810
|
+
println!("feltdb-server [--host 127.0.0.1] [--port 7700] [--advertise-url https://node.example] [--namespace default] [--data ./data/feltdb.log] [--auth] [--keys ./data/api-keys.json] [--audit ./data/feltdb.audit.log] [--allow-origin https://app.example] [--peer https://peer] [--peer-token-env FELTDB_PEER_TOKEN] [--sync-interval-ms 2000] [--worker] [--work-authority]");
|
|
8572
8811
|
std::process::exit(0);
|
|
8573
8812
|
}
|
|
8574
8813
|
_ => return Err(format!("unknown argument: {argument}")),
|
|
@@ -8591,6 +8830,7 @@ impl Config {
|
|
|
8591
8830
|
audit,
|
|
8592
8831
|
allowed_origins,
|
|
8593
8832
|
worker_enabled,
|
|
8833
|
+
work_authority,
|
|
8594
8834
|
advertise_url,
|
|
8595
8835
|
maximum_clock_drift_ms,
|
|
8596
8836
|
})
|
|
@@ -8661,10 +8901,28 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
|
|
|
8661
8901
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
8662
8902
|
tracing_subscriber::fmt()
|
|
8663
8903
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
8904
|
+
// Every log line passes through credential redaction, whatever wrote it.
|
|
8905
|
+
.with_writer(feltdb_server::redaction::RedactingStdout)
|
|
8664
8906
|
.init();
|
|
8907
|
+
if matches!(
|
|
8908
|
+
std::env::args().nth(1).as_deref(),
|
|
8909
|
+
Some("version" | "--version" | "-V")
|
|
8910
|
+
) {
|
|
8911
|
+
// One line an operator script can print instead of a hard-coded banner.
|
|
8912
|
+
println!(
|
|
8913
|
+
"feltdb-server {} (release {}, commit {})",
|
|
8914
|
+
env!("CARGO_PKG_VERSION"),
|
|
8915
|
+
option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
|
|
8916
|
+
option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown")
|
|
8917
|
+
);
|
|
8918
|
+
return Ok(());
|
|
8919
|
+
}
|
|
8665
8920
|
if manage_keys()? {
|
|
8666
8921
|
return Ok(());
|
|
8667
8922
|
}
|
|
8923
|
+
if manage_console_accounts()? {
|
|
8924
|
+
return Ok(());
|
|
8925
|
+
}
|
|
8668
8926
|
if manage_authorization()? {
|
|
8669
8927
|
return Ok(());
|
|
8670
8928
|
}
|
|
@@ -8801,6 +9059,8 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8801
9059
|
lifecycle_lock: Arc::new(tokio::sync::Mutex::new(())),
|
|
8802
9060
|
state_transaction_admission: Arc::new(tokio::sync::Semaphore::new(2)),
|
|
8803
9061
|
worker_enabled: config.worker_enabled,
|
|
9062
|
+
work_authority: config.work_authority,
|
|
9063
|
+
work_authority_conflict: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
|
8804
9064
|
leases: LeaseStore::load(config.data.with_extension("leases.json"))?,
|
|
8805
9065
|
content: content_store,
|
|
8806
9066
|
artifacts: artifact_store,
|
|
@@ -8822,6 +9082,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8822
9082
|
.into_bytes(),
|
|
8823
9083
|
config.data.with_extension("revoked-sessions.json"),
|
|
8824
9084
|
)?,
|
|
9085
|
+
console_accounts: ConsoleAccountStore::load(
|
|
9086
|
+
config.data.with_extension("console-accounts.json"),
|
|
9087
|
+
console_accounts::configured_secret(std::env::var("FELTDB_SESSION_SECRET").ok()),
|
|
9088
|
+
)?,
|
|
8825
9089
|
principals: principal_authority,
|
|
8826
9090
|
connections: ConnectionStore::load_with_provider(
|
|
8827
9091
|
config.data.with_extension("connections.json"),
|
|
@@ -8846,6 +9110,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8846
9110
|
};
|
|
8847
9111
|
start_peer_sessions(&state, &config)?;
|
|
8848
9112
|
start_membership_recovery(&state);
|
|
9113
|
+
start_workload_recovery(&state);
|
|
8849
9114
|
start_autonomous_worker(&state);
|
|
8850
9115
|
|
|
8851
9116
|
let protected = Router::new()
|
|
@@ -8909,6 +9174,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8909
9174
|
.route("/api/tenants/{tenant_id}", get(get_tenant))
|
|
8910
9175
|
.route("/api/keys", get(list_keys).post(create_key))
|
|
8911
9176
|
.route("/api/keys/{id}", axum::routing::delete(revoke_key))
|
|
9177
|
+
.route("/api/keys/{id}/rotate", axum::routing::post(rotate_key))
|
|
9178
|
+
// Operator observability: authenticated, `operator:read`.
|
|
9179
|
+
.route("/metrics", get(network_metrics))
|
|
9180
|
+
.route("/v1/status", get(operator_status))
|
|
8912
9181
|
.route(
|
|
8913
9182
|
"/api/tenants/{tenant_id}/applications",
|
|
8914
9183
|
get(list_applications).post(create_application_control),
|
|
@@ -9569,23 +9838,23 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9569
9838
|
.route("/v1/openapi.json", get(application_openapi))
|
|
9570
9839
|
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
9571
9840
|
.route("/v1/auth/signin", axum::routing::post(auth_sign_in))
|
|
9572
|
-
|
|
9841
|
+
// Console accounts: callable only by the website, which signs each
|
|
9842
|
+
// request with FELTDB_SESSION_SECRET (see console_accounts.rs).
|
|
9573
9843
|
.route(
|
|
9574
|
-
"/
|
|
9575
|
-
axum::routing::post(
|
|
9844
|
+
"/v1/console/accounts/password/signup",
|
|
9845
|
+
axum::routing::post(console_password_signup),
|
|
9576
9846
|
)
|
|
9577
|
-
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
9578
9847
|
.route(
|
|
9579
|
-
"/
|
|
9580
|
-
axum::routing::post(
|
|
9848
|
+
"/v1/console/accounts/password/signin",
|
|
9849
|
+
axum::routing::post(console_password_signin),
|
|
9581
9850
|
)
|
|
9582
|
-
.route("/ready", get(readiness))
|
|
9583
|
-
.route("/runtime", get(runtime))
|
|
9584
|
-
.route("/metrics", get(network_metrics))
|
|
9585
9851
|
.route(
|
|
9586
|
-
"/
|
|
9587
|
-
axum::routing::
|
|
9852
|
+
"/v1/console/accounts/github/resolve",
|
|
9853
|
+
axum::routing::post(console_github_resolve),
|
|
9588
9854
|
)
|
|
9855
|
+
.route("/ready", get(readiness))
|
|
9856
|
+
.route("/runtime", get(runtime))
|
|
9857
|
+
.merge(service_secret_routes())
|
|
9589
9858
|
.merge(protected)
|
|
9590
9859
|
.merge(query_execution_diagnostics_router())
|
|
9591
9860
|
.layer(middleware::from_fn_with_state(state.clone(), admit_request))
|
|
@@ -9593,7 +9862,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9593
9862
|
.layer(middleware::from_fn(attribute_request))
|
|
9594
9863
|
.layer(middleware::from_fn_with_state(state.clone(), count_request))
|
|
9595
9864
|
.layer(RequestBodyLimitLayer::new(1024 * 1024))
|
|
9596
|
-
.layer(
|
|
9865
|
+
.layer(middleware::from_fn(redact_error_responses))
|
|
9866
|
+
// Spans carry the path, never the query string, where clients put tokens.
|
|
9867
|
+
.layer(TraceLayer::new_for_http().make_span_with(
|
|
9868
|
+
|request: &Request<axum::body::Body>| {
|
|
9869
|
+
tracing::debug_span!(
|
|
9870
|
+
"request",
|
|
9871
|
+
method = %request.method(),
|
|
9872
|
+
path = %request.uri().path(),
|
|
9873
|
+
)
|
|
9874
|
+
},
|
|
9875
|
+
))
|
|
9597
9876
|
.with_state(state);
|
|
9598
9877
|
if !config.allowed_origins.is_empty() {
|
|
9599
9878
|
app = app.layer(
|
|
@@ -9637,6 +9916,32 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
9637
9916
|
Ok(())
|
|
9638
9917
|
}
|
|
9639
9918
|
|
|
9919
|
+
/// Error bodies can quote client input (axum's JSON rejections echo the
|
|
9920
|
+
/// offending value); a credential sent by mistake must not come back out, or
|
|
9921
|
+
/// land in whatever logs the response. Success bodies are untouched: a key's
|
|
9922
|
+
/// secret is returned exactly once, at creation or rotation.
|
|
9923
|
+
async fn redact_error_responses(request: Request<axum::body::Body>, next: Next) -> Response {
|
|
9924
|
+
let response = next.run(request).await;
|
|
9925
|
+
if response.status().as_u16() < 400 {
|
|
9926
|
+
return response;
|
|
9927
|
+
}
|
|
9928
|
+
let (parts, body) = response.into_parts();
|
|
9929
|
+
let bytes = match axum::body::to_bytes(body, 1024 * 1024).await {
|
|
9930
|
+
Ok(bytes) => bytes,
|
|
9931
|
+
Err(_) => return Response::from_parts(parts, axum::body::Body::empty()),
|
|
9932
|
+
};
|
|
9933
|
+
let body = match std::str::from_utf8(&bytes) {
|
|
9934
|
+
Ok(text) => match feltdb_server::redaction::redact(text) {
|
|
9935
|
+
std::borrow::Cow::Owned(redacted) => axum::body::Body::from(redacted),
|
|
9936
|
+
std::borrow::Cow::Borrowed(_) => axum::body::Body::from(bytes),
|
|
9937
|
+
},
|
|
9938
|
+
Err(_) => axum::body::Body::from(bytes),
|
|
9939
|
+
};
|
|
9940
|
+
let mut parts = parts;
|
|
9941
|
+
parts.headers.remove(axum::http::header::CONTENT_LENGTH);
|
|
9942
|
+
Response::from_parts(parts, body)
|
|
9943
|
+
}
|
|
9944
|
+
|
|
9640
9945
|
async fn count_request(
|
|
9641
9946
|
State(state): State<AppState>,
|
|
9642
9947
|
request: Request<axum::body::Body>,
|
|
@@ -9669,7 +9974,7 @@ async fn authenticate(
|
|
|
9669
9974
|
// an answer to PR36's question — which part of the request does is.
|
|
9670
9975
|
let authorization =
|
|
9671
9976
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::Authorization);
|
|
9672
|
-
let principal = if state.auth_enabled {
|
|
9977
|
+
let (principal, class) = if state.auth_enabled {
|
|
9673
9978
|
let token = request
|
|
9674
9979
|
.headers()
|
|
9675
9980
|
.get(AUTHORIZATION)
|
|
@@ -9732,8 +10037,8 @@ async fn authenticate(
|
|
|
9732
10037
|
})
|
|
9733
10038
|
.and_then(|token| state.sessions.verify(token));
|
|
9734
10039
|
match (machine, actor, human) {
|
|
9735
|
-
(Some(principal), _, _) => principal,
|
|
9736
|
-
(None, Some(session), _) => Principal {
|
|
10040
|
+
(Some(principal), _, _) => (principal, PrincipalClass::Machine),
|
|
10041
|
+
(None, Some(session), _) => (Principal {
|
|
9737
10042
|
key_id: session.subject_id,
|
|
9738
10043
|
scopes: vec![
|
|
9739
10044
|
"application:read".into(),
|
|
@@ -9748,24 +10053,55 @@ async fn authenticate(
|
|
|
9748
10053
|
subject_type: "human".into(),
|
|
9749
10054
|
identity_id: Some(session.identity_id),
|
|
9750
10055
|
session_id: Some(session.session_id),
|
|
9751
|
-
},
|
|
10056
|
+
}, PrincipalClass::EndUser),
|
|
9752
10057
|
(None, None, Some(session)) => {
|
|
9753
|
-
|
|
9754
|
-
|
|
9755
|
-
|
|
9756
|
-
|
|
9757
|
-
|
|
10058
|
+
// A signed cookie is not enough: its user id must belong to a
|
|
10059
|
+
// console account (created through the verified account
|
|
10060
|
+
// routes, or bound/claimed for a pre-0.15.0 user), and its
|
|
10061
|
+
// identity must be that account's. Sessions the website
|
|
10062
|
+
// minted before accounts existed are refused here.
|
|
10063
|
+
let account = match state.console_accounts.account_for_user(&session.user_id) {
|
|
10064
|
+
Some(account) if account.display_identity == session.identity.trim().to_lowercase() => account,
|
|
10065
|
+
_ => {
|
|
10066
|
+
state.metrics.authentication_failure();
|
|
10067
|
+
audit(
|
|
10068
|
+
&state,
|
|
10069
|
+
&session.user_id,
|
|
10070
|
+
"authenticate",
|
|
10071
|
+
request.uri().path(),
|
|
10072
|
+
"denied",
|
|
10073
|
+
401,
|
|
10074
|
+
);
|
|
10075
|
+
return ApiError::structured(
|
|
10076
|
+
StatusCode::UNAUTHORIZED,
|
|
10077
|
+
json!({
|
|
10078
|
+
"code": "CONSOLE_ACCOUNT_REQUIRED",
|
|
10079
|
+
"message": "this console session is not bound to a console account; sign in again",
|
|
10080
|
+
}),
|
|
10081
|
+
)
|
|
10082
|
+
.into_response();
|
|
10083
|
+
}
|
|
10084
|
+
};
|
|
10085
|
+
if (!state.tenancy.platform_user_exists(&account.user_id)
|
|
10086
|
+
&& state
|
|
10087
|
+
.tenancy
|
|
10088
|
+
.register_platform_user(&account.user_id, &account.display_identity)
|
|
10089
|
+
.is_err())
|
|
10090
|
+
|| !state.tenancy.platform_user_active(&account.user_id)
|
|
9758
10091
|
{
|
|
9759
10092
|
return ApiError(StatusCode::FORBIDDEN, "platform user is suspended".into())
|
|
9760
10093
|
.into_response();
|
|
9761
10094
|
}
|
|
9762
|
-
|
|
9763
|
-
|
|
9764
|
-
|
|
9765
|
-
|
|
9766
|
-
|
|
9767
|
-
|
|
9768
|
-
|
|
10095
|
+
(
|
|
10096
|
+
Principal {
|
|
10097
|
+
key_id: account.user_id,
|
|
10098
|
+
scopes: vec![],
|
|
10099
|
+
subject_type: "human".into(),
|
|
10100
|
+
identity_id: Some(account.display_identity),
|
|
10101
|
+
session_id: Some(session.id),
|
|
10102
|
+
},
|
|
10103
|
+
PrincipalClass::ConsoleUser,
|
|
10104
|
+
)
|
|
9769
10105
|
}
|
|
9770
10106
|
(None, None, None) => {
|
|
9771
10107
|
state.metrics.authentication_failure();
|
|
@@ -9785,17 +10121,43 @@ async fn authenticate(
|
|
|
9785
10121
|
}
|
|
9786
10122
|
}
|
|
9787
10123
|
} else {
|
|
9788
|
-
|
|
9789
|
-
|
|
9790
|
-
|
|
9791
|
-
|
|
9792
|
-
|
|
9793
|
-
|
|
9794
|
-
|
|
10124
|
+
(
|
|
10125
|
+
Principal {
|
|
10126
|
+
key_id: "development".to_string(),
|
|
10127
|
+
scopes: vec!["*".to_string()],
|
|
10128
|
+
subject_type: "service".into(),
|
|
10129
|
+
identity_id: None,
|
|
10130
|
+
session_id: None,
|
|
10131
|
+
},
|
|
10132
|
+
PrincipalClass::Development,
|
|
10133
|
+
)
|
|
9795
10134
|
};
|
|
9796
10135
|
|
|
9797
10136
|
let path = request.uri().path();
|
|
9798
|
-
|
|
10137
|
+
// The control plane (`/api/*`) is decided per route, before any handler:
|
|
10138
|
+
// machine keys need the route's capability, console users are held to
|
|
10139
|
+
// tenant/application membership by the handler, and application end-user
|
|
10140
|
+
// sessions are refused. There is no path family without a decision.
|
|
10141
|
+
let control = control_authorization::control_plane_requirement(request.method(), path);
|
|
10142
|
+
if let Some(requirement) = control {
|
|
10143
|
+
if let Err(denial) = control_authorization::decide(class, &principal.scopes, requirement) {
|
|
10144
|
+
state.metrics.authentication_failure();
|
|
10145
|
+
let action = match requirement {
|
|
10146
|
+
control_authorization::Requirement::Capability(scope) => scope,
|
|
10147
|
+
_ => "control-plane",
|
|
10148
|
+
};
|
|
10149
|
+
audit(&state, &principal.key_id, action, path, "denied", 403);
|
|
10150
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
10151
|
+
}
|
|
10152
|
+
}
|
|
10153
|
+
let required = if let Some(requirement) = control {
|
|
10154
|
+
match requirement {
|
|
10155
|
+
control_authorization::Requirement::Capability(scope) => scope,
|
|
10156
|
+
_ => "session:self",
|
|
10157
|
+
}
|
|
10158
|
+
} else if path == "/metrics" || path == "/v1/status" {
|
|
10159
|
+
control_authorization::OPERATOR_READ
|
|
10160
|
+
} else if path == "/events" {
|
|
9799
10161
|
"events:read"
|
|
9800
10162
|
} else if path == "/v1/state/diff"
|
|
9801
10163
|
|| path == "/v1/state/conflicts"
|
|
@@ -9855,6 +10217,24 @@ async fn authenticate(
|
|
|
9855
10217
|
} else {
|
|
9856
10218
|
"state:write"
|
|
9857
10219
|
};
|
|
10220
|
+
// An end-user session belongs to one application (self-serve sign-up
|
|
10221
|
+
// registers it there). Its state scopes are meaningful only on the
|
|
10222
|
+
// application API, which checks that membership per request. The legacy
|
|
10223
|
+
// routes (`/collections`, `/transactions`, `/cas`, `/events`, ...) are
|
|
10224
|
+
// scoped to the whole server namespace, so a session there would read and
|
|
10225
|
+
// write every application's records.
|
|
10226
|
+
if class == PrincipalClass::EndUser && !path.starts_with("/v1/") {
|
|
10227
|
+
state.metrics.authentication_failure();
|
|
10228
|
+
audit(&state, &principal.key_id, required, path, "denied", 403);
|
|
10229
|
+
let denial = control_authorization::Denial::new(
|
|
10230
|
+
control_authorization::END_USER_SESSION_FORBIDDEN,
|
|
10231
|
+
format!(
|
|
10232
|
+
"end-user sessions can only use the application API (/v1/...) of their own application; {path} is a namespace-wide route — use an application-scoped service key on the server side"
|
|
10233
|
+
),
|
|
10234
|
+
)
|
|
10235
|
+
.with_scope(required);
|
|
10236
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
10237
|
+
}
|
|
9858
10238
|
let subject = if principal.subject_type == "human" {
|
|
9859
10239
|
Subject::User(principal.key_id.clone())
|
|
9860
10240
|
} else {
|
|
@@ -9872,16 +10252,26 @@ async fn authenticate(
|
|
|
9872
10252
|
let human_contract = principal.subject_type == "human"
|
|
9873
10253
|
&& path.starts_with("/v1/applications/")
|
|
9874
10254
|
&& path.split('/').any(|segment| segment == "contract");
|
|
10255
|
+
// Two non-control routes are decided by who is asking rather than by a
|
|
10256
|
+
// scope, and are listed here rather than exempted: a user's own contract
|
|
10257
|
+
// drafts (keyed by that user) and the authenticated diagnostics summary.
|
|
9875
10258
|
let authenticated_diagnostics = path == "/v1/diagnostics";
|
|
9876
|
-
|
|
9877
|
-
|
|
9878
|
-
&& !authenticated_diagnostics
|
|
9879
|
-
&& !decision.allowed
|
|
9880
|
-
{
|
|
10259
|
+
let decided_by_route = control.is_some() || human_contract || authenticated_diagnostics;
|
|
10260
|
+
if !decided_by_route && !decision.allowed {
|
|
9881
10261
|
state.metrics.authentication_failure();
|
|
9882
10262
|
audit(&state, &principal.key_id, required, path, "denied", 403);
|
|
9883
|
-
|
|
9884
|
-
.
|
|
10263
|
+
let denial = if class == PrincipalClass::EndUser
|
|
10264
|
+
&& (path == "/metrics" || path == "/v1/status" || path.starts_with("/admin/"))
|
|
10265
|
+
{
|
|
10266
|
+
control_authorization::Denial::new(
|
|
10267
|
+
control_authorization::END_USER_SESSION_FORBIDDEN,
|
|
10268
|
+
format!("missing scope {required} — end-user sessions cannot use operator routes; use an operator key"),
|
|
10269
|
+
)
|
|
10270
|
+
.with_scope(required)
|
|
10271
|
+
} else {
|
|
10272
|
+
control_authorization::Denial::missing_scope(required)
|
|
10273
|
+
};
|
|
10274
|
+
return ApiError(StatusCode::FORBIDDEN, denial.body()).into_response();
|
|
9885
10275
|
}
|
|
9886
10276
|
if path == "/events" {
|
|
9887
10277
|
state.metrics.event_connection();
|
|
@@ -9900,6 +10290,7 @@ async fn authenticate(
|
|
|
9900
10290
|
|
|
9901
10291
|
request.extensions_mut().insert(principal.clone());
|
|
9902
10292
|
request.extensions_mut().insert(authenticated.clone());
|
|
10293
|
+
request.extensions_mut().insert(class);
|
|
9903
10294
|
|
|
9904
10295
|
let principal_id = request
|
|
9905
10296
|
.extensions()
|
|
@@ -10245,14 +10636,14 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
10245
10636
|
capability_fingerprint: control_plane::public_contract_capability_fingerprint(),
|
|
10246
10637
|
runtime: "self-hosted",
|
|
10247
10638
|
storage: database.storage.label(),
|
|
10248
|
-
fabric
|
|
10639
|
+
// `fabric`, `workflows` and `agents` were constant "healthy" /
|
|
10640
|
+
// "available" here whatever their state (GAP-10). Nothing checks them,
|
|
10641
|
+
// so nothing reports them.
|
|
10249
10642
|
execution: if state.worker_enabled {
|
|
10250
10643
|
"autonomous"
|
|
10251
10644
|
} else {
|
|
10252
10645
|
"state-coordinated"
|
|
10253
10646
|
},
|
|
10254
|
-
workflows: "available",
|
|
10255
|
-
agents: "available",
|
|
10256
10647
|
membership: if clock {
|
|
10257
10648
|
membership
|
|
10258
10649
|
} else {
|
|
@@ -10390,10 +10781,19 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10390
10781
|
values("--namespace", "default"),
|
|
10391
10782
|
option("--worker"),
|
|
10392
10783
|
)?;
|
|
10393
|
-
|
|
10394
|
-
|
|
10395
|
-
|
|
10396
|
-
)
|
|
10784
|
+
// `--secret-file <path>` writes the secret to an owner-only file
|
|
10785
|
+
// and prints only the id, so a bootstrap script never has to put a
|
|
10786
|
+
// credential on stdout (and from there into platform logs).
|
|
10787
|
+
match option("--secret-file") {
|
|
10788
|
+
Some(file) => {
|
|
10789
|
+
write_secret_file(std::path::Path::new(&file), &key.secret)?;
|
|
10790
|
+
println!("API key created (id: {}). Secret written to {file} (mode 0600).", key.id);
|
|
10791
|
+
}
|
|
10792
|
+
None => println!(
|
|
10793
|
+
"API key created (id: {}). This secret will not be shown again:\n{}",
|
|
10794
|
+
key.id, key.secret
|
|
10795
|
+
),
|
|
10796
|
+
}
|
|
10397
10797
|
}
|
|
10398
10798
|
"list" => println!("{}", serde_json::to_string_pretty(&store.list()?)?),
|
|
10399
10799
|
"revoke" => {
|
|
@@ -10403,11 +10803,93 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10403
10803
|
}
|
|
10404
10804
|
println!("API key revoked: {id}");
|
|
10405
10805
|
}
|
|
10406
|
-
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--id key-id]".into()),
|
|
10806
|
+
_ => return Err("usage: feltdb-server keys <create|list|revoke> [--keys path] [--name name] [--scope scopes] [--namespace names] [--worker worker-id] [--secret-file path] [--id key-id]".into()),
|
|
10807
|
+
}
|
|
10808
|
+
Ok(true)
|
|
10809
|
+
}
|
|
10810
|
+
|
|
10811
|
+
/// `feltdb-server console-accounts <claim|list> --data <state.log> ...`
|
|
10812
|
+
///
|
|
10813
|
+
/// `claim --email <email> --password-file <file>` binds a console password
|
|
10814
|
+
/// account to the existing pre-0.15.0 platform user registered under that
|
|
10815
|
+
/// e-mail, so its owner keeps their tenants. Before 0.15.0 the website did not
|
|
10816
|
+
/// verify passwords, so such a user cannot be claimed by self-service sign-up;
|
|
10817
|
+
/// the operator verifies the person out of band and runs this. The password
|
|
10818
|
+
/// is read from a file (first line) so it never appears in the process list.
|
|
10819
|
+
fn manage_console_accounts() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10820
|
+
let arguments: Vec<String> = std::env::args().collect();
|
|
10821
|
+
if arguments.get(1).map(String::as_str) != Some("console-accounts") {
|
|
10822
|
+
return Ok(false);
|
|
10823
|
+
}
|
|
10824
|
+
let usage = "usage: feltdb-server console-accounts <claim|list> --data <state.log path> [--email email --password-file path]";
|
|
10825
|
+
let option = |name: &str| {
|
|
10826
|
+
arguments
|
|
10827
|
+
.iter()
|
|
10828
|
+
.position(|argument| argument == name)
|
|
10829
|
+
.and_then(|index| arguments.get(index + 1))
|
|
10830
|
+
.cloned()
|
|
10831
|
+
};
|
|
10832
|
+
let data = PathBuf::from(
|
|
10833
|
+
option("--data").unwrap_or_else(|| "./data/feltdb.log".to_string()),
|
|
10834
|
+
);
|
|
10835
|
+
let accounts = ConsoleAccountStore::load(data.with_extension("console-accounts.json"), None)?;
|
|
10836
|
+
let tenancy = TenancyStore::load(data.with_extension("platform.json"))?;
|
|
10837
|
+
match arguments.get(2).map(String::as_str).unwrap_or("") {
|
|
10838
|
+
"claim" => {
|
|
10839
|
+
let email = option("--email").ok_or("console-accounts claim requires --email")?;
|
|
10840
|
+
let file = option("--password-file")
|
|
10841
|
+
.ok_or("console-accounts claim requires --password-file")?;
|
|
10842
|
+
let password = std::fs::read_to_string(&file)?
|
|
10843
|
+
.lines()
|
|
10844
|
+
.next()
|
|
10845
|
+
.unwrap_or_default()
|
|
10846
|
+
.to_string();
|
|
10847
|
+
let claimed = accounts
|
|
10848
|
+
.claim_password(&tenancy, &email, &password)
|
|
10849
|
+
.map_err(|error| format!("{}: {}", error.code, error.message))?;
|
|
10850
|
+
println!(
|
|
10851
|
+
"Console account claimed: {} is bound to existing platform user {}.",
|
|
10852
|
+
claimed.identity, claimed.user_id
|
|
10853
|
+
);
|
|
10854
|
+
}
|
|
10855
|
+
"list" => {
|
|
10856
|
+
let listed: Vec<Value> = accounts
|
|
10857
|
+
.list()
|
|
10858
|
+
.into_iter()
|
|
10859
|
+
.map(|account| {
|
|
10860
|
+
json!({
|
|
10861
|
+
"identity": account.identity,
|
|
10862
|
+
"user_id": account.user_id,
|
|
10863
|
+
"provider": account.provider,
|
|
10864
|
+
"display_identity": account.display_identity,
|
|
10865
|
+
"origin": account.origin,
|
|
10866
|
+
"created_at": account.created_at,
|
|
10867
|
+
})
|
|
10868
|
+
})
|
|
10869
|
+
.collect();
|
|
10870
|
+
println!("{}", serde_json::to_string_pretty(&listed)?);
|
|
10871
|
+
}
|
|
10872
|
+
_ => return Err(usage.into()),
|
|
10407
10873
|
}
|
|
10408
10874
|
Ok(true)
|
|
10409
10875
|
}
|
|
10410
10876
|
|
|
10877
|
+
/// Create `path` owner-only (0600) and write `secret` to it; refuses to
|
|
10878
|
+
/// overwrite, so a second bootstrap never silently replaces a delivered secret.
|
|
10879
|
+
fn write_secret_file(path: &std::path::Path, secret: &str) -> std::io::Result<()> {
|
|
10880
|
+
let mut options = OpenOptions::new();
|
|
10881
|
+
options.write(true).create_new(true);
|
|
10882
|
+
#[cfg(unix)]
|
|
10883
|
+
{
|
|
10884
|
+
use std::os::unix::fs::OpenOptionsExt;
|
|
10885
|
+
options.mode(0o600);
|
|
10886
|
+
}
|
|
10887
|
+
let mut file = options.open(path)?;
|
|
10888
|
+
file.write_all(secret.as_bytes())?;
|
|
10889
|
+
file.write_all(b"\n")?;
|
|
10890
|
+
file.sync_all()
|
|
10891
|
+
}
|
|
10892
|
+
|
|
10411
10893
|
fn manage_authorization() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10412
10894
|
let arguments: Vec<String> = std::env::args().collect();
|
|
10413
10895
|
let family = arguments.get(1).map(String::as_str).unwrap_or("");
|
|
@@ -10934,6 +11416,37 @@ fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10934
11416
|
Ok(true)
|
|
10935
11417
|
}
|
|
10936
11418
|
|
|
11419
|
+
/// Where an online backup bundle may be written.
|
|
11420
|
+
///
|
|
11421
|
+
/// With `FELTDB_BACKUP_DIR` set (the managed deployments set it), the request
|
|
11422
|
+
/// names a bundle, not a path: a single relative component placed inside that
|
|
11423
|
+
/// directory. Without it the server is self-hosted and the operator's path is
|
|
11424
|
+
/// used as given, as before.
|
|
11425
|
+
fn backup_output_path(
|
|
11426
|
+
requested: &str,
|
|
11427
|
+
backup_dir: Option<std::ffi::OsString>,
|
|
11428
|
+
) -> Result<PathBuf, ApiError> {
|
|
11429
|
+
let Some(root) = backup_dir.filter(|value| !value.is_empty()) else {
|
|
11430
|
+
return Ok(PathBuf::from(requested));
|
|
11431
|
+
};
|
|
11432
|
+
let name = std::path::Path::new(requested);
|
|
11433
|
+
let mut components = name.components();
|
|
11434
|
+
let single = matches!(
|
|
11435
|
+
(components.next(), components.next()),
|
|
11436
|
+
(Some(std::path::Component::Normal(_)), None)
|
|
11437
|
+
);
|
|
11438
|
+
if !single {
|
|
11439
|
+
return Err(ApiError(
|
|
11440
|
+
StatusCode::BAD_REQUEST,
|
|
11441
|
+
format!(
|
|
11442
|
+
"backup output must be a bundle name, not a path: this server writes bundles inside FELTDB_BACKUP_DIR ({}); send e.g. {{\"output\":\"backup-2026-10-28\"}}",
|
|
11443
|
+
std::path::Path::new(&root).display()
|
|
11444
|
+
),
|
|
11445
|
+
));
|
|
11446
|
+
}
|
|
11447
|
+
Ok(PathBuf::from(root).join(name))
|
|
11448
|
+
}
|
|
11449
|
+
|
|
10937
11450
|
async fn create_online_backup(
|
|
10938
11451
|
State(state): State<AppState>,
|
|
10939
11452
|
Json(request): Json<OnlineBackupRequest>,
|
|
@@ -10963,7 +11476,7 @@ async fn create_online_backup(
|
|
|
10963
11476
|
})
|
|
10964
11477
|
.cloned()
|
|
10965
11478
|
.collect();
|
|
10966
|
-
let output =
|
|
11479
|
+
let output = backup_output_path(&request.output, std::env::var_os("FELTDB_BACKUP_DIR"))?;
|
|
10967
11480
|
let source = feltdb_server::backup::BackupSource {
|
|
10968
11481
|
data: state
|
|
10969
11482
|
.backup_artifacts
|
|
@@ -10975,14 +11488,22 @@ async fn create_online_backup(
|
|
|
10975
11488
|
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
10976
11489
|
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
10977
11490
|
};
|
|
11491
|
+
let records = operator_record_path(&state, BACKUP_RECORDS);
|
|
10978
11492
|
let manifest = tokio::task::spawn_blocking(move || {
|
|
10979
|
-
let
|
|
10980
|
-
|
|
10981
|
-
|
|
10982
|
-
|
|
10983
|
-
|
|
10984
|
-
|
|
10985
|
-
|
|
11493
|
+
let manifest = {
|
|
11494
|
+
let _guard = guard;
|
|
11495
|
+
feltdb_server::backup::create_bundle_with_source(
|
|
11496
|
+
&output,
|
|
11497
|
+
&artifacts,
|
|
11498
|
+
&[("database.snapshot.json".to_string(), snapshot_bytes)],
|
|
11499
|
+
source,
|
|
11500
|
+
)?
|
|
11501
|
+
};
|
|
11502
|
+
// Verified after the barrier is released: restoring into scratch and
|
|
11503
|
+
// opening the result is what turns "a bundle exists" into "a bundle
|
|
11504
|
+
// restores", and the record says which of the two is true.
|
|
11505
|
+
record_online_backup(&records, &output, &manifest);
|
|
11506
|
+
Ok::<_, std::io::Error>(manifest)
|
|
10986
11507
|
})
|
|
10987
11508
|
.await
|
|
10988
11509
|
.map_err(|error| {
|
|
@@ -11006,28 +11527,258 @@ async fn create_online_backup(
|
|
|
11006
11527
|
/// finding was that losing security evidence was invisible, and a signal an
|
|
11007
11528
|
/// operator has to know to go and look for is not much better than no signal.
|
|
11008
11529
|
/// It rides with the metrics they already read.
|
|
11009
|
-
|
|
11010
|
-
|
|
11011
|
-
|
|
11012
|
-
|
|
11013
|
-
|
|
11014
|
-
|
|
11015
|
-
);
|
|
11016
|
-
}
|
|
11017
|
-
Json(body)
|
|
11530
|
+
const BACKUP_RECORDS: &str = "backups.json";
|
|
11531
|
+
|
|
11532
|
+
/// Operator records live beside the durable log (`<data>.<name>`), on the
|
|
11533
|
+
/// same volume, like the readiness probe they are derived from.
|
|
11534
|
+
fn operator_record_path(state: &AppState, name: &str) -> PathBuf {
|
|
11535
|
+
state.readiness_probe.with_extension(name)
|
|
11018
11536
|
}
|
|
11019
11537
|
|
|
11020
|
-
|
|
11021
|
-
|
|
11022
|
-
|
|
11023
|
-
|
|
11024
|
-
|
|
11025
|
-
|
|
11026
|
-
|
|
11027
|
-
|
|
11028
|
-
|
|
11029
|
-
|
|
11030
|
-
|
|
11538
|
+
fn read_records(path: &std::path::Path) -> Vec<Value> {
|
|
11539
|
+
std::fs::read(path)
|
|
11540
|
+
.ok()
|
|
11541
|
+
.and_then(|bytes| serde_json::from_slice::<Vec<Value>>(&bytes).ok())
|
|
11542
|
+
.unwrap_or_default()
|
|
11543
|
+
}
|
|
11544
|
+
|
|
11545
|
+
/// Append one record; bounded so the file cannot grow without limit.
|
|
11546
|
+
fn append_record(path: &std::path::Path, record: Value) -> std::io::Result<()> {
|
|
11547
|
+
let mut records = read_records(path);
|
|
11548
|
+
records.push(record);
|
|
11549
|
+
let excess = records.len().saturating_sub(100);
|
|
11550
|
+
records.drain(..excess);
|
|
11551
|
+
let temporary = path.with_extension("json.tmp");
|
|
11552
|
+
let mut file = std::fs::File::create(&temporary)?;
|
|
11553
|
+
file.write_all(&serde_json::to_vec_pretty(&records).map_err(std::io::Error::other)?)?;
|
|
11554
|
+
file.sync_all()?;
|
|
11555
|
+
std::fs::rename(&temporary, path)
|
|
11556
|
+
}
|
|
11557
|
+
|
|
11558
|
+
fn record_online_backup(
|
|
11559
|
+
records: &std::path::Path,
|
|
11560
|
+
output: &std::path::Path,
|
|
11561
|
+
manifest: &feltdb_server::backup::BackupManifest,
|
|
11562
|
+
) {
|
|
11563
|
+
let started = Instant::now();
|
|
11564
|
+
let restore = verify_restorable(output).map_err(|error| error.to_string());
|
|
11565
|
+
let record = json!({
|
|
11566
|
+
"kind": "online_backup",
|
|
11567
|
+
"bundle_id": feltdb_server::backup::backup_id(output).ok(),
|
|
11568
|
+
"output": output.display().to_string(),
|
|
11569
|
+
"created_ms": manifest.created_ms,
|
|
11570
|
+
"files": manifest.entries.len(),
|
|
11571
|
+
"verified": restore.is_ok(),
|
|
11572
|
+
"restore_verification": match &restore {
|
|
11573
|
+
Ok(detail) => json!({
|
|
11574
|
+
"method": "bundle verified, restored into a scratch directory on this host and opened",
|
|
11575
|
+
"verified_at_ms": unix_millis(),
|
|
11576
|
+
"duration_ms": started.elapsed().as_millis() as u64,
|
|
11577
|
+
"detail": detail,
|
|
11578
|
+
}),
|
|
11579
|
+
Err(error) => json!({ "method": "scratch restore", "error": error }),
|
|
11580
|
+
},
|
|
11581
|
+
});
|
|
11582
|
+
if let Err(error) = append_record(records, record) {
|
|
11583
|
+
tracing::error!(%error, "failed to record online backup");
|
|
11584
|
+
}
|
|
11585
|
+
}
|
|
11586
|
+
|
|
11587
|
+
fn unix_millis() -> u64 {
|
|
11588
|
+
std::time::SystemTime::now()
|
|
11589
|
+
.duration_since(std::time::UNIX_EPOCH)
|
|
11590
|
+
.unwrap_or_default()
|
|
11591
|
+
.as_millis() as u64
|
|
11592
|
+
}
|
|
11593
|
+
|
|
11594
|
+
/// The routes authenticated by a shared service secret rather than an API
|
|
11595
|
+
/// key. They are routed only when deliberately configured, so an authority
|
|
11596
|
+
/// that is not part of a managed Intent Engine deployment or a certification
|
|
11597
|
+
/// run has no such surface at all.
|
|
11598
|
+
fn service_secret_routes() -> Router<AppState> {
|
|
11599
|
+
let mut routes = Router::new();
|
|
11600
|
+
let intent_secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").unwrap_or_default();
|
|
11601
|
+
if intent_secret.len() >= 32 {
|
|
11602
|
+
routes = routes
|
|
11603
|
+
.route("/internal/intents/by-hash", get(internal_find_intent))
|
|
11604
|
+
.route(
|
|
11605
|
+
"/internal/intents",
|
|
11606
|
+
axum::routing::post(internal_create_intent),
|
|
11607
|
+
)
|
|
11608
|
+
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
11609
|
+
.route(
|
|
11610
|
+
"/internal/intent-events",
|
|
11611
|
+
axum::routing::post(internal_create_intent_event),
|
|
11612
|
+
);
|
|
11613
|
+
} else if !intent_secret.is_empty() {
|
|
11614
|
+
tracing::warn!("FELTDB_INTENT_SERVICE_SECRET is shorter than 32 bytes; /internal/intents* is not routed");
|
|
11615
|
+
}
|
|
11616
|
+
let certification = matches!(
|
|
11617
|
+
std::env::var("FELTDB_CERTIFICATION_MODE").as_deref(),
|
|
11618
|
+
Ok("1" | "true")
|
|
11619
|
+
);
|
|
11620
|
+
if certification {
|
|
11621
|
+
tracing::warn!("FELTDB_CERTIFICATION_MODE is set: certification fixture deletion is routed");
|
|
11622
|
+
routes = routes.route(
|
|
11623
|
+
"/api/certification/fixtures/{application_id}",
|
|
11624
|
+
axum::routing::delete(delete_certification_fixture),
|
|
11625
|
+
);
|
|
11626
|
+
}
|
|
11627
|
+
routes
|
|
11628
|
+
}
|
|
11629
|
+
|
|
11630
|
+
/// A real write → fsync → read-back probe of the storage volume.
|
|
11631
|
+
fn storage_probe(path: &std::path::Path) -> Value {
|
|
11632
|
+
let started = Instant::now();
|
|
11633
|
+
let token = format!("feltdb-status-probe-{}\n", uuid::Uuid::new_v4().simple());
|
|
11634
|
+
let mut wrote = false;
|
|
11635
|
+
let mut synced = false;
|
|
11636
|
+
let result = (|| -> std::io::Result<bool> {
|
|
11637
|
+
if let Some(parent) = path.parent() {
|
|
11638
|
+
std::fs::create_dir_all(parent)?;
|
|
11639
|
+
}
|
|
11640
|
+
let mut file = OpenOptions::new()
|
|
11641
|
+
.create(true)
|
|
11642
|
+
.write(true)
|
|
11643
|
+
.truncate(true)
|
|
11644
|
+
.open(path)?;
|
|
11645
|
+
file.write_all(token.as_bytes())?;
|
|
11646
|
+
wrote = true;
|
|
11647
|
+
file.sync_all()?;
|
|
11648
|
+
synced = true;
|
|
11649
|
+
Ok(std::fs::read(path)? == token.as_bytes())
|
|
11650
|
+
})();
|
|
11651
|
+
let read_back = matches!(result, Ok(true));
|
|
11652
|
+
json!({
|
|
11653
|
+
"ok": read_back,
|
|
11654
|
+
"write": wrote,
|
|
11655
|
+
"fsync": synced,
|
|
11656
|
+
"read_back": read_back,
|
|
11657
|
+
"checked_at_ms": unix_millis(),
|
|
11658
|
+
"latency_us": started.elapsed().as_micros() as u64,
|
|
11659
|
+
"error": result.err().map(|error| error.to_string()),
|
|
11660
|
+
})
|
|
11661
|
+
}
|
|
11662
|
+
|
|
11663
|
+
/// `GET /v1/status` — the operator's view, needs `operator:read`.
|
|
11664
|
+
///
|
|
11665
|
+
/// Every field is observed by this request or read from a record this server
|
|
11666
|
+
/// wrote when the event happened; nothing is asserted. Availability (this
|
|
11667
|
+
/// process answers and its volume accepts a synced write) is reported apart
|
|
11668
|
+
/// from verified durability (a backup of this state was restored and opened).
|
|
11669
|
+
async fn operator_status(State(state): State<AppState>) -> Result<Json<Value>, ApiError> {
|
|
11670
|
+
let probe_path = operator_record_path(&state, "status-probe");
|
|
11671
|
+
let backups_path = operator_record_path(&state, BACKUP_RECORDS);
|
|
11672
|
+
let (probe, backups) = tokio::task::spawn_blocking(move || {
|
|
11673
|
+
(storage_probe(&probe_path), read_records(&backups_path))
|
|
11674
|
+
})
|
|
11675
|
+
.await
|
|
11676
|
+
.map_err(|error| {
|
|
11677
|
+
tracing::error!(%error, "status worker failed");
|
|
11678
|
+
ApiError(StatusCode::SERVICE_UNAVAILABLE, "status check temporarily unavailable".into())
|
|
11679
|
+
})?;
|
|
11680
|
+
let database = state.db.health();
|
|
11681
|
+
let last_backup = backups
|
|
11682
|
+
.iter()
|
|
11683
|
+
.rev()
|
|
11684
|
+
.find(|record| record["kind"] == "online_backup")
|
|
11685
|
+
.map(|record| {
|
|
11686
|
+
json!({
|
|
11687
|
+
"bundle_id": record["bundle_id"],
|
|
11688
|
+
"created_ms": record["created_ms"],
|
|
11689
|
+
"output": record["output"],
|
|
11690
|
+
"verified": record["verified"],
|
|
11691
|
+
})
|
|
11692
|
+
});
|
|
11693
|
+
let last_verified_restore = backups
|
|
11694
|
+
.iter()
|
|
11695
|
+
.rev()
|
|
11696
|
+
.find(|record| record["verified"] == true)
|
|
11697
|
+
.map(|record| {
|
|
11698
|
+
json!({
|
|
11699
|
+
"bundle_id": record["bundle_id"],
|
|
11700
|
+
"verified_at_ms": record["restore_verification"]["verified_at_ms"],
|
|
11701
|
+
"method": record["restore_verification"]["method"],
|
|
11702
|
+
})
|
|
11703
|
+
});
|
|
11704
|
+
let clock = state.lease_clock.is_healthy();
|
|
11705
|
+
let membership_healthy = !matches!(
|
|
11706
|
+
state.cluster.proposal().map(|value| value.phase),
|
|
11707
|
+
Some(ProposalPhase::Preparing | ProposalPhase::Prepared | ProposalPhase::Aborted)
|
|
11708
|
+
);
|
|
11709
|
+
let available = probe["ok"] == true;
|
|
11710
|
+
Ok(Json(json!({
|
|
11711
|
+
"status": if available && overall_status(membership_healthy, clock, &database.storage) == "healthy" { "available" } else { "degraded" },
|
|
11712
|
+
"release": {
|
|
11713
|
+
"version": env!("CARGO_PKG_VERSION"),
|
|
11714
|
+
"release_id": option_env!("FELTDB_RELEASE_ID").unwrap_or("unknown"),
|
|
11715
|
+
"git_commit": option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
|
|
11716
|
+
"build_timestamp": option_env!("FELTDB_BUILD_TIMESTAMP").unwrap_or("unknown"),
|
|
11717
|
+
"deployment_id": std::env::var("FELTDB_DEPLOYMENT_ID").or_else(|_| std::env::var("FLY_IMAGE_REF")).ok(),
|
|
11718
|
+
"promoted_revision": std::env::var("FELTDB_PROMOTED_REVISION").ok(),
|
|
11719
|
+
},
|
|
11720
|
+
"capabilities": {
|
|
11721
|
+
"public_contract": "feltdb.public.substrate@1.0",
|
|
11722
|
+
"capability_fingerprint": control_plane::public_contract_capability_fingerprint(),
|
|
11723
|
+
"protocol_version": PROTOCOL_VERSION,
|
|
11724
|
+
"authentication": state.auth_enabled,
|
|
11725
|
+
"autonomous_workers": state.worker_enabled,
|
|
11726
|
+
"configured_peers": state.cluster.get().peers.len(),
|
|
11727
|
+
"durable_work_authority": is_work_authority(&state),
|
|
11728
|
+
},
|
|
11729
|
+
"availability": {
|
|
11730
|
+
"serving": true,
|
|
11731
|
+
"storage_writable": available,
|
|
11732
|
+
"clock": if clock { "healthy" } else { "unhealthy" },
|
|
11733
|
+
"membership": if membership_healthy { "healthy" } else { "recovering-or-attention-required" },
|
|
11734
|
+
},
|
|
11735
|
+
"storage": {
|
|
11736
|
+
"replay": database.storage.label(),
|
|
11737
|
+
"durable_format": database.durable_format.to_string(),
|
|
11738
|
+
"probe": probe,
|
|
11739
|
+
},
|
|
11740
|
+
"backups": {
|
|
11741
|
+
"last": last_backup,
|
|
11742
|
+
"recorded": backups.len(),
|
|
11743
|
+
},
|
|
11744
|
+
"restore": {
|
|
11745
|
+
"last_verified": last_verified_restore,
|
|
11746
|
+
"status": if last_verified_restore.is_some() { "verified" } else { "never_verified" },
|
|
11747
|
+
"off_host": "not observable by this server",
|
|
11748
|
+
},
|
|
11749
|
+
"durability": {
|
|
11750
|
+
"verified": last_verified_restore.is_some(),
|
|
11751
|
+
"basis": if last_verified_restore.is_some() {
|
|
11752
|
+
"a backup of this authority was restored into scratch on this host and opened; off-host recovery and power-loss durability are not proven by this endpoint"
|
|
11753
|
+
} else {
|
|
11754
|
+
"no backup of this authority has been restored and verified; availability is not durability"
|
|
11755
|
+
},
|
|
11756
|
+
},
|
|
11757
|
+
})))
|
|
11758
|
+
}
|
|
11759
|
+
|
|
11760
|
+
async fn network_metrics(State(state): State<AppState>) -> Json<Value> {
|
|
11761
|
+
let mut body = serde_json::to_value(state.metrics.snapshot()).unwrap_or_else(|_| json!({}));
|
|
11762
|
+
if let Some(fields) = body.as_object_mut() {
|
|
11763
|
+
fields.insert(
|
|
11764
|
+
"audit".to_string(),
|
|
11765
|
+
serde_json::to_value(state.audit.health()).unwrap_or(Value::Null),
|
|
11766
|
+
);
|
|
11767
|
+
}
|
|
11768
|
+
Json(body)
|
|
11769
|
+
}
|
|
11770
|
+
|
|
11771
|
+
async fn sync_pull(
|
|
11772
|
+
State(state): State<AppState>,
|
|
11773
|
+
Json(request): Json<SyncPullRequest>,
|
|
11774
|
+
) -> Result<Json<SyncPullResponse>, ApiError> {
|
|
11775
|
+
authorize_sync_member(&state, &request.requester, request.membership_epoch)?;
|
|
11776
|
+
let peer_position = request.versions;
|
|
11777
|
+
let authority_position = state.db.operation_versions()?;
|
|
11778
|
+
let retained_from = state.db.retained_operation_floors()?;
|
|
11779
|
+
let catchup = catchup_status(
|
|
11780
|
+
request.requester,
|
|
11781
|
+
peer_position.clone(),
|
|
11031
11782
|
authority_position.clone(),
|
|
11032
11783
|
retained_from,
|
|
11033
11784
|
);
|
|
@@ -11048,6 +11799,7 @@ async fn sync_pull(
|
|
|
11048
11799
|
operations,
|
|
11049
11800
|
versions: authority_position,
|
|
11050
11801
|
catchup,
|
|
11802
|
+
durable_work_authority: state.work_authority,
|
|
11051
11803
|
}))
|
|
11052
11804
|
}
|
|
11053
11805
|
|
|
@@ -11198,6 +11950,40 @@ fn start_autonomous_worker(state: &AppState) {
|
|
|
11198
11950
|
});
|
|
11199
11951
|
}
|
|
11200
11952
|
|
|
11953
|
+
/// Recover Durable Work whose lease expired, on every node, whether or not it
|
|
11954
|
+
/// runs the autonomous worker: external workers depend on it too.
|
|
11955
|
+
///
|
|
11956
|
+
/// It is the mesh's claim recovery (`recover_expired_work`, the part of
|
|
11957
|
+
/// `detect_failures` that recovers work): expired work goes to
|
|
11958
|
+
/// `WorkloadStore::recover_expired`, bounded by each workload's attempt budget,
|
|
11959
|
+
/// and its claim is released from the worker that held it. Worker liveness —
|
|
11960
|
+
/// marking silent workers offline — is deliberately not run here; it would lock
|
|
11961
|
+
/// out registered workers that do not heartbeat on the mesh's schedule. It takes
|
|
11962
|
+
/// the mesh lock before the workload lock, the order the claim route uses.
|
|
11963
|
+
fn start_workload_recovery(state: &AppState) {
|
|
11964
|
+
let state = state.clone();
|
|
11965
|
+
tokio::spawn(async move {
|
|
11966
|
+
loop {
|
|
11967
|
+
if state.cluster.get().is_member && is_work_authority(&state) {
|
|
11968
|
+
let outcome = match (state.mesh.lock(), state.workloads.lock()) {
|
|
11969
|
+
// Deadlines first: work past one is timed out under its
|
|
11970
|
+
// own decision, not recovered as a lost worker.
|
|
11971
|
+
(Ok(mut mesh), Ok(mut workloads)) => mesh
|
|
11972
|
+
.enforce_deadlines(unix_seconds_i64(), &mut workloads)
|
|
11973
|
+
.and_then(|_| mesh.recover_expired_work(unix_seconds_i64(), &mut workloads))
|
|
11974
|
+
.map(|_| ())
|
|
11975
|
+
.map_err(|error| error.message),
|
|
11976
|
+
_ => Err("worker mesh or workload store unavailable".to_string()),
|
|
11977
|
+
};
|
|
11978
|
+
if let Err(error) = outcome {
|
|
11979
|
+
tracing::warn!(%error, "durable work recovery pass incomplete");
|
|
11980
|
+
}
|
|
11981
|
+
}
|
|
11982
|
+
tokio::time::sleep(Duration::from_secs(1)).await;
|
|
11983
|
+
}
|
|
11984
|
+
});
|
|
11985
|
+
}
|
|
11986
|
+
|
|
11201
11987
|
fn start_membership_recovery(state: &AppState) {
|
|
11202
11988
|
let state = state.clone();
|
|
11203
11989
|
tokio::spawn(async move {
|
|
@@ -11218,13 +12004,18 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
11218
12004
|
return Ok(());
|
|
11219
12005
|
}
|
|
11220
12006
|
let worker = state.db.instance_id()?;
|
|
12007
|
+
// One run's failure never stops the pass: each is recorded on its own run,
|
|
12008
|
+
// and an infrastructure fault is logged and retried on the next pass.
|
|
11221
12009
|
for row in state.db.list_collection("_flow_workflow_runs")? {
|
|
12010
|
+
if row.value["status"] != "running" {
|
|
12011
|
+
continue;
|
|
12012
|
+
}
|
|
11222
12013
|
let Some(steps) = row.value["steps"].as_array() else {
|
|
11223
12014
|
continue;
|
|
11224
12015
|
};
|
|
11225
12016
|
let now = lease_now_ms(state)?;
|
|
11226
12017
|
let Some(step) = steps.iter().find(|step| {
|
|
11227
|
-
step["status"] == "ready"
|
|
12018
|
+
(step["status"] == "ready" && step["not_before_ms"].as_u64().unwrap_or(0) <= now)
|
|
11228
12019
|
|| (step["status"] == "running"
|
|
11229
12020
|
&& step["lease_expires_ms"].as_u64().unwrap_or(0) <= now)
|
|
11230
12021
|
}) else {
|
|
@@ -11233,46 +12024,41 @@ async fn run_worker_pass(state: &AppState) -> Result<(), ApiError> {
|
|
|
11233
12024
|
let Some(step_id) = step["id"].as_str() else {
|
|
11234
12025
|
continue;
|
|
11235
12026
|
};
|
|
12027
|
+
// A step that names no capability here is an external step, claimed
|
|
12028
|
+
// by an external worker through the claim route.
|
|
11236
12029
|
if !is_executable_capability(state, step_id)? {
|
|
11237
12030
|
continue;
|
|
11238
12031
|
}
|
|
11239
12032
|
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?;
|
|
12033
|
+
let attempts = step["attempt"].as_u64().unwrap_or(0);
|
|
12034
|
+
if let Err(error) =
|
|
12035
|
+
execute_workflow_step(state, &row.value, &run_id, step_id, attempts, &worker).await
|
|
12036
|
+
{
|
|
12037
|
+
tracing::warn!(run = %run_id, step = %step_id, status = error.0.as_u16(), "workflow step attempt did not finish");
|
|
12038
|
+
}
|
|
11263
12039
|
}
|
|
11264
12040
|
|
|
11265
12041
|
for row in state.db.list_collection("_flow_agent_runs")? {
|
|
11266
|
-
let
|
|
11267
|
-
|
|
11268
|
-
|
|
12042
|
+
let now = lease_now_ms(state)?;
|
|
12043
|
+
let status = row.value["status"].as_str().unwrap_or_default();
|
|
12044
|
+
let lease_expired = row.value["lease_expires_ms"].as_u64().unwrap_or(0) <= now;
|
|
12045
|
+
if !agent_execution::agent_run_claimable(status, lease_expired)
|
|
12046
|
+
|| row.value["not_before_ms"].as_u64().unwrap_or(0) > now
|
|
12047
|
+
{
|
|
11269
12048
|
continue;
|
|
11270
12049
|
}
|
|
11271
|
-
execute_agent_run(state, &row.value, &worker).await
|
|
12050
|
+
if let Err(error) = execute_agent_run(state, &row.value, &worker).await {
|
|
12051
|
+
let run = row.value["id"].as_str().unwrap_or_default();
|
|
12052
|
+
tracing::warn!(%run, status = error.0.as_u16(), "agent run attempt did not finish");
|
|
12053
|
+
}
|
|
11272
12054
|
}
|
|
11273
12055
|
|
|
11274
|
-
|
|
11275
|
-
|
|
12056
|
+
// Triggers admit work and the pass claims it: Durable Work writes, so
|
|
12057
|
+
// only on the authority.
|
|
12058
|
+
if is_work_authority(state) {
|
|
12059
|
+
evaluate_state_triggers(state)?;
|
|
12060
|
+
execute_eligible_workloads(state, &worker)?;
|
|
12061
|
+
}
|
|
11276
12062
|
Ok(())
|
|
11277
12063
|
}
|
|
11278
12064
|
|
|
@@ -11372,7 +12158,7 @@ fn worker_capabilities(state: &AppState) -> Result<BTreeSet<String>, ApiError> {
|
|
|
11372
12158
|
fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiError> {
|
|
11373
12159
|
let capabilities = worker_capabilities(state)?;
|
|
11374
12160
|
let now = unix_seconds_i64();
|
|
11375
|
-
let candidates: Vec<(String, String, Value)> = {
|
|
12161
|
+
let candidates: Vec<(String, String, Value, feltdb::workload::Workload)> = {
|
|
11376
12162
|
let store = workload_store(state)?;
|
|
11377
12163
|
store
|
|
11378
12164
|
.workloads
|
|
@@ -11391,12 +12177,13 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
|
|
|
11391
12177
|
workload.workload_id.clone(),
|
|
11392
12178
|
workload.definition_id.clone(),
|
|
11393
12179
|
workload.input.clone(),
|
|
12180
|
+
workload.clone(),
|
|
11394
12181
|
)
|
|
11395
12182
|
})
|
|
11396
12183
|
.collect()
|
|
11397
12184
|
};
|
|
11398
12185
|
|
|
11399
|
-
for (id, definition, input) in candidates {
|
|
12186
|
+
for (id, definition, input, workload) in candidates {
|
|
11400
12187
|
// Unlike the filter above, this one is enforcement: nothing downstream
|
|
11401
12188
|
// knows whether `definition_id` names something runnable. Claiming a
|
|
11402
12189
|
// workload only to dead-letter it would consume work another node could
|
|
@@ -11427,7 +12214,13 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
|
|
|
11427
12214
|
)
|
|
11428
12215
|
.map_err(workload_error)?;
|
|
11429
12216
|
|
|
11430
|
-
|
|
12217
|
+
let access = CapabilityAccess::Workload {
|
|
12218
|
+
requested_by: &workload.requested_by,
|
|
12219
|
+
tenant_id: &workload.tenant_id,
|
|
12220
|
+
application_id: &workload.application_id,
|
|
12221
|
+
grant: &workload.authorization_snapshot,
|
|
12222
|
+
};
|
|
12223
|
+
match execute_named_capability(state, &definition, &input, &access) {
|
|
11431
12224
|
Ok(output) => {
|
|
11432
12225
|
workload_store(state)?
|
|
11433
12226
|
.complete(
|
|
@@ -11464,6 +12257,157 @@ fn execute_eligible_workloads(state: &AppState, worker: &str) -> Result<(), ApiE
|
|
|
11464
12257
|
Ok(())
|
|
11465
12258
|
}
|
|
11466
12259
|
|
|
12260
|
+
/// Debug builds only: `FELTDB_TEST_TRANSIENT_CAPABILITY=<name>[:<count>]` makes
|
|
12261
|
+
/// executing `<name>` fail with a server error — every time, or for its first
|
|
12262
|
+
/// `<count>` executions — so retry bounds can be exercised at the HTTP
|
|
12263
|
+
/// boundary. Compiled out of release builds, like `FELTDB_TEST_CLOCK_OFFSET_MS`.
|
|
12264
|
+
#[cfg(debug_assertions)]
|
|
12265
|
+
fn injected_capability_fault(name: &str) -> Option<ApiError> {
|
|
12266
|
+
static INJECTED: AtomicU64 = AtomicU64::new(0);
|
|
12267
|
+
let setting = std::env::var("FELTDB_TEST_TRANSIENT_CAPABILITY").ok()?;
|
|
12268
|
+
let (target, limit) = match setting.split_once(':') {
|
|
12269
|
+
Some((target, count)) => (target.to_string(), count.parse::<u64>().ok()),
|
|
12270
|
+
None => (setting, None),
|
|
12271
|
+
};
|
|
12272
|
+
if target != name {
|
|
12273
|
+
return None;
|
|
12274
|
+
}
|
|
12275
|
+
let injected = INJECTED.fetch_add(1, Ordering::SeqCst);
|
|
12276
|
+
if limit.is_some_and(|limit| injected >= limit) {
|
|
12277
|
+
return None;
|
|
12278
|
+
}
|
|
12279
|
+
Some(ApiError(
|
|
12280
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
12281
|
+
"injected transient capability failure".into(),
|
|
12282
|
+
))
|
|
12283
|
+
}
|
|
12284
|
+
|
|
12285
|
+
/// The authority a capability reads under.
|
|
12286
|
+
///
|
|
12287
|
+
/// Every collection a capability touches is authorized against it in
|
|
12288
|
+
/// [`authorize_capability_read`], the single point all capability reads pass
|
|
12289
|
+
/// through, so no route, program step, workflow, or agent reaches state with
|
|
12290
|
+
/// more authority than the principal it acts for.
|
|
12291
|
+
enum CapabilityAccess<'a> {
|
|
12292
|
+
/// The request's own principal, on the direct capability routes.
|
|
12293
|
+
Principal(&'a Principal),
|
|
12294
|
+
/// An application actor on the `/v1` Service API.
|
|
12295
|
+
Application {
|
|
12296
|
+
actor: &'a str,
|
|
12297
|
+
application_id: &'a str,
|
|
12298
|
+
},
|
|
12299
|
+
/// A run's verified delegated authority, on the autonomous worker.
|
|
12300
|
+
Delegated(&'a RunPrincipal),
|
|
12301
|
+
/// A durable workload's signed authorization snapshot. Reads are allowed
|
|
12302
|
+
/// only when that grant, re-verified at execution, authorizes `state:read`
|
|
12303
|
+
/// for the workload's requester — so a workload never reads with the
|
|
12304
|
+
/// node's authority, and a row injected without a server-signed grant
|
|
12305
|
+
/// reads nothing.
|
|
12306
|
+
Workload {
|
|
12307
|
+
requested_by: &'a GrantSubject,
|
|
12308
|
+
tenant_id: &'a str,
|
|
12309
|
+
application_id: &'a str,
|
|
12310
|
+
grant: &'a feltdb::authorization::SignedGrant,
|
|
12311
|
+
},
|
|
12312
|
+
}
|
|
12313
|
+
|
|
12314
|
+
/// Whether a principal, described the way the authenticator describes it, holds
|
|
12315
|
+
/// `capability`. The same decision the request middleware makes.
|
|
12316
|
+
fn principal_grants(
|
|
12317
|
+
state: &AppState,
|
|
12318
|
+
kind: &str,
|
|
12319
|
+
id: &str,
|
|
12320
|
+
scopes: &[String],
|
|
12321
|
+
capability: &str,
|
|
12322
|
+
) -> bool {
|
|
12323
|
+
let subject = if kind == "human" {
|
|
12324
|
+
Subject::User(id.to_string())
|
|
12325
|
+
} else {
|
|
12326
|
+
Subject::Service(id.to_string())
|
|
12327
|
+
};
|
|
12328
|
+
authorize(&AuthorizationRequest {
|
|
12329
|
+
subject: &subject,
|
|
12330
|
+
tenant_id: &state.namespace,
|
|
12331
|
+
application_id: Some(&state.namespace),
|
|
12332
|
+
capability,
|
|
12333
|
+
direct_grants: scopes,
|
|
12334
|
+
memberships: &[],
|
|
12335
|
+
roles: &[],
|
|
12336
|
+
})
|
|
12337
|
+
.allowed
|
|
12338
|
+
}
|
|
12339
|
+
|
|
12340
|
+
fn request_principal_grants(state: &AppState, principal: &Principal, capability: &str) -> bool {
|
|
12341
|
+
principal_grants(
|
|
12342
|
+
state,
|
|
12343
|
+
&principal.subject_type,
|
|
12344
|
+
&principal.key_id,
|
|
12345
|
+
&principal.scopes,
|
|
12346
|
+
capability,
|
|
12347
|
+
)
|
|
12348
|
+
}
|
|
12349
|
+
|
|
12350
|
+
fn delegated_grants(state: &AppState, principal: &RunPrincipal, capability: &str) -> bool {
|
|
12351
|
+
let scopes: Vec<String> = principal.scopes.iter().cloned().collect();
|
|
12352
|
+
principal_grants(state, &principal.kind, &principal.id, &scopes, capability)
|
|
12353
|
+
}
|
|
12354
|
+
|
|
12355
|
+
fn capability_read_denied(message: String) -> ApiError {
|
|
12356
|
+
ApiError::structured(
|
|
12357
|
+
StatusCode::FORBIDDEN,
|
|
12358
|
+
json!({"code": agent_execution::AUTHORIZATION_DENIED, "message": message}),
|
|
12359
|
+
)
|
|
12360
|
+
}
|
|
12361
|
+
|
|
12362
|
+
/// Authorize one collection read made by a capability.
|
|
12363
|
+
///
|
|
12364
|
+
/// Reading records through a capability is reading state, so it needs the
|
|
12365
|
+
/// `state:read` a direct read needs. System collections (`_`-prefixed) hold
|
|
12366
|
+
/// runs, definitions and other server records and are never readable this way.
|
|
12367
|
+
fn authorize_capability_read(
|
|
12368
|
+
state: &AppState,
|
|
12369
|
+
access: &CapabilityAccess<'_>,
|
|
12370
|
+
collection: &str,
|
|
12371
|
+
) -> Result<(), ApiError> {
|
|
12372
|
+
if agent_execution::is_system_collection(collection) {
|
|
12373
|
+
return Err(capability_read_denied(format!(
|
|
12374
|
+
"capabilities cannot read system collection {collection}"
|
|
12375
|
+
)));
|
|
12376
|
+
}
|
|
12377
|
+
let allowed = match access {
|
|
12378
|
+
CapabilityAccess::Principal(principal) => {
|
|
12379
|
+
request_principal_grants(state, principal, "state:read")
|
|
12380
|
+
}
|
|
12381
|
+
CapabilityAccess::Application {
|
|
12382
|
+
actor,
|
|
12383
|
+
application_id,
|
|
12384
|
+
} => application_scope(state, actor, application_id, "state:read").is_ok(),
|
|
12385
|
+
CapabilityAccess::Delegated(principal) => delegated_grants(state, principal, "state:read"),
|
|
12386
|
+
CapabilityAccess::Workload {
|
|
12387
|
+
requested_by,
|
|
12388
|
+
tenant_id,
|
|
12389
|
+
application_id,
|
|
12390
|
+
grant,
|
|
12391
|
+
} => workload_decision(
|
|
12392
|
+
state,
|
|
12393
|
+
(*requested_by).clone(),
|
|
12394
|
+
tenant_id,
|
|
12395
|
+
application_id,
|
|
12396
|
+
format!("flow://{application_id}/state/{collection}"),
|
|
12397
|
+
"state:read",
|
|
12398
|
+
grant,
|
|
12399
|
+
)
|
|
12400
|
+
.is_ok(),
|
|
12401
|
+
};
|
|
12402
|
+
if allowed {
|
|
12403
|
+
Ok(())
|
|
12404
|
+
} else {
|
|
12405
|
+
Err(capability_read_denied(format!(
|
|
12406
|
+
"the caller is not authorized to read {collection}"
|
|
12407
|
+
)))
|
|
12408
|
+
}
|
|
12409
|
+
}
|
|
12410
|
+
|
|
11467
12411
|
fn is_builtin_capability(name: &str) -> bool {
|
|
11468
12412
|
matches!(name, "search" | "identity")
|
|
11469
12413
|
}
|
|
@@ -11480,6 +12424,7 @@ fn execute_builtin_capability(
|
|
|
11480
12424
|
state: &AppState,
|
|
11481
12425
|
name: &str,
|
|
11482
12426
|
input: &Value,
|
|
12427
|
+
access: &CapabilityAccess<'_>,
|
|
11483
12428
|
) -> Result<Value, ApiError> {
|
|
11484
12429
|
match name {
|
|
11485
12430
|
"identity" => Ok(input.clone()),
|
|
@@ -11491,6 +12436,7 @@ fn execute_builtin_capability(
|
|
|
11491
12436
|
)
|
|
11492
12437
|
})?;
|
|
11493
12438
|
validate_segment(collection)?;
|
|
12439
|
+
authorize_capability_read(state, access, collection)?;
|
|
11494
12440
|
let needle = input["query"].as_str().unwrap_or_default().to_lowercase();
|
|
11495
12441
|
let limit = input["limit"].as_u64().unwrap_or(50).min(500) as usize;
|
|
11496
12442
|
Ok(Value::Array(
|
|
@@ -11511,80 +12457,369 @@ fn execute_builtin_capability(
|
|
|
11511
12457
|
}
|
|
11512
12458
|
}
|
|
11513
12459
|
|
|
12460
|
+
/// The text of an [`ApiError`] without its wire envelope: the `message` of a
|
|
12461
|
+
/// structured error, or the plain message.
|
|
12462
|
+
fn api_error_message(error: &ApiError) -> String {
|
|
12463
|
+
serde_json::from_str::<Value>(&error.1)
|
|
12464
|
+
.ok()
|
|
12465
|
+
.and_then(|value| value["message"].as_str().map(str::to_string))
|
|
12466
|
+
.unwrap_or_else(|| error.1.clone())
|
|
12467
|
+
}
|
|
12468
|
+
|
|
12469
|
+
/// Everything an agent run needs before the worker may lease it.
|
|
12470
|
+
///
|
|
12471
|
+
/// Checked on every attempt, so authority revoked after the run was created,
|
|
12472
|
+
/// a definition removed, or a capability unregistered ends the run here,
|
|
12473
|
+
/// before anything executes.
|
|
12474
|
+
fn prepare_agent_run(
|
|
12475
|
+
state: &AppState,
|
|
12476
|
+
run: &Value,
|
|
12477
|
+
id: &str,
|
|
12478
|
+
attempt: u64,
|
|
12479
|
+
) -> Result<(String, RunPrincipal, Option<CompletionCriteria>), RunFailure> {
|
|
12480
|
+
if attempt > agent_execution::max_run_attempts() {
|
|
12481
|
+
return Err(RunFailure::permanent(
|
|
12482
|
+
agent_execution::RETRY_EXHAUSTED,
|
|
12483
|
+
format!(
|
|
12484
|
+
"run was claimed {} times without finishing",
|
|
12485
|
+
attempt - 1
|
|
12486
|
+
),
|
|
12487
|
+
));
|
|
12488
|
+
}
|
|
12489
|
+
let principal = resolve_run_principal(state, run, RunKind::Agent, id)?;
|
|
12490
|
+
if !delegated_grants(state, &principal, "agents:run") {
|
|
12491
|
+
return Err(RunFailure::permanent(
|
|
12492
|
+
agent_execution::AUTHORIZATION_DENIED,
|
|
12493
|
+
"the run's principal may not run agents",
|
|
12494
|
+
));
|
|
12495
|
+
}
|
|
12496
|
+
let agent = run["agent"].as_str().unwrap_or_default();
|
|
12497
|
+
let definition = match validate_segment(agent)
|
|
12498
|
+
.and_then(|_| required_value(state, "_flow_agents", agent, "agent not found"))
|
|
12499
|
+
{
|
|
12500
|
+
Ok(definition) => definition,
|
|
12501
|
+
Err(_) => {
|
|
12502
|
+
return Err(RunFailure::permanent(
|
|
12503
|
+
agent_execution::AGENT_NOT_FOUND,
|
|
12504
|
+
"the run's agent is not defined",
|
|
12505
|
+
))
|
|
12506
|
+
}
|
|
12507
|
+
};
|
|
12508
|
+
let capability = agent_capability(state, &definition)?;
|
|
12509
|
+
let criteria = match &run["completion"] {
|
|
12510
|
+
Value::Null => None,
|
|
12511
|
+
value => Some(
|
|
12512
|
+
serde_json::from_value::<CompletionCriteria>(value.clone())
|
|
12513
|
+
.ok()
|
|
12514
|
+
.filter(|criteria| criteria.validate().is_ok())
|
|
12515
|
+
.ok_or_else(|| {
|
|
12516
|
+
RunFailure::permanent(
|
|
12517
|
+
agent_execution::RUN_INVALID,
|
|
12518
|
+
"the run's completion criteria are invalid",
|
|
12519
|
+
)
|
|
12520
|
+
})?,
|
|
12521
|
+
),
|
|
12522
|
+
};
|
|
12523
|
+
Ok((capability, principal, criteria))
|
|
12524
|
+
}
|
|
12525
|
+
|
|
12526
|
+
fn mark_agent_run_failed(run: &mut Value, failure: &RunFailure, attempt: u64) {
|
|
12527
|
+
run["status"] = json!(agent_execution::STATUS_FAILED);
|
|
12528
|
+
run["failure"] = failure.to_record(attempt);
|
|
12529
|
+
run["attempt"] = json!(attempt);
|
|
12530
|
+
}
|
|
12531
|
+
|
|
12532
|
+
/// Execute one attempt of an agent run.
|
|
12533
|
+
///
|
|
12534
|
+
/// The run completes only when its declared criteria hold for the result this
|
|
12535
|
+
/// worker observed. Every other outcome is explicit: a permanent error fails
|
|
12536
|
+
/// the run, a transient one returns it to `ready` after a backoff until the
|
|
12537
|
+
/// attempt bound, and a run without criteria fails as unverifiable. An `Err`
|
|
12538
|
+
/// is an infrastructure fault that left the run untouched.
|
|
11514
12539
|
async fn execute_agent_run(state: &AppState, run: &Value, worker: &str) -> Result<(), ApiError> {
|
|
11515
12540
|
let id = run["id"]
|
|
11516
12541
|
.as_str()
|
|
11517
12542
|
.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
|
-
{
|
|
12543
|
+
let (capability, principal, criteria, claim_id, attempt, input) = {
|
|
11543
12544
|
let _guard = state.lifecycle_lock.lock().await;
|
|
11544
|
-
reserve_quorum_lease(state, &format!("agent-{id}"), &lease).await?;
|
|
11545
12545
|
let mut current = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
|
|
11546
|
-
let
|
|
11547
|
-
|
|
11548
|
-
|
|
12546
|
+
let now = lease_now_ms(state)?;
|
|
12547
|
+
let status = current["status"].as_str().unwrap_or_default();
|
|
12548
|
+
let lease_expired = current["lease_expires_ms"].as_u64().unwrap_or(0) <= now;
|
|
12549
|
+
if !agent_execution::agent_run_claimable(status, lease_expired)
|
|
12550
|
+
|| current["not_before_ms"].as_u64().unwrap_or(0) > now
|
|
12551
|
+
{
|
|
11549
12552
|
return Ok(());
|
|
11550
12553
|
}
|
|
11551
|
-
current["
|
|
12554
|
+
let attempt = current["attempt"].as_u64().unwrap_or(0) + 1;
|
|
12555
|
+
let (capability, principal, criteria) =
|
|
12556
|
+
match prepare_agent_run(state, ¤t, id, attempt) {
|
|
12557
|
+
Ok(prepared) => prepared,
|
|
12558
|
+
Err(failure) => {
|
|
12559
|
+
mark_agent_run_failed(&mut current, &failure, attempt);
|
|
12560
|
+
put_canonical(state, "_flow_agent_runs", id, current)?;
|
|
12561
|
+
return Ok(());
|
|
12562
|
+
}
|
|
12563
|
+
};
|
|
12564
|
+
let claim_id = next_id(state, "claim");
|
|
12565
|
+
let expires_ms = now.saturating_add(30_000);
|
|
12566
|
+
let lease = LeaseRequest {
|
|
12567
|
+
claim_id: claim_id.clone(),
|
|
12568
|
+
owner: worker.to_string(),
|
|
12569
|
+
expires_ms,
|
|
12570
|
+
membership_epoch: state.cluster.get().epoch,
|
|
12571
|
+
};
|
|
12572
|
+
reserve_quorum_lease(state, &format!("agent-{id}"), &lease).await?;
|
|
12573
|
+
current["status"] = json!(agent_execution::STATUS_RUNNING);
|
|
11552
12574
|
current["worker"] = json!(worker);
|
|
11553
12575
|
current["claim_id"] = json!(claim_id);
|
|
11554
12576
|
current["lease_expires_ms"] = json!(expires_ms);
|
|
11555
|
-
|
|
11556
|
-
|
|
11557
|
-
|
|
12577
|
+
current["attempt"] = json!(attempt);
|
|
12578
|
+
let input = current["input"].clone();
|
|
12579
|
+
put_canonical(state, "_flow_agent_runs", id, current)?;
|
|
12580
|
+
(capability, principal, criteria, claim_id, attempt, input)
|
|
12581
|
+
};
|
|
12582
|
+
|
|
12583
|
+
let outcome = execute_named_capability(
|
|
12584
|
+
state,
|
|
12585
|
+
&capability,
|
|
12586
|
+
&input,
|
|
12587
|
+
&CapabilityAccess::Delegated(&principal),
|
|
12588
|
+
);
|
|
12589
|
+
|
|
11558
12590
|
let _guard = state.lifecycle_lock.lock().await;
|
|
11559
12591
|
let mut current = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
|
|
11560
|
-
|
|
11561
|
-
|
|
12592
|
+
let now = lease_now_ms(state)?;
|
|
12593
|
+
// Publish only while this claim still owns the run. A claim that lost its
|
|
12594
|
+
// lease publishes nothing; the next claim counts as another attempt.
|
|
12595
|
+
if current["claim_id"] != claim_id.as_str()
|
|
12596
|
+
|| current["status"] != agent_execution::STATUS_RUNNING
|
|
12597
|
+
|| current["lease_expires_ms"].as_u64().unwrap_or(0) <= now
|
|
11562
12598
|
{
|
|
11563
|
-
return
|
|
11564
|
-
|
|
11565
|
-
|
|
11566
|
-
)
|
|
12599
|
+
return Ok(());
|
|
12600
|
+
}
|
|
12601
|
+
match outcome {
|
|
12602
|
+
Ok(result) => {
|
|
12603
|
+
let satisfied = criteria
|
|
12604
|
+
.as_ref()
|
|
12605
|
+
.map(|criteria| criteria.satisfied_by(&result));
|
|
12606
|
+
let digest = format!(
|
|
12607
|
+
"sha256:{:x}",
|
|
12608
|
+
Sha256::digest(serde_json::to_vec(&result).unwrap_or_default())
|
|
12609
|
+
);
|
|
12610
|
+
let evidence = json!({
|
|
12611
|
+
"source": "runtime", "attempt": attempt, "claim_id": claim_id,
|
|
12612
|
+
"capability": capability, "result_items": result.as_array().map(Vec::len),
|
|
12613
|
+
"result_digest": digest, "criteria": criteria, "satisfied": satisfied,
|
|
12614
|
+
"observed_at_ms": now,
|
|
12615
|
+
});
|
|
12616
|
+
for (field, value) in [
|
|
12617
|
+
("observations", result.clone()),
|
|
12618
|
+
(
|
|
12619
|
+
"decisions",
|
|
12620
|
+
json!({"capability": capability, "result": result, "source": "runtime"}),
|
|
12621
|
+
),
|
|
12622
|
+
("evidence", evidence),
|
|
12623
|
+
] {
|
|
12624
|
+
if !current[field].is_array() {
|
|
12625
|
+
current[field] = json!([]);
|
|
12626
|
+
}
|
|
12627
|
+
current[field]
|
|
12628
|
+
.as_array_mut()
|
|
12629
|
+
.expect("normalized to an array")
|
|
12630
|
+
.push(value);
|
|
12631
|
+
}
|
|
12632
|
+
match satisfied {
|
|
12633
|
+
Some(true) => {
|
|
12634
|
+
current["status"] = json!(agent_execution::STATUS_COMPLETED);
|
|
12635
|
+
current["completed_at_ms"] = json!(now);
|
|
12636
|
+
}
|
|
12637
|
+
Some(false) => mark_agent_run_failed(
|
|
12638
|
+
&mut current,
|
|
12639
|
+
&RunFailure::permanent(
|
|
12640
|
+
agent_execution::COMPLETION_CRITERIA_UNMET,
|
|
12641
|
+
"the observed result does not satisfy the run's completion criteria",
|
|
12642
|
+
),
|
|
12643
|
+
attempt,
|
|
12644
|
+
),
|
|
12645
|
+
None => mark_agent_run_failed(
|
|
12646
|
+
&mut current,
|
|
12647
|
+
&RunFailure::permanent(
|
|
12648
|
+
agent_execution::COMPLETION_UNVERIFIABLE,
|
|
12649
|
+
"the run declares no completion criteria the runtime can verify; its goal text is not evaluated",
|
|
12650
|
+
),
|
|
12651
|
+
attempt,
|
|
12652
|
+
),
|
|
12653
|
+
}
|
|
12654
|
+
}
|
|
12655
|
+
Err(error) => {
|
|
12656
|
+
let failure = agent_execution::classify_capability_error(
|
|
12657
|
+
error.0.as_u16(),
|
|
12658
|
+
&api_error_message(&error),
|
|
12659
|
+
);
|
|
12660
|
+
if failure.retryable && attempt < agent_execution::max_run_attempts() {
|
|
12661
|
+
// The failed attempt's lease stays reserved until it lapses, so
|
|
12662
|
+
// the retry waits for that as well as for the backoff: a new
|
|
12663
|
+
// claim before then would be refused by the lease quorum.
|
|
12664
|
+
let lease_expires = current["lease_expires_ms"].as_u64().unwrap_or(0);
|
|
12665
|
+
current["status"] = json!(agent_execution::STATUS_READY);
|
|
12666
|
+
current["not_before_ms"] = json!(now
|
|
12667
|
+
.saturating_add(agent_execution::retry_delay_ms(attempt + 1))
|
|
12668
|
+
.max(lease_expires));
|
|
12669
|
+
current["last_failure"] = failure.to_record(attempt);
|
|
12670
|
+
} else if failure.retryable {
|
|
12671
|
+
mark_agent_run_failed(
|
|
12672
|
+
&mut current,
|
|
12673
|
+
&RunFailure::permanent(
|
|
12674
|
+
agent_execution::RETRY_EXHAUSTED,
|
|
12675
|
+
format!("{} after {attempt} attempts", failure.message),
|
|
12676
|
+
),
|
|
12677
|
+
attempt,
|
|
12678
|
+
);
|
|
12679
|
+
} else {
|
|
12680
|
+
mark_agent_run_failed(&mut current, &failure, attempt);
|
|
12681
|
+
}
|
|
12682
|
+
}
|
|
11567
12683
|
}
|
|
11568
|
-
|
|
12684
|
+
put_canonical(state, "_flow_agent_runs", id, current)?;
|
|
12685
|
+
Ok(())
|
|
12686
|
+
}
|
|
12687
|
+
|
|
12688
|
+
/// Fail a workflow step and its run. `claim_id`, when given, must still own
|
|
12689
|
+
/// the step; otherwise another claim has taken over and nothing changes.
|
|
12690
|
+
async fn fail_workflow_step(
|
|
12691
|
+
state: &AppState,
|
|
12692
|
+
run_id: &str,
|
|
12693
|
+
step_id: &str,
|
|
12694
|
+
claim_id: Option<&str>,
|
|
12695
|
+
failure: &RunFailure,
|
|
12696
|
+
) -> Result<(), ApiError> {
|
|
12697
|
+
let _guard = state.lifecycle_lock.lock().await;
|
|
12698
|
+
let mut run = required_value(state, "_flow_workflow_runs", run_id, "workflow run not found")?;
|
|
12699
|
+
if run["status"] != "running" {
|
|
12700
|
+
return Ok(());
|
|
12701
|
+
}
|
|
12702
|
+
let Some(step) = run["steps"]
|
|
11569
12703
|
.as_array_mut()
|
|
11570
|
-
.
|
|
11571
|
-
|
|
11572
|
-
|
|
11573
|
-
|
|
11574
|
-
|
|
11575
|
-
|
|
11576
|
-
|
|
11577
|
-
|
|
12704
|
+
.and_then(|steps| steps.iter_mut().find(|step| step["id"] == step_id))
|
|
12705
|
+
else {
|
|
12706
|
+
return Ok(());
|
|
12707
|
+
};
|
|
12708
|
+
if claim_id.is_some_and(|claim| step["claim_id"] != claim) {
|
|
12709
|
+
return Ok(());
|
|
12710
|
+
}
|
|
12711
|
+
let attempt = step["attempt"].as_u64().unwrap_or(0);
|
|
12712
|
+
step["status"] = json!(agent_execution::STATUS_FAILED);
|
|
12713
|
+
step["failure"] = failure.to_record(attempt);
|
|
12714
|
+
run["status"] = json!(agent_execution::STATUS_FAILED);
|
|
12715
|
+
run["failure"] = json!({"step": step_id, "code": failure.code, "message": failure.message});
|
|
12716
|
+
put_canonical(state, "_flow_workflow_runs", run_id, run)?;
|
|
12717
|
+
Ok(())
|
|
12718
|
+
}
|
|
12719
|
+
|
|
12720
|
+
/// Return a step that failed transiently to `ready` after a backoff.
|
|
12721
|
+
async fn release_workflow_step(
|
|
12722
|
+
state: &AppState,
|
|
12723
|
+
run_id: &str,
|
|
12724
|
+
step_id: &str,
|
|
12725
|
+
claim_id: &str,
|
|
12726
|
+
failure: &RunFailure,
|
|
12727
|
+
) -> Result<(), ApiError> {
|
|
12728
|
+
let _guard = state.lifecycle_lock.lock().await;
|
|
12729
|
+
let mut run = required_value(state, "_flow_workflow_runs", run_id, "workflow run not found")?;
|
|
12730
|
+
let now = lease_now_ms(state)?;
|
|
12731
|
+
let Some(step) = run["steps"]
|
|
11578
12732
|
.as_array_mut()
|
|
12733
|
+
.and_then(|steps| steps.iter_mut().find(|step| step["id"] == step_id))
|
|
12734
|
+
else {
|
|
12735
|
+
return Ok(());
|
|
12736
|
+
};
|
|
12737
|
+
if step["claim_id"] != claim_id || step["status"] != "running" {
|
|
12738
|
+
return Ok(());
|
|
12739
|
+
}
|
|
12740
|
+
let attempt = step["attempt"].as_u64().unwrap_or(0);
|
|
12741
|
+
let lease_expires = step["lease_expires_ms"].as_u64().unwrap_or(0);
|
|
12742
|
+
step["status"] = json!(agent_execution::STATUS_READY);
|
|
12743
|
+
// Not before the failed attempt's lease lapses: until then the lease
|
|
12744
|
+
// quorum refuses any other claim.
|
|
12745
|
+
step["not_before_ms"] = json!(now
|
|
12746
|
+
.saturating_add(agent_execution::retry_delay_ms(attempt + 1))
|
|
12747
|
+
.max(lease_expires));
|
|
12748
|
+
step["last_failure"] = failure.to_record(attempt);
|
|
12749
|
+
put_canonical(state, "_flow_workflow_runs", run_id, run)?;
|
|
12750
|
+
Ok(())
|
|
12751
|
+
}
|
|
12752
|
+
|
|
12753
|
+
/// Execute one autonomous workflow step under the run's delegated authority.
|
|
12754
|
+
async fn execute_workflow_step(
|
|
12755
|
+
state: &AppState,
|
|
12756
|
+
run: &Value,
|
|
12757
|
+
run_id: &str,
|
|
12758
|
+
step_id: &str,
|
|
12759
|
+
attempts: u64,
|
|
12760
|
+
worker: &str,
|
|
12761
|
+
) -> Result<(), ApiError> {
|
|
12762
|
+
if attempts >= agent_execution::max_run_attempts() {
|
|
12763
|
+
let failure = RunFailure::permanent(
|
|
12764
|
+
agent_execution::RETRY_EXHAUSTED,
|
|
12765
|
+
format!("step was claimed {attempts} times without finishing"),
|
|
12766
|
+
);
|
|
12767
|
+
return fail_workflow_step(state, run_id, step_id, None, &failure).await;
|
|
12768
|
+
}
|
|
12769
|
+
let principal = match resolve_run_principal(state, run, RunKind::Workflow, run_id) {
|
|
12770
|
+
Ok(principal) if delegated_grants(state, &principal, "workflows:run") => principal,
|
|
12771
|
+
Ok(_) => {
|
|
12772
|
+
let failure = RunFailure::permanent(
|
|
12773
|
+
agent_execution::AUTHORIZATION_DENIED,
|
|
12774
|
+
"the run's principal may not run workflows",
|
|
12775
|
+
);
|
|
12776
|
+
return fail_workflow_step(state, run_id, step_id, None, &failure).await;
|
|
12777
|
+
}
|
|
12778
|
+
Err(failure) => return fail_workflow_step(state, run_id, step_id, None, &failure).await,
|
|
12779
|
+
};
|
|
12780
|
+
let claimed = transition_workflow_step(
|
|
12781
|
+
state,
|
|
12782
|
+
run_id,
|
|
12783
|
+
step_id,
|
|
12784
|
+
Some((worker.to_string(), 30_000)),
|
|
12785
|
+
None,
|
|
12786
|
+
)
|
|
12787
|
+
.await?;
|
|
12788
|
+
let claim_id = claimed.0.value["steps"]
|
|
12789
|
+
.as_array()
|
|
12790
|
+
.and_then(|steps| steps.iter().find(|step| step["id"] == step_id))
|
|
12791
|
+
.and_then(|step| step["claim_id"].as_str())
|
|
11579
12792
|
.ok_or_else(|| {
|
|
11580
12793
|
ApiError(
|
|
11581
12794
|
StatusCode::CONFLICT,
|
|
11582
|
-
"
|
|
12795
|
+
"worker claim was not materialized".to_string(),
|
|
11583
12796
|
)
|
|
11584
12797
|
})?
|
|
11585
|
-
.
|
|
11586
|
-
|
|
11587
|
-
|
|
12798
|
+
.to_string();
|
|
12799
|
+
let input = run["input"].clone();
|
|
12800
|
+
match execute_named_capability(state, step_id, &input, &CapabilityAccess::Delegated(&principal))
|
|
12801
|
+
{
|
|
12802
|
+
Ok(result) => {
|
|
12803
|
+
transition_workflow_step(state, run_id, step_id, None, Some((claim_id, result))).await?;
|
|
12804
|
+
}
|
|
12805
|
+
Err(error) => {
|
|
12806
|
+
let failure = agent_execution::classify_capability_error(
|
|
12807
|
+
error.0.as_u16(),
|
|
12808
|
+
&api_error_message(&error),
|
|
12809
|
+
);
|
|
12810
|
+
if failure.retryable && attempts + 1 < agent_execution::max_run_attempts() {
|
|
12811
|
+
release_workflow_step(state, run_id, step_id, &claim_id, &failure).await?;
|
|
12812
|
+
} else if failure.retryable {
|
|
12813
|
+
let exhausted = RunFailure::permanent(
|
|
12814
|
+
agent_execution::RETRY_EXHAUSTED,
|
|
12815
|
+
format!("{} after {} attempts", failure.message, attempts + 1),
|
|
12816
|
+
);
|
|
12817
|
+
fail_workflow_step(state, run_id, step_id, Some(&claim_id), &exhausted).await?;
|
|
12818
|
+
} else {
|
|
12819
|
+
fail_workflow_step(state, run_id, step_id, Some(&claim_id), &failure).await?;
|
|
12820
|
+
}
|
|
12821
|
+
}
|
|
12822
|
+
}
|
|
11588
12823
|
Ok(())
|
|
11589
12824
|
}
|
|
11590
12825
|
|
|
@@ -11638,6 +12873,17 @@ async fn sync_with_peer(
|
|
|
11638
12873
|
.json::<SyncPullResponse>()
|
|
11639
12874
|
.await?;
|
|
11640
12875
|
|
|
12876
|
+
// Two nodes configured as the Durable Work authority would each commit
|
|
12877
|
+
// work the other cannot see in time; both refuse until one is restarted
|
|
12878
|
+
// without `--work-authority`.
|
|
12879
|
+
if state.work_authority && pull.durable_work_authority {
|
|
12880
|
+
if !state
|
|
12881
|
+
.work_authority_conflict
|
|
12882
|
+
.swap(true, std::sync::atomic::Ordering::SeqCst)
|
|
12883
|
+
{
|
|
12884
|
+
tracing::error!(%peer, "two nodes are configured as the Durable Work authority; refusing Durable Work writes");
|
|
12885
|
+
}
|
|
12886
|
+
}
|
|
11641
12887
|
let mut incoming = pull.operations;
|
|
11642
12888
|
incoming.sort_by(|left, right| {
|
|
11643
12889
|
left.instance_id
|
|
@@ -12563,10 +13809,12 @@ async fn get_provenance(
|
|
|
12563
13809
|
|
|
12564
13810
|
async fn search_collection(
|
|
12565
13811
|
State(state): State<AppState>,
|
|
13812
|
+
Extension(principal): Extension<Principal>,
|
|
12566
13813
|
Path(collection): Path<String>,
|
|
12567
13814
|
Json(request): Json<SearchRequest>,
|
|
12568
13815
|
) -> Result<Json<Vec<RecordResponse>>, ApiError> {
|
|
12569
13816
|
validate_segment(&collection)?;
|
|
13817
|
+
authorize_capability_read(&state, &CapabilityAccess::Principal(&principal), &collection)?;
|
|
12570
13818
|
if request.query.len() > 512 || request.limit == 0 || request.limit > 500 {
|
|
12571
13819
|
return Err(ApiError(
|
|
12572
13820
|
StatusCode::BAD_REQUEST,
|
|
@@ -12616,7 +13864,12 @@ async fn execute_capability(
|
|
|
12616
13864
|
));
|
|
12617
13865
|
}
|
|
12618
13866
|
validate_segment(&name)?;
|
|
12619
|
-
Ok(Json(execute_named_capability(
|
|
13867
|
+
Ok(Json(execute_named_capability(
|
|
13868
|
+
&state,
|
|
13869
|
+
&name,
|
|
13870
|
+
&input,
|
|
13871
|
+
&CapabilityAccess::Principal(&principal),
|
|
13872
|
+
)?))
|
|
12620
13873
|
}
|
|
12621
13874
|
|
|
12622
13875
|
async fn execute_application_capability(
|
|
@@ -12663,7 +13916,15 @@ async fn execute_application_capability(
|
|
|
12663
13916
|
"capability is not externally invocable".into(),
|
|
12664
13917
|
));
|
|
12665
13918
|
}
|
|
12666
|
-
Ok(Json(execute_named_capability(
|
|
13919
|
+
Ok(Json(execute_named_capability(
|
|
13920
|
+
&state,
|
|
13921
|
+
&name,
|
|
13922
|
+
&payload,
|
|
13923
|
+
&CapabilityAccess::Application {
|
|
13924
|
+
actor: &principal.key_id,
|
|
13925
|
+
application_id: &input.application_id,
|
|
13926
|
+
},
|
|
13927
|
+
)?))
|
|
12667
13928
|
}
|
|
12668
13929
|
|
|
12669
13930
|
fn validate_capability_program(program: &CapabilityProgram) -> Result<(), ApiError> {
|
|
@@ -12722,9 +13983,14 @@ fn execute_named_capability(
|
|
|
12722
13983
|
state: &AppState,
|
|
12723
13984
|
name: &str,
|
|
12724
13985
|
input: &Value,
|
|
13986
|
+
access: &CapabilityAccess<'_>,
|
|
12725
13987
|
) -> Result<Value, ApiError> {
|
|
13988
|
+
#[cfg(debug_assertions)]
|
|
13989
|
+
if let Some(fault) = injected_capability_fault(name) {
|
|
13990
|
+
return Err(fault);
|
|
13991
|
+
}
|
|
12726
13992
|
if is_builtin_capability(name) {
|
|
12727
|
-
return execute_builtin_capability(state, name, input);
|
|
13993
|
+
return execute_builtin_capability(state, name, input, access);
|
|
12728
13994
|
}
|
|
12729
13995
|
let value = required_value(state, "_flow_capabilities", name, "capability not found")?;
|
|
12730
13996
|
let program: CapabilityProgram = serde_json::from_value(value).map_err(|error| {
|
|
@@ -12758,6 +14024,7 @@ fn execute_named_capability(
|
|
|
12758
14024
|
state,
|
|
12759
14025
|
"search",
|
|
12760
14026
|
&json!({"collection": collection, "query": query, "limit": limit.unwrap_or(50)}),
|
|
14027
|
+
access,
|
|
12761
14028
|
)?
|
|
12762
14029
|
}
|
|
12763
14030
|
CapabilityInstruction::FilterEq {
|
|
@@ -12873,12 +14140,14 @@ async fn start_workflow_run(
|
|
|
12873
14140
|
let step_states: Vec<Value> = steps.iter().enumerate().map(|(index, step)| json!({
|
|
12874
14141
|
"id": step, "status": if index == 0 { "ready" } else { "pending" }, "worker": null, "result": null
|
|
12875
14142
|
})).collect();
|
|
14143
|
+
let (authority, attribution) = run_authority(&state, RunKind::Workflow, &id, &principal)?;
|
|
12876
14144
|
let response = insert_canonical(
|
|
12877
14145
|
&state,
|
|
12878
14146
|
"_flow_workflow_runs",
|
|
12879
14147
|
&id,
|
|
12880
14148
|
json!({
|
|
12881
|
-
"id": id, "workflow": name, "status": "running", "input": request.input, "steps": step_states
|
|
14149
|
+
"id": id, "workflow": name, "status": "running", "input": request.input, "steps": step_states,
|
|
14150
|
+
"authority": authority, "principal": attribution,
|
|
12882
14151
|
}),
|
|
12883
14152
|
)?;
|
|
12884
14153
|
Ok((StatusCode::CREATED, Json(response)))
|
|
@@ -12886,9 +14155,12 @@ async fn start_workflow_run(
|
|
|
12886
14155
|
|
|
12887
14156
|
async fn get_workflow_run(
|
|
12888
14157
|
State(state): State<AppState>,
|
|
14158
|
+
Extension(principal): Extension<Principal>,
|
|
12889
14159
|
Path(id): Path<String>,
|
|
12890
14160
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
12891
|
-
get_canonical(&state, "_flow_workflow_runs", &id, "workflow run not found")
|
|
14161
|
+
let mut response = get_canonical(&state, "_flow_workflow_runs", &id, "workflow run not found")?;
|
|
14162
|
+
redact_run_results(&state, &principal, &mut response.0.value);
|
|
14163
|
+
Ok(response)
|
|
12892
14164
|
}
|
|
12893
14165
|
|
|
12894
14166
|
async fn claim_workflow_step(
|
|
@@ -12940,6 +14212,12 @@ async fn transition_workflow_step(
|
|
|
12940
14212
|
validate_segment(step)?;
|
|
12941
14213
|
let _guard = state.lifecycle_lock.lock().await;
|
|
12942
14214
|
let mut run = required_value(state, "_flow_workflow_runs", id, "workflow run not found")?;
|
|
14215
|
+
if run["status"] == agent_execution::STATUS_FAILED {
|
|
14216
|
+
return Err(ApiError::structured(
|
|
14217
|
+
StatusCode::CONFLICT,
|
|
14218
|
+
json!({"code": "WORKFLOW_RUN_TERMINAL", "message": "workflow run has failed"}),
|
|
14219
|
+
));
|
|
14220
|
+
}
|
|
12943
14221
|
let steps = run["steps"]
|
|
12944
14222
|
.as_array_mut()
|
|
12945
14223
|
.ok_or_else(|| ApiError(StatusCode::CONFLICT, "workflow run is invalid".to_string()))?;
|
|
@@ -12968,10 +14246,12 @@ async fn transition_workflow_step(
|
|
|
12968
14246
|
membership_epoch: state.cluster.get().epoch,
|
|
12969
14247
|
};
|
|
12970
14248
|
reserve_quorum_lease(state, &resource, &lease).await?;
|
|
14249
|
+
let attempt = steps[index]["attempt"].as_u64().unwrap_or(0) + 1;
|
|
12971
14250
|
steps[index]["status"] = json!("running");
|
|
12972
14251
|
steps[index]["worker"] = json!(worker);
|
|
12973
14252
|
steps[index]["claim_id"] = json!(claim_id);
|
|
12974
14253
|
steps[index]["lease_expires_ms"] = json!(expires_ms);
|
|
14254
|
+
steps[index]["attempt"] = json!(attempt);
|
|
12975
14255
|
} else {
|
|
12976
14256
|
if status != "running" {
|
|
12977
14257
|
return Err(ApiError(
|
|
@@ -13029,31 +14309,169 @@ async fn start_agent_run(
|
|
|
13029
14309
|
));
|
|
13030
14310
|
}
|
|
13031
14311
|
validate_segment(&name)?;
|
|
13032
|
-
required_value(&state, "_flow_agents", &name, "agent not found")?;
|
|
14312
|
+
let definition = required_value(&state, "_flow_agents", &name, "agent not found")?;
|
|
13033
14313
|
if request.goal.trim().is_empty() {
|
|
13034
14314
|
return Err(ApiError(
|
|
13035
14315
|
StatusCode::BAD_REQUEST,
|
|
13036
14316
|
"agent goal is required".to_string(),
|
|
13037
14317
|
));
|
|
13038
14318
|
}
|
|
14319
|
+
if let Some(criteria) = &request.completion {
|
|
14320
|
+
criteria.validate().map_err(|message| {
|
|
14321
|
+
ApiError::structured(
|
|
14322
|
+
StatusCode::BAD_REQUEST,
|
|
14323
|
+
json!({"code": "COMPLETION_CRITERIA_INVALID", "message": message}),
|
|
14324
|
+
)
|
|
14325
|
+
})?;
|
|
14326
|
+
}
|
|
14327
|
+
// Refuse a run that could only ever fail, instead of creating one.
|
|
14328
|
+
agent_capability(&state, &definition).map_err(|failure| {
|
|
14329
|
+
ApiError::structured(
|
|
14330
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
14331
|
+
json!({"code": failure.code, "message": failure.message}),
|
|
14332
|
+
)
|
|
14333
|
+
})?;
|
|
13039
14334
|
let id = next_id(&state, "agent");
|
|
14335
|
+
let (authority, attribution) = run_authority(&state, RunKind::Agent, &id, &principal)?;
|
|
13040
14336
|
let response = insert_canonical(
|
|
13041
14337
|
&state,
|
|
13042
14338
|
"_flow_agent_runs",
|
|
13043
14339
|
&id,
|
|
13044
14340
|
json!({
|
|
13045
14341
|
"id": id, "agent": name, "goal": request.goal, "input": request.input,
|
|
13046
|
-
"status":
|
|
14342
|
+
"status": agent_execution::STATUS_READY, "observations": [], "decisions": [],
|
|
14343
|
+
"completion": request.completion, "evidence": [], "attempt": 0,
|
|
14344
|
+
"authority": authority, "principal": attribution,
|
|
13047
14345
|
}),
|
|
13048
14346
|
)?;
|
|
13049
14347
|
Ok((StatusCode::CREATED, Json(response)))
|
|
13050
14348
|
}
|
|
13051
14349
|
|
|
14350
|
+
/// Sign the requesting principal's authority into a new run, and the
|
|
14351
|
+
/// attribution recorded beside it.
|
|
14352
|
+
fn run_authority(
|
|
14353
|
+
state: &AppState,
|
|
14354
|
+
kind: RunKind,
|
|
14355
|
+
run_id: &str,
|
|
14356
|
+
principal: &Principal,
|
|
14357
|
+
) -> Result<(Value, Value), ApiError> {
|
|
14358
|
+
let run_principal = RunPrincipal {
|
|
14359
|
+
id: principal.key_id.clone(),
|
|
14360
|
+
kind: principal.subject_type.clone(),
|
|
14361
|
+
scopes: principal.scopes.iter().cloned().collect(),
|
|
14362
|
+
};
|
|
14363
|
+
let grant = agent_execution::issue_run_authority(
|
|
14364
|
+
&state.grant_signer,
|
|
14365
|
+
kind,
|
|
14366
|
+
run_id,
|
|
14367
|
+
&state.namespace,
|
|
14368
|
+
&run_principal,
|
|
14369
|
+
unix_seconds_i64(),
|
|
14370
|
+
)
|
|
14371
|
+
.map_err(|message| ApiError(StatusCode::INTERNAL_SERVER_ERROR, message))?;
|
|
14372
|
+
let authority = serde_json::to_value(grant)
|
|
14373
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
|
14374
|
+
Ok((
|
|
14375
|
+
authority,
|
|
14376
|
+
json!({"id": principal.key_id, "kind": principal.subject_type}),
|
|
14377
|
+
))
|
|
14378
|
+
}
|
|
14379
|
+
|
|
14380
|
+
/// The capability an agent run executes: the first one its definition
|
|
14381
|
+
/// declares. It must be executable now; one that is not is a permanent error,
|
|
14382
|
+
/// never a run left waiting for a capability that does not exist.
|
|
14383
|
+
fn agent_capability(state: &AppState, definition: &Value) -> Result<String, RunFailure> {
|
|
14384
|
+
let Some(name) = definition["capabilities"]
|
|
14385
|
+
.as_array()
|
|
14386
|
+
.and_then(|values| values.first())
|
|
14387
|
+
.and_then(Value::as_str)
|
|
14388
|
+
else {
|
|
14389
|
+
return Err(RunFailure::permanent(
|
|
14390
|
+
agent_execution::CAPABILITY_UNKNOWN,
|
|
14391
|
+
"agent declares no capability",
|
|
14392
|
+
));
|
|
14393
|
+
};
|
|
14394
|
+
match is_executable_capability(state, name) {
|
|
14395
|
+
Ok(true) => Ok(name.to_string()),
|
|
14396
|
+
_ => Err(RunFailure::permanent(
|
|
14397
|
+
agent_execution::CAPABILITY_UNKNOWN,
|
|
14398
|
+
format!("capability {name} is not defined on this node"),
|
|
14399
|
+
)),
|
|
14400
|
+
}
|
|
14401
|
+
}
|
|
14402
|
+
|
|
14403
|
+
/// The principal a run may act for at execution time.
|
|
14404
|
+
///
|
|
14405
|
+
/// The signed authority binds scopes to the run. When authentication is on,
|
|
14406
|
+
/// the key is also re-read, and only scopes it still holds remain: a revoked
|
|
14407
|
+
/// key acts for nothing, and a narrowed key never acts with its old breadth.
|
|
14408
|
+
fn resolve_run_principal(
|
|
14409
|
+
state: &AppState,
|
|
14410
|
+
run: &Value,
|
|
14411
|
+
kind: RunKind,
|
|
14412
|
+
run_id: &str,
|
|
14413
|
+
) -> Result<RunPrincipal, RunFailure> {
|
|
14414
|
+
let mut principal = agent_execution::verify_run_authority(
|
|
14415
|
+
&state.grant_signer,
|
|
14416
|
+
&run["authority"],
|
|
14417
|
+
kind,
|
|
14418
|
+
run_id,
|
|
14419
|
+
&state.namespace,
|
|
14420
|
+
unix_seconds_i64(),
|
|
14421
|
+
)?;
|
|
14422
|
+
if state.auth_enabled && principal.kind != "human" {
|
|
14423
|
+
let Some((current, current_kind)) = state.keys.active_scopes(&principal.id, &state.namespace)
|
|
14424
|
+
else {
|
|
14425
|
+
return Err(RunFailure::permanent(
|
|
14426
|
+
agent_execution::AUTHORIZATION_DENIED,
|
|
14427
|
+
"the run's principal is no longer active",
|
|
14428
|
+
));
|
|
14429
|
+
};
|
|
14430
|
+
if current_kind != principal.kind {
|
|
14431
|
+
return Err(RunFailure::permanent(
|
|
14432
|
+
agent_execution::AUTHORIZATION_DENIED,
|
|
14433
|
+
"the run's principal changed type",
|
|
14434
|
+
));
|
|
14435
|
+
}
|
|
14436
|
+
principal.scopes =
|
|
14437
|
+
agent_execution::effective_scopes(&principal.scopes, ¤t.into_iter().collect());
|
|
14438
|
+
}
|
|
14439
|
+
Ok(principal)
|
|
14440
|
+
}
|
|
14441
|
+
|
|
13052
14442
|
async fn get_agent_run(
|
|
13053
14443
|
State(state): State<AppState>,
|
|
14444
|
+
Extension(principal): Extension<Principal>,
|
|
13054
14445
|
Path(id): Path<String>,
|
|
13055
14446
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13056
|
-
get_canonical(&state, "_flow_agent_runs", &id, "agent run not found")
|
|
14447
|
+
let mut response = get_canonical(&state, "_flow_agent_runs", &id, "agent run not found")?;
|
|
14448
|
+
redact_run_results(&state, &principal, &mut response.0.value);
|
|
14449
|
+
Ok(response)
|
|
14450
|
+
}
|
|
14451
|
+
|
|
14452
|
+
/// Remove the state a run read from a response to a reader who may not read
|
|
14453
|
+
/// state. A run's observations hold records read under its caller's
|
|
14454
|
+
/// authority; `agents:read` alone must not disclose them.
|
|
14455
|
+
fn redact_run_results(state: &AppState, principal: &Principal, run: &mut Value) {
|
|
14456
|
+
if request_principal_grants(state, principal, "state:read") {
|
|
14457
|
+
return;
|
|
14458
|
+
}
|
|
14459
|
+
let Some(object) = run.as_object_mut() else {
|
|
14460
|
+
return;
|
|
14461
|
+
};
|
|
14462
|
+
for field in ["observations", "decisions"] {
|
|
14463
|
+
if object.contains_key(field) {
|
|
14464
|
+
object.insert(field.into(), json!([]));
|
|
14465
|
+
}
|
|
14466
|
+
}
|
|
14467
|
+
if let Some(steps) = object.get_mut("steps").and_then(Value::as_array_mut) {
|
|
14468
|
+
for step in steps {
|
|
14469
|
+
if step.get("result").is_some_and(|result| !result.is_null()) {
|
|
14470
|
+
step["result"] = Value::Null;
|
|
14471
|
+
}
|
|
14472
|
+
}
|
|
14473
|
+
}
|
|
14474
|
+
object.insert("results_redacted".into(), json!(true));
|
|
13057
14475
|
}
|
|
13058
14476
|
|
|
13059
14477
|
async fn add_agent_observation(
|
|
@@ -13061,12 +14479,11 @@ async fn add_agent_observation(
|
|
|
13061
14479
|
Path(id): Path<String>,
|
|
13062
14480
|
Json(request): Json<Observation>,
|
|
13063
14481
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13064
|
-
mutate_agent_run(&state, &id, |run| {
|
|
14482
|
+
mutate_agent_run(&state, &id, agent_execution::STATUS_DELIBERATING, |run| {
|
|
13065
14483
|
run["observations"]
|
|
13066
14484
|
.as_array_mut()
|
|
13067
14485
|
.expect("validated agent run")
|
|
13068
14486
|
.push(request.observation);
|
|
13069
|
-
run["status"] = json!("deliberating");
|
|
13070
14487
|
})
|
|
13071
14488
|
.await
|
|
13072
14489
|
}
|
|
@@ -13076,32 +14493,44 @@ async fn add_agent_decision(
|
|
|
13076
14493
|
Path(id): Path<String>,
|
|
13077
14494
|
Json(request): Json<Decision>,
|
|
13078
14495
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13079
|
-
|
|
14496
|
+
// Completion is decided by the runtime from criteria it verifies, never by
|
|
14497
|
+
// a participant saying so — whatever evidence the request carries.
|
|
14498
|
+
if request.completed {
|
|
14499
|
+
return Err(ApiError::structured(
|
|
14500
|
+
StatusCode::CONFLICT,
|
|
14501
|
+
json!({
|
|
14502
|
+
"code": "AGENT_COMPLETION_NOT_CLIENT_CONTROLLED",
|
|
14503
|
+
"message": "a run completes only when the runtime verifies its declared completion criteria",
|
|
14504
|
+
}),
|
|
14505
|
+
));
|
|
14506
|
+
}
|
|
14507
|
+
mutate_agent_run(&state, &id, agent_execution::STATUS_ACTING, |run| {
|
|
13080
14508
|
run["decisions"]
|
|
13081
14509
|
.as_array_mut()
|
|
13082
14510
|
.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
|
-
});
|
|
14511
|
+
.push(json!({"decision": request.decision, "action": request.action, "source": "client"}));
|
|
13089
14512
|
})
|
|
13090
14513
|
.await
|
|
13091
14514
|
}
|
|
13092
14515
|
|
|
14516
|
+
/// Append a participant's annotation to a run that is still open.
|
|
14517
|
+
///
|
|
14518
|
+
/// Terminal outcomes are never rewritten. While the worker holds the run, the
|
|
14519
|
+
/// annotation is kept but the status is the worker's.
|
|
13093
14520
|
async fn mutate_agent_run<F: FnOnce(&mut Value)>(
|
|
13094
14521
|
state: &AppState,
|
|
13095
14522
|
id: &str,
|
|
14523
|
+
annotation: &str,
|
|
13096
14524
|
change: F,
|
|
13097
14525
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
13098
14526
|
validate_segment(id)?;
|
|
13099
14527
|
let _guard = state.lifecycle_lock.lock().await;
|
|
13100
14528
|
let mut run = required_value(state, "_flow_agent_runs", id, "agent run not found")?;
|
|
13101
|
-
|
|
13102
|
-
|
|
14529
|
+
let status = run["status"].as_str().unwrap_or_default().to_string();
|
|
14530
|
+
if agent_execution::is_terminal(&status) {
|
|
14531
|
+
return Err(ApiError::structured(
|
|
13103
14532
|
StatusCode::CONFLICT,
|
|
13104
|
-
"agent run is already
|
|
14533
|
+
json!({"code": "AGENT_RUN_TERMINAL", "message": format!("agent run is already {status}")}),
|
|
13105
14534
|
));
|
|
13106
14535
|
}
|
|
13107
14536
|
if !run["observations"].is_array() || !run["decisions"].is_array() {
|
|
@@ -13111,6 +14540,9 @@ async fn mutate_agent_run<F: FnOnce(&mut Value)>(
|
|
|
13111
14540
|
));
|
|
13112
14541
|
}
|
|
13113
14542
|
change(&mut run);
|
|
14543
|
+
if status != agent_execution::STATUS_RUNNING {
|
|
14544
|
+
run["status"] = json!(annotation);
|
|
14545
|
+
}
|
|
13114
14546
|
put_canonical(state, "_flow_agent_runs", id, run)
|
|
13115
14547
|
}
|
|
13116
14548
|
|
|
@@ -14214,6 +15646,7 @@ async fn commit_transaction(
|
|
|
14214
15646
|
record_preconditions.push(precondition);
|
|
14215
15647
|
}
|
|
14216
15648
|
for operation in request.operations {
|
|
15649
|
+
ensure_not_runtime_owned(&operation.collection)?;
|
|
14217
15650
|
let key = record_key(&operation.collection, &operation.id)?;
|
|
14218
15651
|
if !seen.insert(key.clone()) {
|
|
14219
15652
|
return Err(ApiError(
|
|
@@ -14287,7 +15720,7 @@ async fn commit_transaction(
|
|
|
14287
15720
|
"committed": false,
|
|
14288
15721
|
"code": "PRECONDITION_FAILED",
|
|
14289
15722
|
"error": failure.to_string(),
|
|
14290
|
-
"failure":
|
|
15723
|
+
"failure": failure.public_json(&feltdb::ReadLayout::Direct),
|
|
14291
15724
|
}),
|
|
14292
15725
|
),
|
|
14293
15726
|
FlowError::CapabilityError(message) if message.starts_with("INVALID_READ_SET:") => {
|
|
@@ -14304,7 +15737,29 @@ async fn commit_transaction(
|
|
|
14304
15737
|
}),
|
|
14305
15738
|
)
|
|
14306
15739
|
}
|
|
14307
|
-
|
|
15740
|
+
// The commit could not be made durable. It is neither a lost
|
|
15741
|
+
// race nor a success, and answering 409 here once let a caller
|
|
15742
|
+
// read a storage failure as "someone else won". The outcome is
|
|
15743
|
+
// to be treated as unknown and reconciled by retrying with the
|
|
15744
|
+
// same transaction id.
|
|
15745
|
+
storage @ (FlowError::Io(_) | FlowError::Serde(_)) => ApiError::structured(
|
|
15746
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
15747
|
+
json!({
|
|
15748
|
+
"committed": "unknown",
|
|
15749
|
+
"code": "DURABILITY_FAILURE",
|
|
15750
|
+
"message": storage.to_string(),
|
|
15751
|
+
"transaction_id": request.transaction_id,
|
|
15752
|
+
}),
|
|
15753
|
+
),
|
|
15754
|
+
other => ApiError::structured(
|
|
15755
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
15756
|
+
json!({
|
|
15757
|
+
"committed": "unknown",
|
|
15758
|
+
"code": "EXECUTION_FAILED",
|
|
15759
|
+
"message": other.to_string(),
|
|
15760
|
+
"transaction_id": request.transaction_id,
|
|
15761
|
+
}),
|
|
15762
|
+
),
|
|
14308
15763
|
}
|
|
14309
15764
|
})?;
|
|
14310
15765
|
let operation_ids = state
|
|
@@ -14334,6 +15789,21 @@ async fn commit_transaction(
|
|
|
14334
15789
|
))
|
|
14335
15790
|
}
|
|
14336
15791
|
|
|
15792
|
+
/// Refuse a raw write to a collection only the runtime writes.
|
|
15793
|
+
///
|
|
15794
|
+
/// A run's status, retry state, evidence and authority decide what the worker
|
|
15795
|
+
/// does and what the run reports. Letting `/collections` or `/transactions`
|
|
15796
|
+
/// write them would let any `state:write` caller declare a run completed.
|
|
15797
|
+
fn ensure_not_runtime_owned(collection: &str) -> Result<(), ApiError> {
|
|
15798
|
+
if agent_execution::is_runtime_owned_collection(collection) {
|
|
15799
|
+
return Err(ApiError::structured(
|
|
15800
|
+
StatusCode::FORBIDDEN,
|
|
15801
|
+
json!({"code": "RUNTIME_OWNED_COLLECTION", "message": "this collection is written only by the runtime"}),
|
|
15802
|
+
));
|
|
15803
|
+
}
|
|
15804
|
+
Ok(())
|
|
15805
|
+
}
|
|
15806
|
+
|
|
14337
15807
|
async fn create_record(
|
|
14338
15808
|
State(state): State<AppState>,
|
|
14339
15809
|
Path(collection): Path<String>,
|
|
@@ -14341,6 +15811,7 @@ async fn create_record(
|
|
|
14341
15811
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
14342
15812
|
let _handler =
|
|
14343
15813
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
15814
|
+
ensure_not_runtime_owned(&collection)?;
|
|
14344
15815
|
if collection == PROPOSAL_COLLECTION
|
|
14345
15816
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
14346
15817
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -14389,6 +15860,7 @@ async fn update_record(
|
|
|
14389
15860
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
14390
15861
|
let _handler =
|
|
14391
15862
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
15863
|
+
ensure_not_runtime_owned(&collection)?;
|
|
14392
15864
|
if collection == PROPOSAL_COLLECTION
|
|
14393
15865
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
14394
15866
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -14428,6 +15900,7 @@ async fn compare_and_set_record(
|
|
|
14428
15900
|
) -> Result<Response, ApiError> {
|
|
14429
15901
|
let _handler =
|
|
14430
15902
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
15903
|
+
ensure_not_runtime_owned(&collection)?;
|
|
14431
15904
|
let key = record_key(&collection, &id)?;
|
|
14432
15905
|
let result = state.db.compare_and_set_json(
|
|
14433
15906
|
&key,
|
|
@@ -14519,6 +15992,7 @@ async fn delete_record(
|
|
|
14519
15992
|
) -> Result<StatusCode, ApiError> {
|
|
14520
15993
|
let _handler =
|
|
14521
15994
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
15995
|
+
ensure_not_runtime_owned(&collection)?;
|
|
14522
15996
|
if collection == PROPOSAL_COLLECTION
|
|
14523
15997
|
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
14524
15998
|
|| collection == CONVERGENCE_COLLECTION
|
|
@@ -15728,7 +17202,8 @@ async fn control_plane_agent(
|
|
|
15728
17202
|
"workload store unavailable".into(),
|
|
15729
17203
|
)
|
|
15730
17204
|
})?
|
|
15731
|
-
.
|
|
17205
|
+
.fresh_list(&tenant, &application_id)
|
|
17206
|
+
.map_err(workload_error)?
|
|
15732
17207
|
.into_iter()
|
|
15733
17208
|
.filter(|workload| {
|
|
15734
17209
|
serde_json::to_value(&workload.requested_by)
|
|
@@ -16323,3 +17798,32 @@ mod authority_gate_tests {
|
|
|
16323
17798
|
}
|
|
16324
17799
|
}
|
|
16325
17800
|
}
|
|
17801
|
+
|
|
17802
|
+
#[cfg(test)]
|
|
17803
|
+
mod backup_output_tests {
|
|
17804
|
+
use super::backup_output_path;
|
|
17805
|
+
use std::path::PathBuf;
|
|
17806
|
+
|
|
17807
|
+
#[test]
|
|
17808
|
+
fn a_confined_server_writes_named_bundles_inside_its_backup_directory() {
|
|
17809
|
+
let root = Some("/data/backups".into());
|
|
17810
|
+
assert_eq!(
|
|
17811
|
+
backup_output_path("nightly-1", root.clone()).unwrap(),
|
|
17812
|
+
PathBuf::from("/data/backups/nightly-1")
|
|
17813
|
+
);
|
|
17814
|
+
for refused in ["/data/state.log", "../state.log", "a/b", "..", ".", "", "/"] {
|
|
17815
|
+
assert!(
|
|
17816
|
+
backup_output_path(refused, root.clone()).is_err(),
|
|
17817
|
+
"{refused:?} must be refused"
|
|
17818
|
+
);
|
|
17819
|
+
}
|
|
17820
|
+
}
|
|
17821
|
+
|
|
17822
|
+
#[test]
|
|
17823
|
+
fn a_self_hosted_server_uses_the_operator_path() {
|
|
17824
|
+
assert_eq!(
|
|
17825
|
+
backup_output_path("/srv/backups/x", None).unwrap(),
|
|
17826
|
+
PathBuf::from("/srv/backups/x")
|
|
17827
|
+
);
|
|
17828
|
+
}
|
|
17829
|
+
}
|