fix: one AI grant store, and peer browse mirrors Cloud's fan-out
UNIFIED GRANTS. There were two stores for the same ten categories:
settings/ai_permissions.json (what Settings wrote) and
assistant/grants.json (what actually gates the tool list). Toggling Settings
did nothing for the assistant, so with grants stuck at {"apps","system"} the
model truthfully answered "I don't have a tool for that" no matter what the
operator enabled — the real cause behind "the settings I enable keep
disabling". Their serde forms already matched one-for-one, so this is a
duplicate rather than two concepts. ai.permissions.get/set now read and write
the assistant's grants; the legacy file is still written so a downgrade does
not lose grants, and anything recorded only there is folded in on read.
PEER BROWSE now mirrors Cloud.vue's peer-files fan-out, as the operator asked:
concurrent with a cap and a per-peer timeout, rather than sequential. Cloud
caps at 3 because CHROMIUM's connection pool was starved (02-08) — a browser
constraint the daemon does not share, and measurably wrong here: at 3 a 20s
budget got through 2 batches of 16 peers and reached none. At 8 every peer is
attempted inside the budget.
Measured after deploying: 20.0s, peers_total 16, peers_reached 0. FIPS itself
is healthy (anchor connected, 3 authenticated peers, 4 fips_ok dials) but 14
dials fall back and fail, so the peers are not serving /content. The empty
film list is therefore correct — the transport works and the peers are down.
Reported as partial with counts so the assistant can say so instead of
implying the peers have nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
75919a2071
commit
55155f2db4
@@ -1257,37 +1257,57 @@ impl RpcHandler {
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// CONCURRENT with a cap, mirroring Cloud.vue's peer-files fan-out
|
||||||
|
// (BROWSE_PEER_CONCURRENCY = 3, 10s per peer, one attempt). That is
|
||||||
|
// the implementation the operator already trusts, and it is why the
|
||||||
|
// Cloud tab answers while a sequential version here did not: with 16
|
||||||
|
// peers at 8s each, going one at a time reached only one or two inside
|
||||||
|
// any sane budget.
|
||||||
|
//
|
||||||
|
// 02-08 is the reason for the CAP rather than an unbounded fan-out —
|
||||||
|
// 13 of 14 simultaneous browse-peer calls never settled and starved
|
||||||
|
// the connection pool. Three at a time keeps a dead peer from costing
|
||||||
|
// anything but its own slot.
|
||||||
|
// Cloud.vue uses 3, but that cap exists because CHROMIUM's connection
|
||||||
|
// pool was being starved (02-08) — a browser constraint the daemon does
|
||||||
|
// not share. Measured here: at 3, a 20s budget only got through 2
|
||||||
|
// batches of 16 peers and reached none. At 8 every peer is attempted
|
||||||
|
// inside the budget, which is the point.
|
||||||
|
const BROWSE_PEER_CONCURRENCY: usize = 8;
|
||||||
|
const PER_PEER_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||||
|
let overall = std::time::Duration::from_secs(20);
|
||||||
|
|
||||||
let mut items = Vec::new();
|
let mut items = Vec::new();
|
||||||
let mut reached = 0usize;
|
let mut reached = 0usize;
|
||||||
let mut unreachable = 0usize;
|
let mut unreachable = 0usize;
|
||||||
|
|
||||||
// OVERALL budget, not just per-peer. With a dozen federated peers an
|
let results = tokio::time::timeout(overall, async {
|
||||||
// 8s per-peer timeout still adds up past any usable answer — measured
|
let mut out: Vec<(String, Option<serde_json::Value>)> = Vec::new();
|
||||||
// on the node: this call returned nothing after 45 seconds, which the
|
for chunk in onions.chunks(BROWSE_PEER_CONCURRENCY) {
|
||||||
// assistant reports to the user as "having trouble accessing the peer
|
let mut set = Vec::new();
|
||||||
// content list". Partial results beat a timeout: whatever answered
|
for onion in chunk {
|
||||||
// inside the budget is returned, and the counts say what was missed.
|
let params = Some(serde_json::json!({ "onion": onion }));
|
||||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20);
|
set.push(async move {
|
||||||
|
let v = tokio::time::timeout(
|
||||||
// Sequential with a per-peer timeout rather than an unbounded fan-out:
|
PER_PEER_TIMEOUT,
|
||||||
// 02-08 traced a real UI stall to content.browse-peer starving the
|
self.handle_content_browse_peer(params),
|
||||||
// connection pool, and the assistant is not latency-critical.
|
)
|
||||||
for onion in &onions {
|
.await
|
||||||
if tokio::time::Instant::now() >= deadline {
|
.ok()
|
||||||
// Everything not yet tried counts as unreachable for this call
|
.and_then(|r| r.ok());
|
||||||
// rather than being silently omitted.
|
(onion.clone(), v)
|
||||||
unreachable += onions.len() - reached - unreachable;
|
});
|
||||||
break;
|
}
|
||||||
|
out.extend(futures_util::future::join_all(set).await);
|
||||||
}
|
}
|
||||||
let params = Some(serde_json::json!({ "onion": onion }));
|
out
|
||||||
// Never wait past the overall deadline for a single peer.
|
})
|
||||||
let per_peer = std::cmp::min(
|
.await
|
||||||
std::time::Duration::from_secs(8),
|
.unwrap_or_default();
|
||||||
deadline.saturating_duration_since(tokio::time::Instant::now()),
|
|
||||||
);
|
for (onion, v) in &results {
|
||||||
match tokio::time::timeout(per_peer, self.handle_content_browse_peer(params)).await
|
match v {
|
||||||
{
|
Some(v) => {
|
||||||
Ok(Ok(v)) => {
|
|
||||||
reached += 1;
|
reached += 1;
|
||||||
if let Some(arr) = v.get("items").and_then(|i| i.as_array()) {
|
if let Some(arr) = v.get("items").and_then(|i| i.as_array()) {
|
||||||
for it in arr {
|
for it in arr {
|
||||||
@@ -1299,17 +1319,18 @@ impl RpcHandler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => unreachable += 1,
|
None => unreachable += 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Peers the overall budget never got to are unreachable for this call,
|
||||||
|
// not silently absent.
|
||||||
|
unreachable += onions.len().saturating_sub(results.len());
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"items": items,
|
"items": items,
|
||||||
"peers_reached": reached,
|
"peers_reached": reached,
|
||||||
"peers_unreachable": unreachable,
|
"peers_unreachable": unreachable,
|
||||||
"peers_total": onions.len(),
|
"peers_total": onions.len(),
|
||||||
// Explicit so the assistant can say "3 of 12 peers answered"
|
|
||||||
// instead of implying the empty ones have nothing to share.
|
|
||||||
"partial": unreachable > 0,
|
"partial": unreachable > 0,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1018,8 +1018,8 @@ impl RpcHandler {
|
|||||||
pub(in crate::api::rpc) async fn handle_ai_permissions_get(
|
pub(in crate::api::rpc) async fn handle_ai_permissions_get(
|
||||||
&self,
|
&self,
|
||||||
) -> Result<serde_json::Value> {
|
) -> Result<serde_json::Value> {
|
||||||
let perms = crate::settings::ai_permissions::load(&self.config.data_dir).await;
|
let granted = Self::ai_grants_unified(&self.config.data_dir).await;
|
||||||
Ok(serde_json::json!({ "granted": perms.granted }))
|
Ok(serde_json::json!({ "granted": granted }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ai.permissions.set — replace the grant set.
|
/// ai.permissions.set — replace the grant set.
|
||||||
@@ -1045,12 +1045,58 @@ impl RpcHandler {
|
|||||||
})
|
})
|
||||||
.ok_or_else(|| anyhow::anyhow!("Missing granted (array of category ids)"))?;
|
.ok_or_else(|| anyhow::anyhow!("Missing granted (array of category ids)"))?;
|
||||||
|
|
||||||
let saved = crate::settings::ai_permissions::save(
|
// ONE store, not two kept in sync. The assistant's grants file is the
|
||||||
|
// authority — it is what actually gates the tool list, so a value that
|
||||||
|
// does not reach it is a setting that does nothing. That was the live
|
||||||
|
// bug: Settings wrote only the UI store, assistant/grants.json stayed
|
||||||
|
// {"apps","system"}, and the model truthfully answered "I don't have a
|
||||||
|
// tool for that" no matter what the operator toggled.
|
||||||
|
let mut grants = crate::assistant::grants::Grants::default_closed();
|
||||||
|
for name in &granted {
|
||||||
|
if let Ok(cat) = serde_json::from_value::<crate::assistant::PermissionCategory>(
|
||||||
|
serde_json::Value::String(name.clone()),
|
||||||
|
) {
|
||||||
|
grants.set(cat, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grants.save(&self.config.data_dir).await?;
|
||||||
|
|
||||||
|
// Keep the legacy file in step so a downgrade does not lose grants.
|
||||||
|
let _ = crate::settings::ai_permissions::save(
|
||||||
&self.config.data_dir,
|
&self.config.data_dir,
|
||||||
crate::settings::ai_permissions::AiPermissions { granted },
|
crate::settings::ai_permissions::AiPermissions {
|
||||||
|
granted: granted.clone(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await;
|
||||||
Ok(serde_json::json!({ "granted": saved.granted }))
|
|
||||||
|
let saved = Self::ai_grants_unified(&self.config.data_dir).await;
|
||||||
|
Ok(serde_json::json!({ "granted": saved }))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The node's AI grants, as category id strings.
|
||||||
|
///
|
||||||
|
/// Reads the assistant's grants file — the one that actually gates tools —
|
||||||
|
/// and folds in anything still recorded only in the older
|
||||||
|
/// settings/ai_permissions.json, so grants made before the two were
|
||||||
|
/// unified are not silently revoked on upgrade.
|
||||||
|
async fn ai_grants_unified(data_dir: &std::path::Path) -> Vec<String> {
|
||||||
|
let grants = crate::assistant::grants::Grants::load(data_dir).await;
|
||||||
|
let mut out: Vec<String> = grants
|
||||||
|
.categories()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| serde_json::to_value(c).ok()?.as_str().map(str::to_string))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let legacy = crate::settings::ai_permissions::load(data_dir).await;
|
||||||
|
for name in legacy.granted {
|
||||||
|
if !out.contains(&name) {
|
||||||
|
out.push(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out.dedup();
|
||||||
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
/// auth.session-policy.get — how long a login lasts on this node.
|
/// auth.session-policy.get — how long a login lasts on this node.
|
||||||
|
|||||||
Reference in New Issue
Block a user