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:
archipelago
2026-08-07 04:23:56 -04:00
co-authored by Claude Opus 5
parent 75919a2071
commit 55155f2db4
2 changed files with 102 additions and 35 deletions
+50 -29
View File
@@ -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.