multiuser part 2

This commit is contained in:
2026-07-10 22:15:35 +01:00
parent c72f18b0d6
commit 5936cf0b2e
9 changed files with 139 additions and 78 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ pub async fn get(
) -> Result<Json<AgentDetail>, ApiError> { ) -> Result<Json<AgentDetail>, ApiError> {
let meta = skald_core::agents::load_meta(&id)?; let meta = skald_core::agents::load_meta(&id)?;
let prompt = skald_core::agents::load_prompt(&id)?; let prompt = skald_core::agents::load_prompt(&id)?;
let all = skald.manager().llm_manager().list_models_info().await; let all = skald.llm_manager().list_models_info().await;
let models = sort_models_for_agent(all, meta.scope.as_deref(), meta.strength); let models = sort_models_for_agent(all, meta.scope.as_deref(), meta.strength);
Ok(Json(AgentDetail { meta, prompt, models })) Ok(Json(AgentDetail { meta, prompt, models }))
} }
+11 -7
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -11,7 +11,7 @@ use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
// ── GET /api/approval/rules ─────────────────────────────────────────────────── // ── GET /api/approval/rules ───────────────────────────────────────────────────
@@ -78,14 +78,16 @@ fn default_action() -> String { "approve".to_string() }
pub async fn resolve_pending( pub async fn resolve_pending(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolvePath>, Path(p): Path<ResolvePath>,
Json(body): Json<ResolveBody>, Json(body): Json<ResolveBody>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
if body.action == "reject" { if body.action == "reject" {
// Pass the raw note; the waiting session builds the canonical message. // Pass the raw note; the waiting session builds the canonical message.
skald.inbox().reject(p.request_id, body.note.clone()).await; ctx.inbox.reject(p.request_id, body.note.clone()).await;
} else { } else {
skald.inbox().approve(p.request_id).await; ctx.inbox.approve(p.request_id).await;
} }
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action }))) Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action })))
} }
@@ -96,9 +98,11 @@ pub async fn resolve_pending(
pub async fn list_pending( pub async fn list_pending(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Json<Value> { Extension(auth): Extension<AuthUser>,
let pending = skald.inbox().list_pending().await.approvals; ) -> Result<Json<Value>, ApiError> {
Json(json!(pending)) let ctx = require_context(&skald, &auth.user_id).await?;
let pending = ctx.inbox.list_pending().await.approvals;
Ok(Json(json!(pending)))
} }
// ── GET /api/approval/tools ─────────────────────────────────────────────────── // ── GET /api/approval/tools ───────────────────────────────────────────────────
+30 -20
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
}; };
use serde::Deserialize; use serde::Deserialize;
@@ -7,22 +7,26 @@ use serde_json::{Value, json};
use std::time::Duration; use std::time::Duration;
use std::sync::Arc; use std::sync::Arc;
use skald_core::skald::Skald; use skald_core::skald::{Skald, UserContext};
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
// ── GET /api/inbox ──────────────────────────────────────────────────────────── // ── GET /api/inbox ────────────────────────────────────────────────────────────
// //
// Returns all pending approval requests and clarification requests in a single // Returns all pending approval requests and clarification requests in a single
// response, so the frontend can show a unified Agent Inbox page with one fetch. // response, so the frontend can show a unified Agent Inbox page with one fetch.
pub async fn list(State(skald): State<Arc<Skald>>) -> Json<Value> { pub async fn list(
let items = skald.inbox().list_pending().await; State(skald): State<Arc<Skald>>,
Json(json!({ Extension(auth): Extension<AuthUser>,
) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let items = ctx.inbox.list_pending().await;
Ok(Json(json!({
"total": items.total, "total": items.total,
"approvals": items.approvals, "approvals": items.approvals,
"clarifications": items.clarifications, "clarifications": items.clarifications,
"elicitations": items.elicitations, "elicitations": items.elicitations,
})) })))
} }
// ── POST /api/inbox/approvals/:request_id/resolve ───────────────────────────── // ── POST /api/inbox/approvals/:request_id/resolve ─────────────────────────────
@@ -47,17 +51,19 @@ fn default_action() -> String { "approve".to_string() }
pub async fn resolve_approval( pub async fn resolve_approval(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ApprovePath>, Path(p): Path<ApprovePath>,
Json(body): Json<ApproveBody>, Json(body): Json<ApproveBody>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
// Peek info before resolving so we have session_id and tool metadata for bypass. // Peek info before resolving so we have session_id and tool metadata for bypass.
let info = skald.approval().get_pending(p.request_id).await; let info = ctx.approval.get_pending(p.request_id).await;
if body.action == "reject" { if body.action == "reject" {
// Pass the raw note; the waiting session builds the canonical message. // Pass the raw note; the waiting session builds the canonical message.
skald.inbox().reject(p.request_id, body.note.clone()).await; ctx.inbox.reject(p.request_id, body.note.clone()).await;
} else { } else {
skald.inbox().approve(p.request_id).await; ctx.inbox.approve(p.request_id).await;
// Apply bypass if requested (only on approve). // Apply bypass if requested (only on approve).
if let (Some(info), Some(bypass_secs)) = (info, body.bypass_secs) { if let (Some(info), Some(bypass_secs)) = (info, body.bypass_secs) {
@@ -72,29 +78,29 @@ pub async fn resolve_approval(
match scope { match scope {
"category" => { "category" => {
if let Some(cat) = info.tool_category { if let Some(cat) = info.tool_category {
skald.approval().bypass_session_for_category(info.session_id, cat, duration).await; ctx.approval.bypass_session_for_category(info.session_id, cat, duration).await;
} else { } else {
apply_all_bypass(&skald, info.session_id, duration).await; apply_all_bypass(&ctx, info.session_id, duration).await;
} }
} }
"mcp_server" => { "mcp_server" => {
if let Some(server) = info.mcp_server { if let Some(server) = info.mcp_server {
skald.approval().bypass_session_for_mcp(info.session_id, server, duration).await; ctx.approval.bypass_session_for_mcp(info.session_id, server, duration).await;
} else { } else {
apply_all_bypass(&skald, info.session_id, duration).await; apply_all_bypass(&ctx, info.session_id, duration).await;
} }
} }
_ => apply_all_bypass(&skald, info.session_id, duration).await, _ => apply_all_bypass(&ctx, info.session_id, duration).await,
} }
} }
} }
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action }))) Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": body.action })))
} }
async fn apply_all_bypass(skald: &Skald, session_id: i64, duration: Option<Duration>) { async fn apply_all_bypass(ctx: &UserContext, session_id: i64, duration: Option<Duration>) {
match duration { match duration {
Some(d) => skald.approval().bypass_session_for(session_id, d).await, Some(d) => ctx.approval.bypass_session_for(session_id, d).await,
None => skald.approval().bypass_session(session_id).await, None => ctx.approval.bypass_session(session_id).await,
} }
} }
@@ -110,13 +116,15 @@ pub struct ClarifyBody {
pub async fn resolve_clarification( pub async fn resolve_clarification(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ClarifyPath>, Path(p): Path<ClarifyPath>,
Json(body): Json<ClarifyBody>, Json(body): Json<ClarifyBody>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
if body.answer.trim().is_empty() { if body.answer.trim().is_empty() {
return Err(ApiError::bad_request("answer must not be empty")); return Err(ApiError::bad_request("answer must not be empty"));
} }
let resolved = skald.inbox().answer(p.request_id, body.answer).await; let resolved = ctx.inbox.answer(p.request_id, body.answer).await;
if resolved { if resolved {
Ok(Json(json!({ "ok": true, "request_id": p.request_id }))) Ok(Json(json!({ "ok": true, "request_id": p.request_id })))
} else { } else {
@@ -145,14 +153,16 @@ fn default_elicit_action() -> String { "decline".to_string() }
pub async fn resolve_elicitation( pub async fn resolve_elicitation(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ElicitPath>, Path(p): Path<ElicitPath>,
Json(body): Json<ElicitBody>, Json(body): Json<ElicitBody>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let action = match body.action.as_str() { let action = match body.action.as_str() {
"accept" | "decline" | "cancel" => body.action.clone(), "accept" | "decline" | "cancel" => body.action.clone(),
other => return Err(ApiError::bad_request(format!("invalid action: {other}"))), other => return Err(ApiError::bad_request(format!("invalid action: {other}"))),
}; };
let resolved = skald.inbox().resolve_elicitation(p.request_id, action.clone(), body.content).await; let resolved = ctx.inbox.resolve_elicitation(p.request_id, action.clone(), body.content).await;
if resolved { if resolved {
Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": action }))) Ok(Json(json!({ "ok": true, "request_id": p.request_id, "action": action })))
} else { } else {
+13 -13
View File
@@ -17,7 +17,7 @@ pub async fn provider_models(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<Vec<RemoteLlmModelInfo>>, ApiError> { ) -> Result<Json<Vec<RemoteLlmModelInfo>>, ApiError> {
let models = skald.manager().llm_manager().list_provider_models(id).await?; let models = skald.llm_manager().list_provider_models(id).await?;
Ok(Json(models)) Ok(Json(models))
} }
@@ -36,7 +36,7 @@ pub async fn provider_reasoning_mode(
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
axum::extract::Query(q): axum::extract::Query<ReasoningModeQuery>, axum::extract::Query(q): axum::extract::Query<ReasoningModeQuery>,
) -> Json<Option<ReasoningMode>> { ) -> Json<Option<ReasoningMode>> {
let mode = skald.manager().llm_manager().reasoning_mode_for(id, &q.model_id).await; let mode = skald.llm_manager().reasoning_mode_for(id, &q.model_id).await;
Json(mode) Json(mode)
} }
@@ -51,7 +51,7 @@ pub struct SelectorResponse {
pub async fn selector( pub async fn selector(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<SelectorResponse>, ApiError> { ) -> Result<Json<SelectorResponse>, ApiError> {
let mgr = skald.manager().llm_manager(); let mgr = skald.llm_manager();
let models = mgr.client_names().await; let models = mgr.client_names().await;
let default = mgr.default_name().await; let default = mgr.default_name().await;
Ok(Json(SelectorResponse { models, default })) Ok(Json(SelectorResponse { models, default }))
@@ -62,7 +62,7 @@ pub async fn selector(
pub async fn list_providers( pub async fn list_providers(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<LlmProviderInfo>>, ApiError> { ) -> Result<Json<Vec<LlmProviderInfo>>, ApiError> {
Ok(Json(skald.manager().llm_manager().list_providers_info().await)) Ok(Json(skald.llm_manager().list_providers_info().await))
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -94,7 +94,7 @@ pub async fn create_provider(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
validate_provider_type(&skald, &payload.provider)?; validate_provider_type(&skald, &payload.provider)?;
let record = LlmProviderRecord::from(payload); let record = LlmProviderRecord::from(payload);
skald.manager().llm_manager().add_provider(record).await?; skald.llm_manager().add_provider(record).await?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
@@ -102,7 +102,7 @@ pub async fn get_provider(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<LlmProviderRecord>, ApiError> { ) -> Result<Json<LlmProviderRecord>, ApiError> {
skald.manager().llm_manager().get_provider(id).await skald.llm_manager().get_provider(id).await
.map(Json) .map(Json)
.ok_or_else(|| ApiError::not_found(format!("provider {id} not found"))) .ok_or_else(|| ApiError::not_found(format!("provider {id} not found")))
} }
@@ -114,7 +114,7 @@ pub async fn update_provider(
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
validate_provider_type(&skald, &payload.provider)?; validate_provider_type(&skald, &payload.provider)?;
let record = LlmProviderRecord::from(payload); let record = LlmProviderRecord::from(payload);
skald.manager().llm_manager().update_provider(id, record).await?; skald.llm_manager().update_provider(id, record).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -122,7 +122,7 @@ pub async fn delete_provider(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
skald.manager().llm_manager().delete_provider(id).await?; skald.llm_manager().delete_provider(id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -131,7 +131,7 @@ pub async fn delete_provider(
pub async fn list_models( pub async fn list_models(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<Vec<LlmModelInfo>>, ApiError> { ) -> Result<Json<Vec<LlmModelInfo>>, ApiError> {
let mgr = skald.manager().llm_manager(); let mgr = skald.llm_manager();
// Warm the catalog cache for every provider concurrently so that price data // Warm the catalog cache for every provider concurrently so that price data
// is available for the join inside list_models_info(). Errors are ignored — // is available for the join inside list_models_info(). Errors are ignored —
@@ -203,7 +203,7 @@ pub async fn create_model(
Json(payload): Json<ModelPayload>, Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let record = LlmModelRecord::try_from(payload)?; let record = LlmModelRecord::try_from(payload)?;
skald.manager().llm_manager().add_model(record).await?; skald.llm_manager().add_model(record).await?;
Ok(StatusCode::CREATED) Ok(StatusCode::CREATED)
} }
@@ -211,7 +211,7 @@ pub async fn get_model(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<Json<LlmModelRecord>, ApiError> { ) -> Result<Json<LlmModelRecord>, ApiError> {
skald.manager().llm_manager().get_model(id).await skald.llm_manager().get_model(id).await
.map(Json) .map(Json)
.ok_or_else(|| ApiError::not_found(format!("model {id} not found"))) .ok_or_else(|| ApiError::not_found(format!("model {id} not found")))
} }
@@ -222,7 +222,7 @@ pub async fn update_model(
Json(payload): Json<ModelPayload>, Json(payload): Json<ModelPayload>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let record = LlmModelRecord::try_from(payload)?; let record = LlmModelRecord::try_from(payload)?;
skald.manager().llm_manager().update_model(id, record).await?; skald.llm_manager().update_model(id, record).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -230,7 +230,7 @@ pub async fn delete_model(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
axum::extract::Path(id): axum::extract::Path<i64>, axum::extract::Path(id): axum::extract::Path<i64>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
skald.manager().llm_manager().delete_model(id).await?; skald.llm_manager().delete_model(id).await?;
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+16
View File
@@ -184,6 +184,22 @@ impl ApiError {
pub fn not_found(msg: impl Into<String>) -> Self { pub fn not_found(msg: impl Into<String>) -> Self {
Self { status: StatusCode::NOT_FOUND, message: msg.into() } Self { status: StatusCode::NOT_FOUND, message: msg.into() }
} }
pub fn unauthorized(msg: impl Into<String>) -> Self {
Self { status: StatusCode::UNAUTHORIZED, message: msg.into() }
}
}
/// Resolves the authenticated caller's per-user runtime context, or `401` when the
/// user's database is locked (not logged in). Every owner-bound HTTP handler starts
/// here to route reads/writes to the caller's own `{userid}.db` instead of the
/// shared registry pool. The `AuthUser` is injected by [`guard::require_auth`].
pub async fn require_context(
skald: &Skald,
user_id: &str,
) -> Result<Arc<skald_core::skald::UserContext>, ApiError> {
skald.user_context(user_id).await
.ok_or_else(|| ApiError::unauthorized("session expired — please log in again"))
} }
impl IntoResponse for ApiError { impl IntoResponse for ApiError {
+5 -3
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
@@ -11,7 +11,7 @@ use skald_core::db::project_tickets::ProjectTicket;
use skald_core::db::projects::Project; use skald_core::db::projects::Project;
use skald_core::run_context::RunContext; use skald_core::run_context::RunContext;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
/// Source-id prefix for a project's interactive chat session (e.g. `project-42`). /// Source-id prefix for a project's interactive chat session (e.g. `project-42`).
/// A hyphen (not `:`) is used so the id is URL-safe in `/api/{source}/messages`. /// A hyphen (not `:`) is used so the id is URL-safe in `/api/{source}/messages`.
@@ -284,10 +284,12 @@ pub async fn provisioning_for_source(
pub async fn open_session( pub async fn open_session(
Path(p): Path<ProjectPath>, Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<SessionResponse>, ApiError> { ) -> Result<Json<SessionResponse>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let source = format!("{PROJECT_SOURCE_PREFIX}{}", p.id); let source = format!("{PROJECT_SOURCE_PREFIX}{}", p.id);
let (agent, rc) = provisioning_for_source(&skald, &source).await?; let (agent, rc) = provisioning_for_source(&skald, &source).await?;
let session_id = skald.chat_hub() let session_id = ctx.chat_hub
.provision_session(&source, &agent, rc.as_ref(), false) .provision_session(&source, &agent, rc.as_ref(), false)
.await?; .await?;
Ok(Json(SessionResponse { source, session_id })) Ok(Json(SessionResponse { source, session_id }))
+13 -4
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
@@ -9,7 +9,7 @@ use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use skald_core::skald::Skald; use skald_core::skald::Skald;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
// ── Tool Permission Groups ──────────────────────────────────────────────────── // ── Tool Permission Groups ────────────────────────────────────────────────────
@@ -87,12 +87,21 @@ pub struct SessionPath { pub session_id: i64 }
/// POST body: the full RunContext object, or JSON `null` to clear the context. /// POST body: the full RunContext object, or JSON `null` to clear the context.
pub async fn set_session_run_context( pub async fn set_session_run_context(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SessionPath>, Path(p): Path<SessionPath>,
Json(ctx): Json<Option<skald_core::run_context::RunContext>>, Json(ctx): Json<Option<skald_core::run_context::RunContext>>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
skald.run_context_manager().set_session_run_context(p.session_id, ctx.as_ref()).await?; let uctx = require_context(&skald, &auth.user_id).await?;
// The session row (and its live handler) live in the caller's own pool, so the
// persist + live update both target the user's context. Run-context *definitions*
// (roles) remain instance-wide; only the per-session value is owner data.
skald_core::db::chat_sessions::set_run_context(
&uctx.pool,
p.session_id,
ctx.as_ref().map(|c| c.to_db()).as_deref(),
).await?;
if let Some(handler) = skald.manager().active_handler(p.session_id).await { if let Some(handler) = uctx.sessions.active_handler(p.session_id).await {
handler.set_run_context(ctx).await; handler.set_run_context(ctx).await;
} }
+45 -27
View File
@@ -3,7 +3,7 @@ use std::future::Future;
use std::pin::Pin; use std::pin::Pin;
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, Query, State}, extract::{Path, Query, State},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -13,12 +13,12 @@ use sqlx::SqlitePool;
use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources}; use skald_core::db::{chat_history, chat_llm_tools, chat_sessions, chat_sessions_stack, sources};
use skald_core::db::chat_sessions_stack::SessionStack; use skald_core::db::chat_sessions_stack::SessionStack;
use std::sync::Arc; use std::sync::Arc;
use skald_core::skald::Skald; use skald_core::skald::{Skald, UserContext};
use skald_core::session::handler::ApprovalDecision; use skald_core::session::handler::ApprovalDecision;
use skald_core::approval::ApprovalManager; use skald_core::approval::ApprovalManager;
use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn}; use skald_core::tools::{ToolRegistry, ToolDescriptionLength, tool_names as tn};
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
// ── POST /api/sessions — start a new conversation ───────────────────────────── // ── POST /api/sessions — start a new conversation ─────────────────────────────
@@ -32,12 +32,14 @@ fn default_source() -> String { "web".to_string() }
pub async fn create( pub async fn create(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<CreateQuery>, Query(q): Query<CreateQuery>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
// Resolve agent + RunContext from the source so project chats reset with the // Resolve agent + RunContext from the source so project chats reset with the
// coordinator agent (not the default `main`), then provision a fresh session. // coordinator agent (not the default `main`), then provision a fresh session.
let (agent, rc) = super::projects::provisioning_for_source(&skald, &q.source).await?; let (agent, rc) = super::projects::provisioning_for_source(&skald, &q.source).await?;
skald.chat_hub().provision_session(&q.source, &agent, rc.as_ref(), true).await?; ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?;
Ok(Json(json!({}))) Ok(Json(json!({})))
} }
@@ -45,8 +47,10 @@ pub async fn create(
pub async fn web_messages( pub async fn web_messages(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<Value>>, ApiError> { ) -> Result<Json<Vec<Value>>, ApiError> {
messages_for_source(&skald, "web").await let ctx = require_context(&skald, &auth.user_id).await?;
messages_for_source(&skald, &ctx, "web").await
} }
// ── GET /api/:source/messages ───────────────────────────────────────────────── // ── GET /api/:source/messages ─────────────────────────────────────────────────
@@ -56,31 +60,36 @@ pub struct SourcePath { pub source: String }
pub async fn source_messages( pub async fn source_messages(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SourcePath>, Path(p): Path<SourcePath>,
) -> Result<Json<Vec<Value>>, ApiError> { ) -> Result<Json<Vec<Value>>, ApiError> {
messages_for_source(&skald, &p.source).await let ctx = require_context(&skald, &auth.user_id).await?;
messages_for_source(&skald, &ctx, &p.source).await
} }
async fn messages_for_source(skald: &Arc<Skald>, source: &str) -> Result<Json<Vec<Value>>, ApiError> { async fn messages_for_source(skald: &Arc<Skald>, ctx: &UserContext, source: &str) -> Result<Json<Vec<Value>>, ApiError> {
let session_id = match sources::active_session_id(skald.db(), source).await? { // History/sessions read from the caller's own pool; the tool registry is a
// global capability, and approval is this user's per-user manager.
let db = &ctx.pool;
let session_id = match sources::active_session_id(db, source).await? {
Some(id) => id, Some(id) => id,
None => return Ok(Json(vec![])), None => return Ok(Json(vec![])),
}; };
let main_stack = match chat_sessions_stack::main_for_session(skald.db(), session_id).await? { let main_stack = match chat_sessions_stack::main_for_session(db, session_id).await? {
Some(s) => s, Some(s) => s,
None => return Ok(Json(vec![])), None => return Ok(Json(vec![])),
}; };
let subagent_map: HashMap<i64, SessionStack> = let subagent_map: HashMap<i64, SessionStack> =
chat_sessions_stack::all_for_session(skald.db(), session_id) chat_sessions_stack::all_for_session(db, session_id)
.await? .await?
.into_iter() .into_iter()
.filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s))) .filter_map(|s| s.parent_tool_call_id.map(|tc_id| (tc_id, s)))
.collect(); .collect();
let mut items: Vec<Value> = Vec::new(); let mut items: Vec<Value> = Vec::new();
build_items(skald.db(), skald.tools(), skald.approval(), &main_stack, &subagent_map, &mut items).await?; build_items(db, skald.tools(), &ctx.approval, &main_stack, &subagent_map, &mut items).await?;
Ok(Json(items)) Ok(Json(items))
} }
@@ -117,9 +126,12 @@ pub struct ResolveToolResponse {
/// resolve against the correct session — there is no "current session" scoping. /// resolve against the correct session — there is no "current session" scoping.
pub async fn resolve_tool( pub async fn resolve_tool(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ResolveToolPath>, Path(p): Path<ResolveToolPath>,
Json(body): Json<ResolveToolBody>, Json(body): Json<ResolveToolBody>,
) -> Result<Json<ResolveToolResponse>, ApiError> { ) -> Result<Json<ResolveToolResponse>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
// Look up the tool call by id alone — no active-session filter. Also pull the // Look up the tool call by id alone — no active-session filter. Also pull the
// owning session_id so the post-restart path drives the correct session. // owning session_id so the post-restart path drives the correct session.
let tc = sqlx::query_as::<_, (i64, String, Option<String>, String, i64)>( let tc = sqlx::query_as::<_, (i64, String, Option<String>, String, i64)>(
@@ -130,7 +142,7 @@ pub async fn resolve_tool(
WHERE t.id = ?", WHERE t.id = ?",
) )
.bind(p.tool_call_id) .bind(p.tool_call_id)
.fetch_optional(&**skald.db()) .fetch_optional(&**db)
.await? .await?
.ok_or_else(|| anyhow::anyhow!( .ok_or_else(|| anyhow::anyhow!(
"tool_call_id {} not found", p.tool_call_id "tool_call_id {} not found", p.tool_call_id
@@ -152,12 +164,12 @@ pub async fn resolve_tool(
// Pass the raw user note to the live session so the loop builds the // Pass the raw user note to the live session so the loop builds the
// canonical message; for the not-live path (no waiting session, e.g. // canonical message; for the not-live path (no waiting session, e.g.
// after a restart) build the same message here and save it directly. // after a restart) build the same message here and save it directly.
let live = skald.approval() let live = ctx.approval
.resolve_for_tool_call(tc_id, ApprovalDecision::Rejected { note: body.note.clone() }) .resolve_for_tool_call(tc_id, ApprovalDecision::Rejected { note: body.note.clone() })
.await; .await;
let msg = ApprovalDecision::rejection_message(&body.note); let msg = ApprovalDecision::rejection_message(&body.note);
if !live { if !live {
chat_llm_tools::reject(skald.db(), tc_id, &msg).await?; chat_llm_tools::reject(db, tc_id, &msg).await?;
} }
return Ok(Json(ResolveToolResponse { return Ok(Json(ResolveToolResponse {
tool_call_id: tc_id, tool_call_id: tc_id,
@@ -169,7 +181,7 @@ pub async fn resolve_tool(
// `restart` calls process::exit — mark done in DB first. // `restart` calls process::exit — mark done in DB first.
if tc_name == tn::RESTART { if tc_name == tn::RESTART {
chat_llm_tools::complete(skald.db(), tc_id, "Riavvio avviato.", "string").await?; chat_llm_tools::complete(db, tc_id, "Riavvio avviato.", "string").await?;
// Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in // Use _exit() to skip C atexit handlers (e.g. Metal GPU cleanup in
// whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134 // whisper-rs/ggml, which aborts with SIGABRT and yields exit code 134
// instead of 255 — breaking the run.sh restart supervisor). // instead of 255 — breaking the run.sh restart supervisor).
@@ -177,7 +189,7 @@ pub async fn resolve_tool(
} }
// ── Live path: LLM loop is blocked waiting for approval ────────────────── // ── Live path: LLM loop is blocked waiting for approval ──────────────────
if skald.approval() if ctx.approval
.resolve_for_tool_call(tc_id, ApprovalDecision::Approved) .resolve_for_tool_call(tc_id, ApprovalDecision::Approved)
.await .await
{ {
@@ -196,9 +208,9 @@ pub async fn resolve_tool(
// via `execute_tool_call` (gate skipped) and continues the loop. Events stream // via `execute_tool_call` (gate skipped) and continues the loop. Events stream
// to the reconnected client through the global bus; return immediately. // to the reconnected client through the global bus; return immediately.
if tc_name == "execute_task" || tc_name == tn::EXECUTE_SUBTASK || tc_name == "run_subtask" { if tc_name == "execute_task" || tc_name == tn::EXECUTE_SUBTASK || tc_name == "run_subtask" {
let handler = skald.chat_hub().handler_for_session(session_id).await?; let handler = ctx.chat_hub.handler_for_session(session_id).await?;
handler.mark_pre_approved(tc_id); handler.mark_pre_approved(tc_id);
let hub = skald.chat_hub().clone(); let hub = ctx.chat_hub.clone();
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = hub.resume_session(session_id).await { if let Err(e) = hub.resume_session(session_id).await {
tracing::warn!(session_id, tool_call_id = tc_id, error = %e, "post-restart resume of sub-agent tool failed"); tracing::warn!(session_id, tool_call_id = tc_id, error = %e, "post-restart resume of sub-agent tool failed");
@@ -213,12 +225,12 @@ pub async fn resolve_tool(
} }
// Simple tools: execute directly on the owning session and return the result. // Simple tools: execute directly on the owning session and return the result.
let handler = skald.chat_hub().handler_for_session(session_id).await?; let handler = ctx.chat_hub.handler_for_session(session_id).await?;
match handler.execute_tool(&tc_name, args).await { match handler.execute_tool(&tc_name, args).await {
Ok(result) => { Ok(result) => {
let wire = result.to_wire(); let wire = result.to_wire();
let kind = result.kind(); let kind = result.kind();
chat_llm_tools::complete(skald.db(), tc_id, &wire, kind).await?; chat_llm_tools::complete(db, tc_id, &wire, kind).await?;
Ok(Json(ResolveToolResponse { Ok(Json(ResolveToolResponse {
tool_call_id: tc_id, tool_call_id: tc_id,
status: "done".to_string(), status: "done".to_string(),
@@ -228,7 +240,7 @@ pub async fn resolve_tool(
} }
Err(e) => { Err(e) => {
let msg = e.to_string(); let msg = e.to_string();
chat_llm_tools::fail(skald.db(), tc_id, &msg).await?; chat_llm_tools::fail(db, tc_id, &msg).await?;
Err(anyhow::anyhow!(msg).into()) Err(anyhow::anyhow!(msg).into())
} }
} }
@@ -250,8 +262,11 @@ fn default_per_page() -> i64 { 20 }
pub async fn list_sessions( pub async fn list_sessions(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(q): Query<ListSessionsQuery>, Query(q): Query<ListSessionsQuery>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
let per_page = q.per_page.max(1).min(100); let per_page = q.per_page.max(1).min(100);
let offset = ((q.page.max(1)) - 1) * per_page; let offset = ((q.page.max(1)) - 1) * per_page;
let src = q.source.as_deref(); let src = q.source.as_deref();
@@ -261,7 +276,7 @@ pub async fn list_sessions(
WHERE (? IS NULL OR cs.source = ?)", WHERE (? IS NULL OR cs.source = ?)",
) )
.bind(src).bind(src) .bind(src).bind(src)
.fetch_one(&**skald.db()).await?; .fetch_one(&**db).await?;
let rows = sqlx::query_as::<_, (i64, String, String, bool, bool, Option<String>, i64, Option<String>)>( let rows = sqlx::query_as::<_, (i64, String, String, bool, bool, Option<String>, i64, Option<String>)>(
"SELECT cs.id, cs.source, cs.agent_id, cs.is_ephemeral, cs.is_interactive, "SELECT cs.id, cs.source, cs.agent_id, cs.is_ephemeral, cs.is_interactive,
@@ -278,7 +293,7 @@ pub async fn list_sessions(
) )
.bind(src).bind(src) .bind(src).bind(src)
.bind(per_page).bind(offset) .bind(per_page).bind(offset)
.fetch_all(&**skald.db()).await?; .fetch_all(&**db).await?;
let items: Vec<Value> = rows.into_iter().map(|(id, source, agent_id, is_ephemeral, is_interactive, created_at, message_count, last_message_at)| { let items: Vec<Value> = rows.into_iter().map(|(id, source, agent_id, is_ephemeral, is_interactive, created_at, message_count, last_message_at)| {
json!({ json!({
@@ -308,9 +323,12 @@ pub struct SessionIdPath { pub id: i64 }
pub async fn get_session_detail( pub async fn get_session_detail(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SessionIdPath>, Path(p): Path<SessionIdPath>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let session = chat_sessions::find_by_id(skald.db(), p.id) let ctx = require_context(&skald, &auth.user_id).await?;
let db = &ctx.pool;
let session = chat_sessions::find_by_id(db, p.id)
.await? .await?
.ok_or_else(|| ApiError::not_found(format!("session {} not found", p.id)))?; .ok_or_else(|| ApiError::not_found(format!("session {} not found", p.id)))?;
@@ -318,10 +336,10 @@ pub async fn get_session_detail(
"SELECT created_at FROM chat_sessions WHERE id = ?", "SELECT created_at FROM chat_sessions WHERE id = ?",
) )
.bind(p.id) .bind(p.id)
.fetch_optional(&**skald.db()) .fetch_optional(&**db)
.await?; .await?;
let all_stacks = chat_sessions_stack::all_for_session(skald.db(), session.id).await?; let all_stacks = chat_sessions_stack::all_for_session(db, session.id).await?;
let subagent_map: HashMap<i64, SessionStack> = all_stacks let subagent_map: HashMap<i64, SessionStack> = all_stacks
.iter() .iter()
@@ -340,7 +358,7 @@ pub async fn get_session_detail(
}; };
let mut messages: Vec<Value> = Vec::new(); let mut messages: Vec<Value> = Vec::new();
build_debug_items(skald.db(), skald.tools(), &main_stack, &subagent_map, &mut messages).await?; build_debug_items(db, skald.tools(), &main_stack, &subagent_map, &mut messages).await?;
Ok(Json(json!({ Ok(Json(json!({
"session": { "session": {
+5 -3
View File
@@ -2,7 +2,7 @@ use std::path::{Path as StdPath, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use axum::{ use axum::{
Json, Json, Extension,
extract::{Multipart, Path, State}, extract::{Multipart, Path, State},
}; };
use tokio::io::AsyncWriteExt; use tokio::io::AsyncWriteExt;
@@ -11,7 +11,7 @@ use core_api::message_meta::Attachment;
use skald_core::skald::Skald; use skald_core::skald::Skald;
use skald_core::tools::fs as fs_tools; use skald_core::tools::fs as fs_tools;
use super::ApiError; use super::{ApiError, guard::AuthUser, require_context};
use super::sessions::SourcePath; use super::sessions::SourcePath;
/// `POST /api/{source}/uploads` /// `POST /api/{source}/uploads`
@@ -25,12 +25,14 @@ use super::sessions::SourcePath;
/// size) so the client can show chips and echo them back when sending the message. /// size) so the client can show chips and echo them back when sending the message.
pub async fn upload( pub async fn upload(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<SourcePath>, Path(p): Path<SourcePath>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<Json<Vec<Attachment>>, ApiError> { ) -> Result<Json<Vec<Attachment>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
// Resolve (creating if needed) the source's session so uploads land in the // Resolve (creating if needed) the source's session so uploads land in the
// directory the message will reference. // directory the message will reference.
let session_id = skald.chat_hub().session_handler(&p.source).await?.session_id; let session_id = ctx.chat_hub.session_handler(&p.source).await?.session_id;
let dir_rel = format!("data/uploads/{session_id}"); let dir_rel = format!("data/uploads/{session_id}");
let dir_abs = fs_tools::resolve(&dir_rel)?; let dir_abs = fs_tools::resolve(&dir_rel)?;