@feltdb/core 0.12.1 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-identity.json +13 -4
- package/dist/cli/commands.js +96 -45
- package/dist/cli/workspace-integration.js +8 -0
- package/dist/collection.d.ts +41 -4
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +116 -47
- package/dist/composition.d.ts.map +1 -1
- package/dist/composition.js +11 -1
- package/dist/create/build-identity.json +13 -4
- package/dist/create/cli.js +49 -40
- package/dist/create/create.js +32 -19
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +18 -3
- package/dist/create/server-source/Cargo.toml +4 -2
- package/dist/create/server-source/crates/feltdb/Cargo.toml +20 -0
- package/dist/create/server-source/crates/feltdb/LICENSE +21 -0
- package/dist/create/server-source/crates/feltdb/README.md +75 -0
- package/dist/create/server-source/crates/feltdb/src/agent_state.rs +320 -0
- package/dist/create/server-source/crates/feltdb/src/agent_transition_contract.rs +1545 -0
- package/dist/create/server-source/crates/feltdb/src/authority.rs +145 -15
- package/dist/create/server-source/crates/feltdb/src/lib.rs +227 -23
- package/dist/create/server-source/crates/feltdb/src/state_model.rs +1 -5
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +204 -1
- package/dist/create/server-source/crates/feltdb/src/workload.rs +685 -53
- package/dist/create/server-source/crates/feltdb/tests/agent_state_native.rs +1188 -0
- package/dist/create/server-source/crates/feltdb/tests/agent_transition_contract_conformance.rs +2884 -0
- package/dist/create/server-source/crates/feltdb/tests/authority_transaction_identity.rs +534 -0
- package/dist/create/server-source/crates/feltdb/tests/fixtures/agent_transition_corpus.json +23340 -0
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +29 -0
- package/dist/create/server-source/crates/feltdb-client/LICENSE +21 -0
- package/dist/create/server-source/crates/feltdb-client/README.md +70 -0
- package/dist/create/server-source/crates/feltdb-client/src/application.rs +527 -0
- package/dist/create/server-source/crates/feltdb-client/src/authority.rs +679 -0
- package/dist/create/server-source/crates/feltdb-client/src/control.rs +292 -0
- package/dist/create/server-source/crates/feltdb-client/src/error.rs +227 -0
- package/dist/create/server-source/crates/feltdb-client/src/events.rs +146 -0
- package/dist/create/server-source/crates/feltdb-client/src/lib.rs +466 -0
- package/dist/create/server-source/crates/feltdb-client/tests/fixtures/README.md +4 -0
- package/dist/create/server-source/crates/feltdb-client/tests/fixtures/private-ca.pem +20 -0
- package/dist/create/server-source/crates/feltdb-client/tests/fixtures/server-key.pem +28 -0
- package/dist/create/server-source/crates/feltdb-client/tests/fixtures/server.pem +20 -0
- package/dist/create/server-source/crates/feltdb-client/tests/private_ca.rs +95 -0
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +20 -1
- package/dist/create/server-source/crates/feltdb-server/LICENSE +21 -0
- package/dist/create/server-source/crates/feltdb-server/README.md +12 -1
- package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +28 -0
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +6 -2
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +21 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +845 -138
- package/dist/create/server-source/crates/feltdb-server/tests/rust_client.rs +683 -0
- package/dist/create/server-source/crates/feltdb-wasm/Cargo.toml +3 -1
- package/dist/create/setup-summary.js +40 -0
- package/dist/create/template/default-project/README.md +12 -7
- package/dist/create/template/default-project/src/pages/Dashboard.tsx +1 -1
- package/dist/create/template/dot-feltdb-README.md +3 -3
- package/dist/db.d.ts +27 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +60 -21
- package/dist/development-runtime-bridge.d.ts +20 -0
- package/dist/development-runtime-bridge.d.ts.map +1 -1
- package/dist/development-runtime-bridge.js +74 -4
- package/dist/embedded-transaction.d.ts +75 -8
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +124 -18
- package/dist/error-codes.d.ts +2 -0
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +2 -0
- package/dist/feltdb.d.ts +1 -11
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +6 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +43 -11
- package/dist/http-db.d.ts +13 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +33 -4
- package/dist/indexeddb-db.d.ts.map +1 -1
- package/dist/indexeddb-db.js +65 -41
- package/dist/memory-db.d.ts.map +1 -1
- package/dist/memory-db.js +29 -8
- package/dist/studio/{KeyManagementPanel-DZuSeWBK.js → KeyManagementPanel-skUNnKRw.js} +36 -28
- package/dist/studio/app.d.ts +8 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.js +1 -1
- package/dist/studio/components/RouteErrorBoundary.d.ts +5 -0
- package/dist/studio/components/RouteErrorBoundary.d.ts.map +1 -0
- package/dist/studio/components/index.js +2 -2
- package/dist/studio/{components-Bivm2lX6.js → components-BSGmHfif.js} +6 -6
- package/dist/studio/control-plane/Agents.d.ts.map +1 -1
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Instance.d.ts +4 -1
- package/dist/studio/control-plane/Instance.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts +4 -1
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/Overview.d.ts.map +1 -1
- package/dist/studio/control-plane/Schema.d.ts.map +1 -1
- package/dist/studio/control-plane/context.d.ts +4 -1
- package/dist/studio/control-plane/context.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane/primitives.d.ts +24 -0
- package/dist/studio/control-plane/primitives.d.ts.map +1 -1
- package/dist/studio/{control-plane-CLm69XwX.js → control-plane-CaBA7Rgv.js} +1374 -1298
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +415 -237
- package/dist/studio/navigation.d.ts +57 -0
- package/dist/studio/navigation.d.ts.map +1 -0
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +2 -0
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.js +9 -4
- package/dist/studio/utils/service-api.d.ts.map +1 -1
- package/dist/studio/utils/service-api.js +5 -4
- package/dist/studio-app/assets/{feltdb_wasm-DACcD-zs.js → feltdb_wasm-3hDngl1h.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-CFQT4gCy.wasm +0 -0
- package/dist/studio-app/assets/index-Dql1hS5m.js +30 -0
- package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
- package/dist/studio-app/index.html +2 -2
- package/dist/telemetry.d.ts +1 -0
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +4 -3
- package/dist/transaction-identity.d.ts +62 -0
- package/dist/transaction-identity.d.ts.map +1 -0
- package/dist/transaction-identity.js +138 -0
- package/dist/transaction.d.ts +50 -5
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +28 -0
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workspace/local-development-authority.d.ts +19 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +335 -15
- package/package.json +1 -1
- package/dist/create/server-source/crates/feltdb-wasm/test.db +0 -0
- package/dist/studio-app/assets/feltdb_wasm_bg-BejThWID.wasm +0 -0
- package/dist/studio-app/assets/index-CoZaxnlm.js +0 -30
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FeltDB Contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -5,7 +5,18 @@ second state store: every HTTP mutation calls `FeltDb::insert`, `FeltDb::update`
|
|
|
5
5
|
`FeltDb::delete`, producing the same operations, vector-clock updates, durable log entries, and
|
|
6
6
|
reactive events as an embedded mutation.
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
Install it from crates.io (any Rust 1.96+ toolchain):
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
cargo install feltdb-server --locked
|
|
12
|
+
feltdb-server --host 127.0.0.1 --port 7700 --namespace my-app --data ./data/feltdb.log
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The command line and the HTTP API are the supported contract. The library half of this crate exposes
|
|
16
|
+
the server's building blocks without a compatibility promise; for embedded state in your own process
|
|
17
|
+
depend on [`feltdb`](https://crates.io/crates/feltdb) instead.
|
|
18
|
+
|
|
19
|
+
From a checkout of the repository, run it with:
|
|
9
20
|
|
|
10
21
|
```sh
|
|
11
22
|
cargo run -p feltdb-server -- \
|
|
@@ -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
|
+
}
|
|
@@ -181,6 +181,34 @@ impl KeyStore {
|
|
|
181
181
|
})
|
|
182
182
|
}
|
|
183
183
|
|
|
184
|
+
/// The current scopes and subject type of an active key, by id.
|
|
185
|
+
///
|
|
186
|
+
/// Work executed after its request — an autonomous run — re-reads its
|
|
187
|
+
/// principal here, so a key revoked or narrowed since the run was created
|
|
188
|
+
/// is not honoured at its old breadth. `None` when the key is unknown,
|
|
189
|
+
/// revoked, or not valid for `namespace`.
|
|
190
|
+
pub fn active_scopes(&self, key_id: &str, namespace: &str) -> Option<(Vec<String>, String)> {
|
|
191
|
+
let records = self.records.read().ok()?;
|
|
192
|
+
records
|
|
193
|
+
.iter()
|
|
194
|
+
.find(|record| {
|
|
195
|
+
record.id == key_id
|
|
196
|
+
&& !record.revoked
|
|
197
|
+
&& record
|
|
198
|
+
.namespaces
|
|
199
|
+
.iter()
|
|
200
|
+
.any(|allowed| allowed == "*" || allowed == namespace)
|
|
201
|
+
})
|
|
202
|
+
.map(|record| {
|
|
203
|
+
let kind = if record.worker_id.is_some() {
|
|
204
|
+
"worker"
|
|
205
|
+
} else {
|
|
206
|
+
"service"
|
|
207
|
+
};
|
|
208
|
+
(record.scopes.clone(), kind.to_string())
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
|
|
184
212
|
pub fn create(
|
|
185
213
|
&self,
|
|
186
214
|
name: String,
|
|
@@ -411,11 +411,15 @@ fn copy_source(
|
|
|
411
411
|
if let Some(parent) = destination.parent() {
|
|
412
412
|
fs::create_dir_all(parent)?;
|
|
413
413
|
}
|
|
414
|
-
|
|
414
|
+
// Record what was copied, not what the source measured before the
|
|
415
|
+
// copy. An online backup copies files the server is still appending
|
|
416
|
+
// to (the audit log): a length taken from the source before copying
|
|
417
|
+
// would disagree with the copy's hash and fail verification.
|
|
418
|
+
let copied = fs::copy(source, destination)?;
|
|
415
419
|
sync_file(destination)?;
|
|
416
420
|
entries.push(BackupEntry {
|
|
417
421
|
path: relative.into(),
|
|
418
|
-
bytes:
|
|
422
|
+
bytes: copied,
|
|
419
423
|
sha256: hash_file(destination)?,
|
|
420
424
|
});
|
|
421
425
|
}
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
//! # feltdb-server
|
|
2
|
+
//!
|
|
3
|
+
//! The FeltDB server: the managed FeltDB authority over HTTP. Install and run it with
|
|
4
|
+
//!
|
|
5
|
+
//! ```sh
|
|
6
|
+
//! cargo install feltdb-server --locked
|
|
7
|
+
//! feltdb-server --host 127.0.0.1 --port 7700 --namespace my-app --data ./data/feltdb.log
|
|
8
|
+
//! ```
|
|
9
|
+
//!
|
|
10
|
+
//! Its supported contract is the binary's command line and its HTTP API (`feltdb.public.substrate@1.0`
|
|
11
|
+
//! and the `/v1` Service API, specified in the repository's
|
|
12
|
+
//! [`docs/reference/public-api.md`](https://github.com/rkendel1/flow_db/blob/main/docs/reference/public-api.md)).
|
|
13
|
+
//!
|
|
14
|
+
//! The modules below are the server's building blocks. They are public so the binary and the
|
|
15
|
+
//! repository's tests can use them, and **no compatibility promise covers them**: any release may
|
|
16
|
+
//! change them. For embedded state in your own process, depend on [`feltdb`] directly; it is
|
|
17
|
+
//! re-exported here at the exact version this server is built against.
|
|
18
|
+
|
|
19
|
+
pub use feltdb;
|
|
20
|
+
|
|
21
|
+
pub mod agent_execution;
|
|
1
22
|
pub mod app_state;
|
|
2
23
|
pub mod application_contract;
|
|
3
24
|
pub mod artifacts;
|