@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
|
@@ -1020,19 +1020,25 @@ fn retention_cost_grows_with_the_window_and_inflates_the_log() {
|
|
|
1020
1020
|
|
|
1021
1021
|
// Compare the best of repeated independent runs. The workspace executes
|
|
1022
1022
|
// other crash/soak processes concurrently, so a single wall-clock sample
|
|
1023
|
-
// can measure scheduler delay rather than retention work.
|
|
1024
|
-
//
|
|
1025
|
-
//
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1023
|
+
// can measure scheduler delay rather than retention work. The minimum
|
|
1024
|
+
// filters that one-sided noise, but only if every configuration is sampled
|
|
1025
|
+
// across the same stretch of time: sampling them one after another lets a
|
|
1026
|
+
// busy sibling inflate one configuration's every sample and not another's.
|
|
1027
|
+
// So each round runs all three, and each keeps its own best. Every sample
|
|
1028
|
+
// still performs the complete durable workload.
|
|
1029
|
+
let mut none = (std::time::Duration::MAX, 0u64);
|
|
1030
|
+
let mut small = (std::time::Duration::MAX, 0u64);
|
|
1031
|
+
let mut large = std::time::Duration::MAX;
|
|
1032
|
+
for _ in 0..9 {
|
|
1033
|
+
let sample = run(None);
|
|
1034
|
+
if sample.0 < none.0 { none = sample; }
|
|
1035
|
+
let sample = run(Some(5));
|
|
1036
|
+
if sample.0 < small.0 { small = sample; }
|
|
1037
|
+
large = large.min(run(Some(50)).0);
|
|
1031
1038
|
}
|
|
1032
|
-
|
|
1033
|
-
let (
|
|
1034
|
-
let
|
|
1035
|
-
let (large_time, _) = sample(|| run(Some(50)));
|
|
1039
|
+
let (none_time, none_bytes) = none;
|
|
1040
|
+
let (small_time, small_bytes) = small;
|
|
1041
|
+
let large_time = large;
|
|
1036
1042
|
|
|
1037
1043
|
assert!(
|
|
1038
1044
|
large_time > small_time,
|
|
@@ -215,14 +215,26 @@ fn a_barrier_per_write_costs_more_than_none() {
|
|
|
215
215
|
let directory = TempDir::new().unwrap();
|
|
216
216
|
let workload = WORKLOADS[0];
|
|
217
217
|
|
|
218
|
-
|
|
218
|
+
// A stall can only invert this ordering by inflating the cheap, flushed
|
|
219
|
+
// run, so take the best of several of those against one synced run. The
|
|
220
|
+
// minimum is the cost of the mode with scheduler contention taken out;
|
|
221
|
+
// repeating the slow synced run would only add load to sibling timing
|
|
222
|
+
// tests in this concurrently-run binary.
|
|
219
223
|
let synced = run(directory.path(), workload, DurabilityMode::Synced);
|
|
224
|
+
let flushed = run(directory.path(), workload, DurabilityMode::Flushed);
|
|
225
|
+
let mut best_flushed = flushed.elapsed;
|
|
226
|
+
for sample in 0..4 {
|
|
227
|
+
// A fresh log per sample: a reused path would append to its history.
|
|
228
|
+
let sample_directory = directory.path().join(format!("flushed-{sample}"));
|
|
229
|
+
std::fs::create_dir_all(&sample_directory).unwrap();
|
|
230
|
+
let repeat = run(&sample_directory, workload, DurabilityMode::Flushed);
|
|
231
|
+
best_flushed = best_flushed.min(repeat.elapsed);
|
|
232
|
+
}
|
|
220
233
|
|
|
221
234
|
assert!(
|
|
222
|
-
synced.elapsed >
|
|
223
|
-
"a barrier per write must cost something: synced {:?} vs flushed {:?}",
|
|
224
|
-
synced.elapsed
|
|
225
|
-
flushed.elapsed
|
|
235
|
+
synced.elapsed > best_flushed,
|
|
236
|
+
"a barrier per write must cost something: synced {:?} vs flushed {best_flushed:?}",
|
|
237
|
+
synced.elapsed
|
|
226
238
|
);
|
|
227
239
|
|
|
228
240
|
// Durability is the only thing that changed. The database is identical.
|
|
@@ -16,7 +16,7 @@ exclude = ["tests/", "target/"]
|
|
|
16
16
|
# The wire types are the server's own: the request and response documents are defined once, in
|
|
17
17
|
# `feltdb`, and both sides serialize the same structs. Exact, because the modules they come from are
|
|
18
18
|
# not yet covered by `feltdb`'s compatibility policy; the workspace releases both at one version.
|
|
19
|
-
feltdb = { version = "=0.
|
|
19
|
+
feltdb = { version = "=0.3.0", path = "../feltdb" }
|
|
20
20
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
|
21
21
|
serde = { version = "1", features = ["derive"] }
|
|
22
22
|
serde_json = "1"
|
|
@@ -24,7 +24,7 @@ hmac = "0.12"
|
|
|
24
24
|
# Exact: the server uses `unstable-internals`, which carries no semver promise, so a release of
|
|
25
25
|
# this crate is built against the one `feltdb` release it was tested with. The workspace shares one
|
|
26
26
|
# version, so this moves with every release (a mismatch fails to resolve, it cannot drift silently).
|
|
27
|
-
feltdb = { version = "=0.
|
|
27
|
+
feltdb = { version = "=0.3.0", path = "../feltdb", features = ["unstable-internals"] }
|
|
28
28
|
futures-core = "0.3"
|
|
29
29
|
rand = "0.8"
|
|
30
30
|
ring = "0.17"
|
|
@@ -53,7 +53,12 @@ Implemented in this slice:
|
|
|
53
53
|
- collection list/create/get/update/delete endpoints
|
|
54
54
|
- `GET /events` as a server-sent event stream
|
|
55
55
|
- durable, hashed API keys with namespace and `state:read`, `state:write`, or `events:read` scopes
|
|
56
|
-
- `GET /metrics` request, authorization, state, and event counters
|
|
56
|
+
- `GET /metrics` request, authorization, state, and event counters (requires `operator:read`)
|
|
57
|
+
- `GET /v1/status` operator status: release identity, a write/fsync/read-back storage probe, the
|
|
58
|
+
last recorded backup and the last verified restore (requires `operator:read`)
|
|
59
|
+
- `/api/keys` listing (never secrets), issuing, `POST /api/keys/{id}/rotate` and revocation
|
|
60
|
+
(requires `keys:admin`; a caller can never delegate a scope it does not hold). See
|
|
61
|
+
[`docs/reference/managed-operator-surface.md`](../../docs/reference/managed-operator-surface.md).
|
|
57
62
|
- authenticated `POST /sync/pull` and `POST /sync/push` operation exchange
|
|
58
63
|
- durable, transitive operation history with content-hash verification, deduplication, and gap rejection
|
|
59
64
|
|
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
//! Authority, completion, and failure rules for runs the autonomous worker executes.
|
|
2
|
+
//!
|
|
3
|
+
//! Agent and workflow runs are created by an authenticated request and executed
|
|
4
|
+
//! later by the worker, which runs with the node's own authority. Without the
|
|
5
|
+
//! rules here, every capability a run invokes would read with that node
|
|
6
|
+
//! authority, and a run would end `completed` whenever a capability returned.
|
|
7
|
+
//!
|
|
8
|
+
//! - **Authority.** When a run is created, the server signs a grant that binds
|
|
9
|
+
//! the requesting principal's scopes to that one run ([`issue_run_authority`]).
|
|
10
|
+
//! The worker verifies it before executing ([`verify_run_authority`]) and
|
|
11
|
+
//! authorizes every collection read against it. The grant is signed with the
|
|
12
|
+
//! server's existing [`GrantSigner`], so a run record written or replayed by
|
|
13
|
+
//! anything other than the server — including a grant copied from another
|
|
14
|
+
//! run — is refused rather than trusted.
|
|
15
|
+
//! - **Completion.** An agent run completes only when criteria declared at
|
|
16
|
+
//! creation are satisfied by the result the runtime itself observed
|
|
17
|
+
//! ([`CompletionCriteria`]). The goal text is never evaluated. A run without
|
|
18
|
+
//! verifiable criteria ends `failed` with [`COMPLETION_UNVERIFIABLE`]; nothing
|
|
19
|
+
//! a client posts can complete a run.
|
|
20
|
+
//! - **Failure.** Permanent errors end the run at once; transient errors retry
|
|
21
|
+
//! at most [`max_run_attempts`] times, under the workload retry policy's bound.
|
|
22
|
+
|
|
23
|
+
use std::collections::BTreeSet;
|
|
24
|
+
|
|
25
|
+
use feltdb::authorization::{
|
|
26
|
+
Grant, GrantConstraints, GrantSigner, GrantStatus, SignedGrant, Subject,
|
|
27
|
+
AUTHORIZATION_CONTRACT_VERSION,
|
|
28
|
+
};
|
|
29
|
+
use feltdb::workload::WorkloadRetryPolicy;
|
|
30
|
+
use serde::{Deserialize, Serialize};
|
|
31
|
+
use serde_json::{json, Value};
|
|
32
|
+
|
|
33
|
+
/// The issuer recorded on every run authority grant.
|
|
34
|
+
pub const RUN_AUTHORITY_ISSUER: &str = "feltdb-run-authority";
|
|
35
|
+
/// How long a run's delegated authority stays valid after the run is created.
|
|
36
|
+
///
|
|
37
|
+
/// A run that has not finished by then fails with [`AUTHORIZATION_DENIED`]
|
|
38
|
+
/// rather than executing on a delegation its caller may no longer intend.
|
|
39
|
+
pub const RUN_AUTHORITY_TTL_SECONDS: i64 = 24 * 60 * 60;
|
|
40
|
+
|
|
41
|
+
/// Run statuses written by the runtime.
|
|
42
|
+
pub const STATUS_READY: &str = "ready";
|
|
43
|
+
pub const STATUS_RUNNING: &str = "running";
|
|
44
|
+
pub const STATUS_COMPLETED: &str = "completed";
|
|
45
|
+
pub const STATUS_FAILED: &str = "failed";
|
|
46
|
+
/// Non-terminal annotations an external participant may set on an agent run.
|
|
47
|
+
pub const STATUS_DELIBERATING: &str = "deliberating";
|
|
48
|
+
pub const STATUS_ACTING: &str = "acting";
|
|
49
|
+
|
|
50
|
+
/// Stable failure codes stored in a failed run's `failure.code`.
|
|
51
|
+
pub const AUTHORIZATION_DENIED: &str = "AUTHORIZATION_DENIED";
|
|
52
|
+
pub const AGENT_NOT_FOUND: &str = "AGENT_NOT_FOUND";
|
|
53
|
+
pub const CAPABILITY_UNKNOWN: &str = "CAPABILITY_UNKNOWN";
|
|
54
|
+
pub const CAPABILITY_INPUT_INVALID: &str = "CAPABILITY_INPUT_INVALID";
|
|
55
|
+
pub const RETRY_EXHAUSTED: &str = "RETRY_EXHAUSTED";
|
|
56
|
+
pub const COMPLETION_CRITERIA_UNMET: &str = "COMPLETION_CRITERIA_UNMET";
|
|
57
|
+
pub const COMPLETION_UNVERIFIABLE: &str = "COMPLETION_UNVERIFIABLE";
|
|
58
|
+
pub const RUN_INVALID: &str = "RUN_INVALID";
|
|
59
|
+
|
|
60
|
+
/// Which kind of run a grant was issued for. It is part of the grant subject,
|
|
61
|
+
/// so an agent run's authority cannot be presented by a workflow run.
|
|
62
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
63
|
+
pub enum RunKind {
|
|
64
|
+
Agent,
|
|
65
|
+
Workflow,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
impl RunKind {
|
|
69
|
+
fn subject(self, run_id: &str) -> Subject {
|
|
70
|
+
match self {
|
|
71
|
+
Self::Agent => Subject::Agent(format!("agent-run:{run_id}")),
|
|
72
|
+
Self::Workflow => Subject::Workload(format!("workflow-run:{run_id}")),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// The principal a run acts for, as the server authenticated it.
|
|
78
|
+
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
79
|
+
pub struct RunPrincipal {
|
|
80
|
+
pub id: String,
|
|
81
|
+
/// `service`, `worker`, or `human`, exactly as the authenticator decided.
|
|
82
|
+
pub kind: String,
|
|
83
|
+
pub scopes: BTreeSet<String>,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/// Sign the authority a run executes under.
|
|
87
|
+
pub fn issue_run_authority(
|
|
88
|
+
signer: &GrantSigner,
|
|
89
|
+
kind: RunKind,
|
|
90
|
+
run_id: &str,
|
|
91
|
+
tenant_id: &str,
|
|
92
|
+
principal: &RunPrincipal,
|
|
93
|
+
issued_at: i64,
|
|
94
|
+
) -> Result<SignedGrant, String> {
|
|
95
|
+
let mut constraints = GrantConstraints::default();
|
|
96
|
+
constraints
|
|
97
|
+
.subject_attributes
|
|
98
|
+
.insert("principal_id".into(), principal.id.clone());
|
|
99
|
+
constraints
|
|
100
|
+
.subject_attributes
|
|
101
|
+
.insert("principal_kind".into(), principal.kind.clone());
|
|
102
|
+
signer
|
|
103
|
+
.sign(Grant {
|
|
104
|
+
contract_version: AUTHORIZATION_CONTRACT_VERSION,
|
|
105
|
+
id: format!("run-authority:{run_id}"),
|
|
106
|
+
issuer: Subject::Service(RUN_AUTHORITY_ISSUER.into()),
|
|
107
|
+
subject: kind.subject(run_id),
|
|
108
|
+
tenant_id: tenant_id.into(),
|
|
109
|
+
application_id: tenant_id.into(),
|
|
110
|
+
capabilities: principal.scopes.clone(),
|
|
111
|
+
resources: Vec::new(),
|
|
112
|
+
constraints,
|
|
113
|
+
issued_at,
|
|
114
|
+
expires_at: issued_at.saturating_add(RUN_AUTHORITY_TTL_SECONDS),
|
|
115
|
+
parent_grant: None,
|
|
116
|
+
revision: 1,
|
|
117
|
+
status: GrantStatus::Active,
|
|
118
|
+
})
|
|
119
|
+
.map_err(|error| error.message)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/// Verify a run's stored authority and return the principal it delegates.
|
|
123
|
+
///
|
|
124
|
+
/// Every refusal is [`AUTHORIZATION_DENIED`] and permanent: retrying cannot
|
|
125
|
+
/// change a signature, a binding, or an expiry.
|
|
126
|
+
pub fn verify_run_authority(
|
|
127
|
+
signer: &GrantSigner,
|
|
128
|
+
stored: &Value,
|
|
129
|
+
kind: RunKind,
|
|
130
|
+
run_id: &str,
|
|
131
|
+
tenant_id: &str,
|
|
132
|
+
now: i64,
|
|
133
|
+
) -> Result<RunPrincipal, RunFailure> {
|
|
134
|
+
let denied = |message: &str| RunFailure::permanent(AUTHORIZATION_DENIED, message);
|
|
135
|
+
if stored.is_null() {
|
|
136
|
+
return Err(denied("run carries no delegated authority"));
|
|
137
|
+
}
|
|
138
|
+
let signed: SignedGrant =
|
|
139
|
+
serde_json::from_value(stored.clone()).map_err(|_| denied("run authority is malformed"))?;
|
|
140
|
+
signer
|
|
141
|
+
.verify_signature(&signed)
|
|
142
|
+
.map_err(|_| denied("run authority signature is invalid"))?;
|
|
143
|
+
let grant = &signed.grant;
|
|
144
|
+
if grant.issuer != Subject::Service(RUN_AUTHORITY_ISSUER.into())
|
|
145
|
+
|| grant.subject != kind.subject(run_id)
|
|
146
|
+
|| grant.id != format!("run-authority:{run_id}")
|
|
147
|
+
{
|
|
148
|
+
return Err(denied("run authority was issued for a different run"));
|
|
149
|
+
}
|
|
150
|
+
if grant.tenant_id != tenant_id || grant.application_id != tenant_id {
|
|
151
|
+
return Err(denied("run authority belongs to a different tenant"));
|
|
152
|
+
}
|
|
153
|
+
if grant.status != GrantStatus::Active || now >= grant.expires_at || now < grant.issued_at {
|
|
154
|
+
return Err(denied("run authority is not current"));
|
|
155
|
+
}
|
|
156
|
+
let attribute = |name: &str| grant.constraints.subject_attributes.get(name).cloned();
|
|
157
|
+
let (Some(id), Some(kind)) = (attribute("principal_id"), attribute("principal_kind")) else {
|
|
158
|
+
return Err(denied("run authority names no principal"));
|
|
159
|
+
};
|
|
160
|
+
Ok(RunPrincipal {
|
|
161
|
+
id,
|
|
162
|
+
kind,
|
|
163
|
+
scopes: grant.capabilities.clone(),
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/// A machine-checkable condition the runtime evaluates against the result it
|
|
168
|
+
/// observed. The set is closed: anything else is refused when the run starts.
|
|
169
|
+
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
170
|
+
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
|
171
|
+
pub enum CompletionCriteria {
|
|
172
|
+
/// The result is an array with at least `min` (and at most `max`) items.
|
|
173
|
+
ResultCount { min: usize, max: Option<usize> },
|
|
174
|
+
/// The result is an array with an item whose `field` equals `equals`.
|
|
175
|
+
ResultContains { field: String, equals: Value },
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
impl CompletionCriteria {
|
|
179
|
+
/// Reject criteria that would be satisfied by any outcome.
|
|
180
|
+
pub fn validate(&self) -> Result<(), String> {
|
|
181
|
+
match self {
|
|
182
|
+
Self::ResultCount { min, max } => {
|
|
183
|
+
if *min == 0 {
|
|
184
|
+
return Err("result_count requires min >= 1; a criterion every outcome satisfies verifies nothing".into());
|
|
185
|
+
}
|
|
186
|
+
if max.is_some_and(|max| max < *min) {
|
|
187
|
+
return Err("result_count max must be >= min".into());
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
Self::ResultContains { field, .. } => {
|
|
191
|
+
if field.is_empty() || field.len() > 128 {
|
|
192
|
+
return Err("result_contains requires a field name of 1..128 characters".into());
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
Ok(())
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/// Whether `result`, as observed by the runtime, satisfies the criterion.
|
|
200
|
+
pub fn satisfied_by(&self, result: &Value) -> bool {
|
|
201
|
+
let Some(items) = result.as_array() else {
|
|
202
|
+
return false;
|
|
203
|
+
};
|
|
204
|
+
match self {
|
|
205
|
+
Self::ResultCount { min, max } => {
|
|
206
|
+
items.len() >= *min && max.is_none_or(|max| items.len() <= max)
|
|
207
|
+
}
|
|
208
|
+
Self::ResultContains { field, equals } => {
|
|
209
|
+
items.iter().any(|item| item.get(field) == Some(equals))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/// Why a run did not complete. `retryable` is decided by the runtime, never by
|
|
216
|
+
/// the run record.
|
|
217
|
+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
|
218
|
+
pub struct RunFailure {
|
|
219
|
+
pub code: &'static str,
|
|
220
|
+
pub message: String,
|
|
221
|
+
pub retryable: bool,
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
impl RunFailure {
|
|
225
|
+
pub fn permanent(code: &'static str, message: impl Into<String>) -> Self {
|
|
226
|
+
Self {
|
|
227
|
+
code,
|
|
228
|
+
message: message.into(),
|
|
229
|
+
retryable: false,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
pub fn to_record(&self, attempt: u64) -> Value {
|
|
234
|
+
json!({ "code": self.code, "message": self.message, "retryable": self.retryable, "attempt": attempt })
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/// Classify a capability error by its HTTP status.
|
|
239
|
+
///
|
|
240
|
+
/// A capability reads committed state and nothing else, so a client error
|
|
241
|
+
/// (bad input, missing record, refused authority) fails the same way on every
|
|
242
|
+
/// attempt and is permanent. Only a server-side error can succeed on retry.
|
|
243
|
+
pub fn classify_capability_error(status: u16, message: &str) -> RunFailure {
|
|
244
|
+
match status {
|
|
245
|
+
401 | 403 => RunFailure::permanent(AUTHORIZATION_DENIED, message),
|
|
246
|
+
404 => RunFailure::permanent(CAPABILITY_UNKNOWN, message),
|
|
247
|
+
400..=499 => RunFailure::permanent(CAPABILITY_INPUT_INVALID, message),
|
|
248
|
+
_ => RunFailure {
|
|
249
|
+
code: "CAPABILITY_FAILED",
|
|
250
|
+
message: message.into(),
|
|
251
|
+
retryable: true,
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/// The scopes a run may use: those granted when it was created that the key
|
|
257
|
+
/// still holds now. `*` on either side stands for the other side's set, so a
|
|
258
|
+
/// wildcard never widens a narrowed key, and an empty set authorizes nothing.
|
|
259
|
+
pub fn effective_scopes(
|
|
260
|
+
granted: &BTreeSet<String>,
|
|
261
|
+
current: &BTreeSet<String>,
|
|
262
|
+
) -> BTreeSet<String> {
|
|
263
|
+
if granted.contains("*") {
|
|
264
|
+
current.clone()
|
|
265
|
+
} else if current.contains("*") {
|
|
266
|
+
granted.clone()
|
|
267
|
+
} else {
|
|
268
|
+
granted.intersection(current).cloned().collect()
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/// The most attempts a run gets, shared with durable workloads.
|
|
273
|
+
pub fn max_run_attempts() -> u64 {
|
|
274
|
+
u64::from(WorkloadRetryPolicy::default().max_attempts)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/// Delay before attempt `next_attempt` (2 or later), exponential from the
|
|
278
|
+
/// workload policy's initial delay and capped at its maximum.
|
|
279
|
+
pub fn retry_delay_ms(next_attempt: u64) -> u64 {
|
|
280
|
+
let policy = WorkloadRetryPolicy::default();
|
|
281
|
+
let exponent = next_attempt.saturating_sub(2).min(20) as u32;
|
|
282
|
+
policy
|
|
283
|
+
.initial_delay_ms
|
|
284
|
+
.saturating_mul(2u64.saturating_pow(exponent))
|
|
285
|
+
.min(policy.max_delay_ms)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/// Collections reserved for the server. No capability reads them.
|
|
289
|
+
pub fn is_system_collection(collection: &str) -> bool {
|
|
290
|
+
collection.starts_with('_')
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/// Collections the runtime alone writes. A raw write could otherwise set a
|
|
294
|
+
/// run's status or retry state directly, or — for durable work, which the
|
|
295
|
+
/// workload store loads from this collection at startup — inject a workload
|
|
296
|
+
/// whose authorization snapshot was never verified.
|
|
297
|
+
pub fn is_runtime_owned_collection(collection: &str) -> bool {
|
|
298
|
+
matches!(
|
|
299
|
+
collection,
|
|
300
|
+
"_flow_agent_runs" | "_flow_workflow_runs" | feltdb::workload::DURABLE_WORK_COLLECTION
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
pub fn is_terminal(status: &str) -> bool {
|
|
305
|
+
matches!(status, STATUS_COMPLETED | STATUS_FAILED)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/// Whether the worker may claim an agent run in `status`.
|
|
309
|
+
///
|
|
310
|
+
/// External annotations (`deliberating`, `acting`) leave a run eligible, so a
|
|
311
|
+
/// client posting an observation cannot strand it outside the worker's reach.
|
|
312
|
+
pub fn agent_run_claimable(status: &str, lease_expired: bool) -> bool {
|
|
313
|
+
match status {
|
|
314
|
+
STATUS_READY | STATUS_DELIBERATING | STATUS_ACTING => true,
|
|
315
|
+
STATUS_RUNNING => lease_expired,
|
|
316
|
+
_ => false,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#[cfg(test)]
|
|
321
|
+
mod tests {
|
|
322
|
+
use super::*;
|
|
323
|
+
|
|
324
|
+
fn signer(seed: u8) -> GrantSigner {
|
|
325
|
+
GrantSigner::new("feltdb-primary-v1", vec![seed; 32]).unwrap()
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
fn principal() -> RunPrincipal {
|
|
329
|
+
RunPrincipal {
|
|
330
|
+
id: "key-1".into(),
|
|
331
|
+
kind: "service".into(),
|
|
332
|
+
scopes: ["agents:run".to_string(), "state:read".to_string()].into(),
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
fn issued(kind: RunKind, run: &str) -> Value {
|
|
337
|
+
serde_json::to_value(
|
|
338
|
+
issue_run_authority(&signer(7), kind, run, "tenant", &principal(), 1_000).unwrap(),
|
|
339
|
+
)
|
|
340
|
+
.unwrap()
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
#[test]
|
|
344
|
+
fn a_run_authority_round_trips_for_its_own_run() {
|
|
345
|
+
let verified = verify_run_authority(
|
|
346
|
+
&signer(7),
|
|
347
|
+
&issued(RunKind::Agent, "agent-1"),
|
|
348
|
+
RunKind::Agent,
|
|
349
|
+
"agent-1",
|
|
350
|
+
"tenant",
|
|
351
|
+
1_001,
|
|
352
|
+
)
|
|
353
|
+
.unwrap();
|
|
354
|
+
assert_eq!(verified, principal());
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
#[test]
|
|
358
|
+
fn a_run_authority_is_refused_for_any_other_binding() {
|
|
359
|
+
let grant = issued(RunKind::Agent, "agent-1");
|
|
360
|
+
let refuse = |value: &Value, kind, run: &str, tenant: &str, now| {
|
|
361
|
+
let failure =
|
|
362
|
+
verify_run_authority(&signer(7), value, kind, run, tenant, now).unwrap_err();
|
|
363
|
+
assert_eq!(failure.code, AUTHORIZATION_DENIED);
|
|
364
|
+
assert!(!failure.retryable);
|
|
365
|
+
};
|
|
366
|
+
refuse(&grant, RunKind::Agent, "agent-2", "tenant", 1_001);
|
|
367
|
+
refuse(&grant, RunKind::Workflow, "agent-1", "tenant", 1_001);
|
|
368
|
+
refuse(&grant, RunKind::Agent, "agent-1", "other-tenant", 1_001);
|
|
369
|
+
refuse(
|
|
370
|
+
&grant,
|
|
371
|
+
RunKind::Agent,
|
|
372
|
+
"agent-1",
|
|
373
|
+
"tenant",
|
|
374
|
+
1_000 + RUN_AUTHORITY_TTL_SECONDS,
|
|
375
|
+
);
|
|
376
|
+
refuse(&Value::Null, RunKind::Agent, "agent-1", "tenant", 1_001);
|
|
377
|
+
assert_eq!(
|
|
378
|
+
verify_run_authority(
|
|
379
|
+
&signer(8),
|
|
380
|
+
&grant,
|
|
381
|
+
RunKind::Agent,
|
|
382
|
+
"agent-1",
|
|
383
|
+
"tenant",
|
|
384
|
+
1_001
|
|
385
|
+
)
|
|
386
|
+
.unwrap_err()
|
|
387
|
+
.code,
|
|
388
|
+
AUTHORIZATION_DENIED,
|
|
389
|
+
"a grant from another signer is refused"
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#[test]
|
|
394
|
+
fn widening_a_signed_run_authority_breaks_its_signature() {
|
|
395
|
+
let mut grant = issued(RunKind::Agent, "agent-1");
|
|
396
|
+
grant["grant"]["capabilities"] = json!(["*"]);
|
|
397
|
+
assert!(verify_run_authority(
|
|
398
|
+
&signer(7),
|
|
399
|
+
&grant,
|
|
400
|
+
RunKind::Agent,
|
|
401
|
+
"agent-1",
|
|
402
|
+
"tenant",
|
|
403
|
+
1_001
|
|
404
|
+
)
|
|
405
|
+
.is_err());
|
|
406
|
+
let mut grant = issued(RunKind::Agent, "agent-1");
|
|
407
|
+
grant["grant"]["constraints"]["subject_attributes"]["principal_id"] = json!("admin");
|
|
408
|
+
assert!(verify_run_authority(
|
|
409
|
+
&signer(7),
|
|
410
|
+
&grant,
|
|
411
|
+
RunKind::Agent,
|
|
412
|
+
"agent-1",
|
|
413
|
+
"tenant",
|
|
414
|
+
1_001
|
|
415
|
+
)
|
|
416
|
+
.is_err());
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
#[test]
|
|
420
|
+
fn completion_criteria_verify_the_observed_result_only() {
|
|
421
|
+
let count = CompletionCriteria::ResultCount { min: 1, max: None };
|
|
422
|
+
assert!(count.satisfied_by(&json!([{ "id": "a" }])));
|
|
423
|
+
assert!(!count.satisfied_by(&json!([])));
|
|
424
|
+
assert!(!count.satisfied_by(&json!({ "items": [1] })));
|
|
425
|
+
let bounded = CompletionCriteria::ResultCount {
|
|
426
|
+
min: 1,
|
|
427
|
+
max: Some(1),
|
|
428
|
+
};
|
|
429
|
+
assert!(!bounded.satisfied_by(&json!([1, 2])));
|
|
430
|
+
let contains = CompletionCriteria::ResultContains {
|
|
431
|
+
field: "id".into(),
|
|
432
|
+
equals: json!("doc-1"),
|
|
433
|
+
};
|
|
434
|
+
assert!(contains.satisfied_by(&json!([{ "id": "doc-1" }])));
|
|
435
|
+
assert!(!contains.satisfied_by(&json!([{ "id": "doc-2" }])));
|
|
436
|
+
assert!(CompletionCriteria::ResultCount { min: 0, max: None }
|
|
437
|
+
.validate()
|
|
438
|
+
.is_err());
|
|
439
|
+
assert!(CompletionCriteria::ResultCount {
|
|
440
|
+
min: 2,
|
|
441
|
+
max: Some(1)
|
|
442
|
+
}
|
|
443
|
+
.validate()
|
|
444
|
+
.is_err());
|
|
445
|
+
assert!(
|
|
446
|
+
serde_json::from_value::<CompletionCriteria>(json!({ "kind": "goal_achieved" }))
|
|
447
|
+
.is_err()
|
|
448
|
+
);
|
|
449
|
+
assert!(serde_json::from_value::<CompletionCriteria>(
|
|
450
|
+
json!({ "kind": "result_count", "min": 1, "trust": true })
|
|
451
|
+
)
|
|
452
|
+
.is_err());
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
#[test]
|
|
456
|
+
fn effective_scopes_never_exceed_either_side() {
|
|
457
|
+
let set = |values: &[&str]| {
|
|
458
|
+
values
|
|
459
|
+
.iter()
|
|
460
|
+
.map(|v| v.to_string())
|
|
461
|
+
.collect::<BTreeSet<_>>()
|
|
462
|
+
};
|
|
463
|
+
let wide = set(&["*"]);
|
|
464
|
+
let reader = set(&["agents:run", "state:read"]);
|
|
465
|
+
let runner = set(&["agents:run"]);
|
|
466
|
+
assert_eq!(
|
|
467
|
+
effective_scopes(&wide, &runner),
|
|
468
|
+
runner,
|
|
469
|
+
"a wildcard grant narrows to the current key"
|
|
470
|
+
);
|
|
471
|
+
assert_eq!(
|
|
472
|
+
effective_scopes(&runner, &wide),
|
|
473
|
+
runner,
|
|
474
|
+
"a wildcard key does not widen the grant"
|
|
475
|
+
);
|
|
476
|
+
assert_eq!(effective_scopes(&reader, &runner), runner);
|
|
477
|
+
assert_eq!(effective_scopes(&wide, &wide), wide);
|
|
478
|
+
assert!(effective_scopes(&reader, &set(&[])).is_empty());
|
|
479
|
+
assert!(effective_scopes(&set(&[]), &wide).is_empty());
|
|
480
|
+
assert!(effective_scopes(&set(&["state:read"]), &set(&["state:write"])).is_empty());
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#[test]
|
|
484
|
+
fn only_server_errors_are_retryable() {
|
|
485
|
+
assert!(!classify_capability_error(403, "denied").retryable);
|
|
486
|
+
assert_eq!(
|
|
487
|
+
classify_capability_error(404, "missing").code,
|
|
488
|
+
CAPABILITY_UNKNOWN
|
|
489
|
+
);
|
|
490
|
+
assert_eq!(
|
|
491
|
+
classify_capability_error(400, "bad").code,
|
|
492
|
+
CAPABILITY_INPUT_INVALID
|
|
493
|
+
);
|
|
494
|
+
assert!(classify_capability_error(500, "storage").retryable);
|
|
495
|
+
assert_eq!(max_run_attempts(), 3);
|
|
496
|
+
assert_eq!(retry_delay_ms(2), 1_000);
|
|
497
|
+
assert_eq!(retry_delay_ms(3), 2_000);
|
|
498
|
+
assert_eq!(retry_delay_ms(99), 300_000);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
#[test]
|
|
502
|
+
fn terminal_runs_are_never_claimable() {
|
|
503
|
+
for status in [STATUS_COMPLETED, STATUS_FAILED] {
|
|
504
|
+
assert!(is_terminal(status));
|
|
505
|
+
assert!(!agent_run_claimable(status, true));
|
|
506
|
+
}
|
|
507
|
+
assert!(agent_run_claimable(STATUS_DELIBERATING, false));
|
|
508
|
+
assert!(!agent_run_claimable(STATUS_RUNNING, false));
|
|
509
|
+
assert!(agent_run_claimable(STATUS_RUNNING, true));
|
|
510
|
+
}
|
|
511
|
+
}
|
|
@@ -21,7 +21,7 @@ use crate::{
|
|
|
21
21
|
auth::KeyStore, canonical_principals::CanonicalPrincipalAuthority,
|
|
22
22
|
causal::CanonicalEventAuthority,
|
|
23
23
|
clock::LeaseClock, cluster::ClusterStore,
|
|
24
|
-
connections::ConnectionStore, content::ContentStore, identity::IdentityStore,
|
|
24
|
+
connections::ConnectionStore, console_accounts::ConsoleAccountStore, content::ContentStore, identity::IdentityStore,
|
|
25
25
|
leases::LeaseStore, metrics::Metrics, portable_bundle::BundleStore,
|
|
26
26
|
providers::ProviderStore, releases::ReleaseStore, sessions::SessionVerifier,
|
|
27
27
|
tenancy::TenancyStore,
|
|
@@ -60,6 +60,12 @@ pub struct AppState {
|
|
|
60
60
|
/// arbiter; this semaphore only bounds pre-commit working memory.
|
|
61
61
|
pub state_transaction_admission: Arc<tokio::sync::Semaphore>,
|
|
62
62
|
pub worker_enabled: bool,
|
|
63
|
+
/// Started with `--work-authority`: this node commits Durable Work even
|
|
64
|
+
/// with peers. See `is_work_authority`.
|
|
65
|
+
pub work_authority: bool,
|
|
66
|
+
/// A peer also reports being the Durable Work authority. Set by peer sync;
|
|
67
|
+
/// while set, this node refuses Durable Work writes (fail closed).
|
|
68
|
+
pub work_authority_conflict: Arc<std::sync::atomic::AtomicBool>,
|
|
63
69
|
pub leases: LeaseStore,
|
|
64
70
|
pub content: ContentStore,
|
|
65
71
|
pub artifacts: ArtifactStore,
|
|
@@ -74,6 +80,8 @@ pub struct AppState {
|
|
|
74
80
|
pub backup_artifacts: Arc<Vec<(String, PathBuf)>>,
|
|
75
81
|
pub tenancy: TenancyStore,
|
|
76
82
|
pub sessions: SessionVerifier,
|
|
83
|
+
/// Console (website) accounts; the only source of console user ids.
|
|
84
|
+
pub console_accounts: ConsoleAccountStore,
|
|
77
85
|
pub principals: CanonicalPrincipalAuthority,
|
|
78
86
|
pub connections: ConnectionStore,
|
|
79
87
|
pub identities: IdentityStore,
|