llm request tracking, user context cleanup, minor fixes

This commit is contained in:
2026-07-11 00:23:14 +01:00
parent 5936cf0b2e
commit 587958ffe0
24 changed files with 402 additions and 347 deletions
+7
View File
@@ -39,6 +39,13 @@ pub struct ChatOptions {
/// providers — only the logging wrapper reads them. /// providers — only the logging wrapper reads them.
pub session_id: Option<i64>, pub session_id: Option<i64>,
pub stack_id: Option<i64>, pub stack_id: Option<i64>,
/// The authenticated user driving this request. Correlates the metadata row
/// in `system.db` with the payload in `{userid}.db`. Logging-only.
pub user_id: Option<String>,
/// UUID correlating the metadata row (`llm_requests`) with the payload row
/// (`llm_request_payloads`). Generated by the LLM loop before the call.
/// Logging-only.
pub request_id: Option<String>,
} }
/// Raw HTTP metadata captured during a provider call. /// Raw HTTP metadata captured during a provider call.
-4
View File
@@ -30,13 +30,9 @@ pub type RouterFactory = Arc<dyn Fn() -> axum::Router + Send + Sync>;
/// `RemotePlugin`. /// `RemotePlugin`.
#[derive(Clone)] #[derive(Clone)]
pub struct PluginContext { pub struct PluginContext {
pub chat_hub: Arc<dyn ChatHubApi>,
/// Custom file-based slash commands (`commands/<name>/`). Read-only from the /// Custom file-based slash commands (`commands/<name>/`). Read-only from the
/// plugin side — lets the Telegram bot resolve `/command` expansions. /// plugin side — lets the Telegram bot resolve `/command` expansions.
pub command: Arc<dyn CommandApi>, pub command: Arc<dyn CommandApi>,
pub approval: Arc<dyn ApprovalApi>,
/// Unified Inbox façade (approvals + clarifications). See plugin.md §12.2.
pub inbox: Arc<dyn InboxApi>,
/// Skald's shared SQLite pool — lets plugins create/use their own tables /// Skald's shared SQLite pool — lets plugins create/use their own tables
/// (e.g. `relay_*`) in the main DB. See plugin.md §12.1. /// (e.g. `relay_*`) in the main DB. See plugin.md §12.1.
pub db: Arc<sqlx::SqlitePool>, pub db: Arc<sqlx::SqlitePool>,
+29 -53
View File
@@ -1,11 +1,12 @@
//! Transparent logging wrapper for any [`ChatbotClient`]. //! Transparent logging wrapper for any [`ChatbotClient`].
//! //!
//! [`LoggingChatbotClient`] intercepts every `chat_with_tools` call, captures //! [`LoggingChatbotClient`] intercepts every `chat_with_tools_raw` call, captures
//! the raw HTTP request/response from the inner provider via `chat_with_tools_raw`, //! the raw HTTP request/response from the inner provider, persists a **metadata-only**
//! then persists a row to `llm_requests` asynchronously (fire-and-forget). //! row to `llm_requests` in `system.db` (fire-and-forget), then returns the raw data
//! to the caller so it can write the **payload** to the user's own database.
//! //!
//! The LLM loop is completely unaware of this: it only holds an //! The split keeps conversation content (payloads) behind the user key while
//! `Arc<dyn ChatbotClient>` and calls `chat_with_tools` as usual. //! metadata (cost, tokens, timing) stays in the admin-readable registry.
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@@ -21,26 +22,10 @@ use super::{ChatOptions, ChatResponse, ChatbotClient, LlmRawMeta, LlmTurn, Messa
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
/// Controls which parts of the HTTP exchange are persisted per row.
#[derive(Debug, Clone, Copy)]
pub struct LogSaveFlags {
pub request_payload: bool,
pub response_payload: bool,
pub request_headers: bool,
pub response_headers: bool,
}
impl Default for LogSaveFlags {
fn default() -> Self {
Self { request_payload: true, response_payload: true, request_headers: true, response_headers: true }
}
}
pub struct LoggingChatbotClient { pub struct LoggingChatbotClient {
inner: Arc<dyn ChatbotClient>, inner: Arc<dyn ChatbotClient>,
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
model_name: String, model_name: String,
flags: LogSaveFlags,
} }
impl LoggingChatbotClient { impl LoggingChatbotClient {
@@ -48,9 +33,8 @@ impl LoggingChatbotClient {
inner: Arc<dyn ChatbotClient>, inner: Arc<dyn ChatbotClient>,
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
model_name: impl Into<String>, model_name: impl Into<String>,
flags: LogSaveFlags,
) -> Self { ) -> Self {
Self { inner, pool, model_name: model_name.into(), flags } Self { inner, pool, model_name: model_name.into() }
} }
} }
@@ -65,20 +49,35 @@ impl ChatbotClient for LoggingChatbotClient {
self.inner.chat(messages, options).await self.inner.chat(messages, options).await
} }
/// Intercepts the call, delegates to `inner.chat_with_tools_raw` to capture /// Passthrough that drops the raw meta. Used by callers that do not need
/// HTTP wire data, then spawns a fire-and-forget DB write before returning. /// payload capture (e.g. the compactor).
async fn chat_with_tools( async fn chat_with_tools(
&self, &self,
messages: &[Value], messages: &[Value],
tools: &[Value], tools: &[Value],
options: &ChatOptions, options: &ChatOptions,
) -> anyhow::Result<LlmTurn> { ) -> anyhow::Result<LlmTurn> {
let (turn, _) = self.chat_with_tools_raw(messages, tools, options).await?;
Ok(turn)
}
/// Intercepts the call, delegates to `inner.chat_with_tools_raw` to capture
/// HTTP wire data, writes a **metadata-only** row to `system.db`, then returns
/// the raw data so the caller can persist payloads to the user's own database.
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
let start = Instant::now(); let start = Instant::now();
let result = self.inner.chat_with_tools_raw(messages, tools, options).await; let result = self.inner.chat_with_tools_raw(messages, tools, options).await;
let duration_ms = start.elapsed().as_millis() as i64; let duration_ms = start.elapsed().as_millis() as i64;
let session_id = options.session_id; let session_id = options.session_id;
let stack_id = options.stack_id; let stack_id = options.stack_id;
let user_id = options.user_id.clone();
let request_id = options.request_id.clone();
let model_name = self.model_name.clone(); let model_name = self.model_name.clone();
let pool = Arc::clone(&self.pool); let pool = Arc::clone(&self.pool);
@@ -90,24 +89,13 @@ impl ChatbotClient for LoggingChatbotClient {
(*input_tokens, *output_tokens, *cache_read_tokens, *cache_creation_tokens), (*input_tokens, *output_tokens, *cache_read_tokens, *cache_creation_tokens),
}; };
let meta = meta.unwrap_or_default();
let flags = self.flags;
let request_json = if flags.request_payload {
meta.request_body.map(|v| v.to_string()).unwrap_or_default()
} else { String::new() };
let request_headers = if flags.request_headers { meta.request_headers.map(|v| v.to_string()) } else { None };
let response_json = if flags.response_payload { meta.response_body.map(|v| v.to_string()) } else { None };
let response_headers = if flags.response_headers { meta.response_headers.map(|v| v.to_string()) } else { None };
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow { if let Err(e) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id, session_id,
stack_id, stack_id,
model_name, model_name,
request_json,
request_headers,
response_json,
response_headers,
error_text: None, error_text: None,
input_tokens: input_tokens.map(|n| n as i64), input_tokens: input_tokens.map(|n| n as i64),
output_tokens: output_tokens.map(|n| n as i64), output_tokens: output_tokens.map(|n| n as i64),
@@ -119,7 +107,7 @@ impl ChatbotClient for LoggingChatbotClient {
} }
}); });
Ok(turn) Ok((turn, meta))
} }
Err(e) => { Err(e) => {
@@ -127,13 +115,11 @@ impl ChatbotClient for LoggingChatbotClient {
tokio::spawn(async move { tokio::spawn(async move {
if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow { if let Err(log_err) = llm_requests::insert(&pool, llm_requests::LlmRequestRow {
request_id,
user_id,
session_id, session_id,
stack_id, stack_id,
model_name, model_name,
request_json: String::new(),
request_headers: None,
response_json: None,
response_headers: None,
error_text: Some(error_text), error_text: Some(error_text),
input_tokens: None, input_tokens: None,
output_tokens: None, output_tokens: None,
@@ -149,14 +135,4 @@ impl ChatbotClient for LoggingChatbotClient {
} }
} }
} }
/// Expose raw metadata so this wrapper can itself be wrapped if needed.
async fn chat_with_tools_raw(
&self,
messages: &[Value],
tools: &[Value],
options: &ChatOptions,
) -> anyhow::Result<(LlmTurn, Option<LlmRawMeta>)> {
self.inner.chat_with_tools_raw(messages, tools, options).await
}
} }
+2
View File
@@ -313,6 +313,8 @@ impl ContextCompactor {
temperature: Some(0.3), temperature: Some(0.3),
session_id: Some(session_id), session_id: Some(session_id),
stack_id: Some(stack_id), stack_id: Some(stack_id),
user_id: None,
request_id: None,
}; };
let turn = llm.client.chat_with_tools(&messages_payload, &[], &options).await let turn = llm.client.chat_with_tools(&messages_payload, &[], &options).await
@@ -0,0 +1,32 @@
//! DB operations for the `llm_request_payloads` table (owner bucket).
//!
//! Full request/response payloads + headers for each LLM call. Lives in
//! `{userid}.db` (encrypted), correlated with the metadata row in `system.db`
//! via `request_id`.
use anyhow::Result;
use sqlx::SqlitePool;
pub struct PayloadRow {
pub request_json: String,
pub request_headers: Option<String>,
pub response_json: Option<String>,
pub response_headers: Option<String>,
pub request_id: String,
}
pub async fn insert(pool: &SqlitePool, row: PayloadRow) -> Result<()> {
sqlx::query(
"INSERT INTO llm_request_payloads
(request_id, request_json, request_headers, response_json, response_headers)
VALUES (?, ?, ?, ?, ?)",
)
.bind(&row.request_id)
.bind(&row.request_json)
.bind(&row.request_headers)
.bind(&row.response_json)
.bind(&row.response_headers)
.execute(pool)
.await?;
Ok(())
}
@@ -1,9 +1,9 @@
//! Background maintenance task for the `llm_requests` table. //! Background maintenance task for the `llm_requests` table.
//! //!
//! Periodically nulls out old payloads/headers and deletes expired rows according //! Deletes expired metadata rows according to the retention settings in
//! to the retention settings in [`LlmRequestsLogConfig`], then `VACUUM`s to reclaim //! [`LlmRequestsLogConfig`], then `VACUUM`s to reclaim freed pages.
//! freed pages. Extracted from `Skald::new` so the loop lives next to the queries it //! Payload/header nulling is gone — those columns moved to `llm_request_payloads`
//! calls; the returned handle is registered with the `TaskSupervisor` for shutdown. //! in the owner bucket.
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
@@ -31,27 +31,6 @@ pub fn spawn(
_ = tokio::time::sleep(Duration::from_secs(60)) => {} _ = tokio::time::sleep(Duration::from_secs(60)) => {}
} }
loop { loop {
if let Some(days) = cfg.cleanup_request_payload_after {
match super::null_request_payload(&pool, days).await {
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled request payload"),
Ok(_) => {}
Err(e) => warn!(error = %e, "llm_requests: null request payload failed"),
}
}
if let Some(days) = cfg.cleanup_response_payload_after {
match super::null_response_payload(&pool, days).await {
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled response payload"),
Ok(_) => {}
Err(e) => warn!(error = %e, "llm_requests: null response payload failed"),
}
}
if let Some(days) = cfg.cleanup_headers_after {
match super::null_headers(&pool, days).await {
Ok(n) if n > 0 => info!(rows = n, days, "llm_requests: nulled headers"),
Ok(_) => {}
Err(e) => warn!(error = %e, "llm_requests: null headers failed"),
}
}
if let Some(days) = cfg.cleanup_rows_after { if let Some(days) = cfg.cleanup_rows_after {
match super::delete_old_rows(&pool, days).await { match super::delete_old_rows(&pool, days).await {
Ok(n) if n > 0 => info!(deleted = n, days, "llm_requests: deleted old rows"), Ok(n) if n > 0 => info!(deleted = n, days, "llm_requests: deleted old rows"),
@@ -59,7 +38,7 @@ pub fn spawn(
Err(e) => warn!(error = %e, "llm_requests: delete old rows failed"), Err(e) => warn!(error = %e, "llm_requests: delete old rows failed"),
} }
} }
// VACUUM reclaims pages freed by DELETE/UPDATE NULL. // VACUUM reclaims pages freed by DELETE.
match sqlx::query("VACUUM").execute(&*pool).await { match sqlx::query("VACUUM").execute(&*pool).await {
Ok(_) => info!("llm_requests: VACUUM complete"), Ok(_) => info!("llm_requests: VACUUM complete"),
Err(e) => warn!(error = %e, "llm_requests: VACUUM failed"), Err(e) => warn!(error = %e, "llm_requests: VACUUM failed"),
+9 -61
View File
@@ -1,7 +1,9 @@
//! DB operations for the `llm_requests` table. //! DB operations for the `llm_requests` table (metadata only).
//! //!
//! Every `chat_with_tools` call is logged here by the //! Every `chat_with_tools` call is logged here by the
//! [`crate::chatbot::logging::LoggingChatbotClient`] wrapper. //! [`crate::chatbot::logging::LoggingChatbotClient`] wrapper.
//! Payloads (request/response bodies + headers) live in `llm_request_payloads`
//! in the owner bucket (`{userid}.db`), correlated by `request_id`.
//! Rows are retained for `llm.request_log.retention_days` days (default 14). //! Rows are retained for `llm.request_log.retention_days` days (default 14).
use anyhow::Result; use anyhow::Result;
@@ -12,17 +14,11 @@ pub mod cleanup;
// ── Row struct ──────────────────────────────────────────────────────────────── // ── Row struct ────────────────────────────────────────────────────────────────
pub struct LlmRequestRow { pub struct LlmRequestRow {
pub request_id: Option<String>,
pub user_id: Option<String>,
pub session_id: Option<i64>, pub session_id: Option<i64>,
pub stack_id: Option<i64>, pub stack_id: Option<i64>,
pub model_name: String, pub model_name: String,
/// Full HTTP request body sent to the provider (compact JSON, no pretty-print).
pub request_json: String,
/// HTTP request headers as a compact JSON object (api-key redacted).
pub request_headers: Option<String>,
/// Full HTTP response body from the provider (compact JSON).
pub response_json: Option<String>,
/// HTTP response headers as a compact JSON object.
pub response_headers: Option<String>,
/// Error message when the HTTP call itself failed (no response available). /// Error message when the HTTP call itself failed (no response available).
pub error_text: Option<String>, pub error_text: Option<String>,
pub input_tokens: Option<i64>, pub input_tokens: Option<i64>,
@@ -40,21 +36,17 @@ pub struct LlmRequestRow {
pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> { pub async fn insert(pool: &SqlitePool, row: LlmRequestRow) -> Result<i64> {
let id = sqlx::query_scalar::<_, i64>( let id = sqlx::query_scalar::<_, i64>(
"INSERT INTO llm_requests ( "INSERT INTO llm_requests (
session_id, stack_id, model_name, request_id, user_id, session_id, stack_id, model_name,
request_json, request_headers,
response_json, response_headers,
error_text, input_tokens, output_tokens, duration_ms, error_text, input_tokens, output_tokens, duration_ms,
cache_read_tokens, cache_creation_tokens cache_read_tokens, cache_creation_tokens
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id", RETURNING id",
) )
.bind(&row.request_id)
.bind(&row.user_id)
.bind(row.session_id) .bind(row.session_id)
.bind(row.stack_id) .bind(row.stack_id)
.bind(&row.model_name) .bind(&row.model_name)
.bind(&row.request_json)
.bind(&row.request_headers)
.bind(&row.response_json)
.bind(&row.response_headers)
.bind(&row.error_text) .bind(&row.error_text)
.bind(row.input_tokens) .bind(row.input_tokens)
.bind(row.output_tokens) .bind(row.output_tokens)
@@ -79,47 +71,3 @@ pub async fn delete_old_rows(pool: &SqlitePool, days: u32) -> Result<u64> {
.rows_affected(); .rows_affected();
Ok(n) Ok(n)
} }
/// Nulls out `request_json` for rows older than `days` days. Returns rows affected.
pub async fn null_request_payload(pool: &SqlitePool, days: u32) -> Result<u64> {
let cutoff = format!("-{days} days");
let n = sqlx::query(
"UPDATE llm_requests SET request_json = '' \
WHERE request_json != '' AND created_at < datetime('now', ?)",
)
.bind(&cutoff)
.execute(pool)
.await?
.rows_affected();
Ok(n)
}
/// Nulls out `response_json` for rows older than `days` days. Returns rows affected.
pub async fn null_response_payload(pool: &SqlitePool, days: u32) -> Result<u64> {
let cutoff = format!("-{days} days");
let n = sqlx::query(
"UPDATE llm_requests SET response_json = NULL \
WHERE response_json IS NOT NULL AND created_at < datetime('now', ?)",
)
.bind(&cutoff)
.execute(pool)
.await?
.rows_affected();
Ok(n)
}
/// Nulls out both header columns for rows older than `days` days. Returns rows affected.
pub async fn null_headers(pool: &SqlitePool, days: u32) -> Result<u64> {
let cutoff = format!("-{days} days");
let n = sqlx::query(
"UPDATE llm_requests \
SET request_headers = NULL, response_headers = NULL \
WHERE (request_headers IS NOT NULL OR response_headers IS NOT NULL) \
AND created_at < datetime('now', ?)",
)
.bind(&cutoff)
.execute(pool)
.await?
.rows_affected();
Ok(n)
}
+24 -4
View File
@@ -10,6 +10,7 @@ pub mod config;
pub mod job_runs; pub mod job_runs;
pub mod known_tools; pub mod known_tools;
pub mod llm_requests; pub mod llm_requests;
pub mod llm_request_payloads;
pub mod mcp_events; pub mod mcp_events;
pub mod mcp_servers; pub mod mcp_servers;
pub mod plugins; pub mod plugins;
@@ -317,16 +318,17 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
// decrypting anything: the admin sees how much, when and which model — never // decrypting anything: the admin sees how much, when and which model — never
// what was said. `session_id` / `stack_id` are bare integers, not foreign // what was said. `session_id` / `stack_id` are bare integers, not foreign
// keys, precisely because the rows they point at live in another file. // keys, precisely because the rows they point at live in another file.
// `user_id` correlates the row with the payload in `{userid}.db`.
// Payloads (request/response bodies, headers) live in `llm_request_payloads`
// in the owner bucket — they are conversation content, behind the user key.
sqlx::query( sqlx::query(
"CREATE TABLE IF NOT EXISTS llm_requests ( "CREATE TABLE IF NOT EXISTS llm_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT,
user_id TEXT,
session_id INTEGER, session_id INTEGER,
stack_id INTEGER, stack_id INTEGER,
model_name TEXT NOT NULL, model_name TEXT NOT NULL,
request_json TEXT NOT NULL DEFAULT '',
request_headers TEXT,
response_json TEXT,
response_headers TEXT,
error_text TEXT, error_text TEXT,
input_tokens INTEGER, input_tokens INTEGER,
output_tokens INTEGER, output_tokens INTEGER,
@@ -691,6 +693,23 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
.execute(pool) .execute(pool)
.await?; .await?;
// Full request/response payloads for telemetry. Lives in the owner bucket
// (per-user, encrypted) because it is conversation content. Correlated with
// the metadata row in `system.db` via `request_id` (uuid).
sqlx::query(
"CREATE TABLE IF NOT EXISTS llm_request_payloads (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id TEXT NOT NULL,
request_json TEXT NOT NULL DEFAULT '',
request_headers TEXT,
response_json TEXT,
response_headers TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
)
.execute(pool)
.await?;
Ok(()) Ok(())
} }
@@ -744,6 +763,7 @@ mod tests {
one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap(); one("INSERT INTO secrets (key, value) VALUES ('k', 'v')").await.unwrap();
one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").await.unwrap(); one("INSERT INTO projects (id, name, path) VALUES (1, 'p', '/tmp')").await.unwrap();
one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap(); one("INSERT INTO project_tickets (project_id, title, job_id) VALUES (1, 't', 1)").await.unwrap();
one("INSERT INTO llm_request_payloads (request_id, request_json) VALUES ('r1', '{}')").await.unwrap();
pool.close().await; pool.close().await;
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
+2
View File
@@ -456,6 +456,8 @@ mod tests {
async fn cleartext_user_round_trips_with_and_without_a_verifier() { async fn cleartext_user_round_trips_with_and_without_a_verifier() {
let path = temp_db_path("users-clear"); let path = temp_db_path("users-clear");
let pool = crate::db::init_system_pool(&path).await.unwrap(); let pool = crate::db::init_system_pool(&path).await.unwrap();
crate::db::roles::insert(&pool, "children", "Children", "default", None)
.await.unwrap();
insert(&pool, "u-1", "kid", None, "children", &cleartext()).await.unwrap(); insert(&pool, "u-1", "kid", None, "children", &cleartext()).await.unwrap();
insert(&pool, "u-2", "kiosk", None, "children", &Credentials::Cleartext(None)).await.unwrap(); insert(&pool, "u-2", "kiosk", None, "children", &Credentials::Cleartext(None)).await.unwrap();
+13 -13
View File
@@ -9,7 +9,7 @@ use tokio::sync::RwLock;
use tracing::{info, warn}; use tracing::{info, warn};
use crate::chatbot::ChatbotClient; use crate::chatbot::ChatbotClient;
use crate::chatbot::logging::{LoggingChatbotClient, LogSaveFlags}; use crate::chatbot::logging::LoggingChatbotClient;
use core_api::provider::LlmStrength; use core_api::provider::LlmStrength;
use crate::provider::{ApiProvider, ProviderRegistry, ReasoningMode}; use crate::provider::{ApiProvider, ProviderRegistry, ReasoningMode};
@@ -69,15 +69,15 @@ pub struct LlmManager {
catalog: RwLock<HashMap<i64, CachedCatalog>>, catalog: RwLock<HashMap<i64, CachedCatalog>>,
/// Per-model metadata cache, keyed by model display name. TTL = 1h. /// Per-model metadata cache, keyed by model display name. TTL = 1h.
model_meta_cache: RwLock<HashMap<String, CachedModelMeta>>, model_meta_cache: RwLock<HashMap<String, CachedModelMeta>>,
/// When `Some`, every LLM entry is wrapped with [`LoggingChatbotClient`]. /// When `true`, every LLM entry is wrapped with [`LoggingChatbotClient`].
log_flags: Option<LogSaveFlags>, log_enabled: bool,
} }
impl LlmManager { impl LlmManager {
pub async fn new( pub async fn new(
pool: Arc<SqlitePool>, pool: Arc<SqlitePool>,
registry: Arc<ProviderRegistry>, registry: Arc<ProviderRegistry>,
log_flags: Option<LogSaveFlags>, log_enabled: bool,
) -> Result<Arc<Self>> { ) -> Result<Arc<Self>> {
let mgr = Arc::new(Self { let mgr = Arc::new(Self {
pool, pool,
@@ -89,7 +89,7 @@ impl LlmManager {
}), }),
catalog: RwLock::new(HashMap::new()), catalog: RwLock::new(HashMap::new()),
model_meta_cache: RwLock::new(HashMap::new()), model_meta_cache: RwLock::new(HashMap::new()),
log_flags, log_enabled,
}); });
mgr.reload().await?; mgr.reload().await?;
Ok(mgr) Ok(mgr)
@@ -461,9 +461,9 @@ impl LlmManager {
} }
}; };
let log_config = self.log_flags.map(|f| (Arc::clone(&self.pool), f)); let log_pool = self.log_enabled.then(|| Arc::clone(&self.pool));
let entry = match build_entry(&self.registry, &provider, &model, model.id, log_config) { let entry = match build_entry(&self.registry, &provider, &model, model.id, log_pool) {
Ok(e) => Arc::new(e), Ok(e) => Arc::new(e),
Err(e) => { Err(e) => {
warn!(model = %model.name, error = %e, "failed to build LLM entry, skipping"); warn!(model = %model.name, error = %e, "failed to build LLM entry, skipping");
@@ -505,7 +505,7 @@ fn build_entry(
provider: &LlmProviderRecord, provider: &LlmProviderRecord,
model: &LlmModelRecord, model: &LlmModelRecord,
model_db_id: i64, model_db_id: i64,
log_config: Option<(Arc<SqlitePool>, LogSaveFlags)>, log_pool: Option<Arc<SqlitePool>>,
) -> Result<LlmEntry> { ) -> Result<LlmEntry> {
let built = registry.get(&provider.provider) let built = registry.get(&provider.provider)
.ok_or_else(|| anyhow::anyhow!("unknown provider type '{}'", provider.provider))? .ok_or_else(|| anyhow::anyhow!("unknown provider type '{}'", provider.provider))?
@@ -516,9 +516,9 @@ fn build_entry(
let prompt_cache = built.prompt_cache; let prompt_cache = built.prompt_cache;
let extra = model.extra_params.clone(); let extra = model.extra_params.clone();
let client: Arc<dyn ChatbotClient> = match log_config { let client: Arc<dyn ChatbotClient> = match log_pool {
Some((pool, flags)) => Arc::new(LoggingChatbotClient::new(inner, pool, &model.name, flags)), Some(pool) => Arc::new(LoggingChatbotClient::new(inner, pool, &model.name)),
None => inner, None => inner,
}; };
Ok(LlmEntry { Ok(LlmEntry {
-3
View File
@@ -96,10 +96,7 @@ impl PluginManager {
.ok_or_else(|| anyhow::anyhow!("PluginManager: web_port not set"))?; .ok_or_else(|| anyhow::anyhow!("PluginManager: web_port not set"))?;
Ok(PluginContext { Ok(PluginContext {
chat_hub: Arc::clone(skald.chat_hub()) as _,
command: Arc::clone(skald.command_manager()) as _, command: Arc::clone(skald.command_manager()) as _,
approval: Arc::clone(skald.approval()) as _,
inbox: Arc::new(skald.inbox().clone()) as _,
db: Arc::clone(skald.db()), db: Arc::clone(skald.db()),
secrets: Arc::clone(skald.secrets()) as _, secrets: Arc::clone(skald.secrets()) as _,
transcribe: Arc::clone(skald.transcribe_manager()) as _, transcribe: Arc::clone(skald.transcribe_manager()) as _,
@@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken;
use tracing::{error, warn}; use tracing::{error, warn};
use crate::chatbot::{ChatOptions, LlmTurn}; use crate::chatbot::{ChatOptions, LlmTurn};
use crate::db::llm_request_payloads;
use crate::llm::{LlmEntry, LlmStrength}; use crate::llm::{LlmEntry, LlmStrength};
use super::ChatSessionHandler; use super::ChatSessionHandler;
@@ -54,12 +55,15 @@ impl ChatSessionHandler {
let mut tried_this_round: Vec<String> = vec![cur_name.clone()]; let mut tried_this_round: Vec<String> = vec![cur_name.clone()];
loop { loop {
let request_id = uuid::Uuid::new_v4().to_string();
let options = ChatOptions { let options = ChatOptions {
model: cur_llm.model.clone(), model: cur_llm.model.clone(),
max_tokens: None, max_tokens: None,
temperature: None, temperature: None,
session_id: Some(self.session_id), session_id: Some(self.session_id),
stack_id: Some(stack_id), stack_id: Some(stack_id),
user_id: Some(self.user_id.clone()),
request_id: Some(request_id.clone()),
}; };
// Clone the Arc so the in-flight future does not borrow `cur_llm` across // Clone the Arc so the in-flight future does not borrow `cur_llm` across
@@ -68,13 +72,33 @@ impl ChatSessionHandler {
let client = cur_llm.client.clone(); let client = cur_llm.client.clone();
let call_result = tokio::select! { let call_result = tokio::select! {
_ = token.cancelled() => return RoundLlm::Cancelled, _ = token.cancelled() => return RoundLlm::Cancelled,
r = client.chat_with_tools(messages.as_slice(), tool_defs, &options) => r, r = client.chat_with_tools_raw(messages.as_slice(), tool_defs, &options) => r,
}; };
let e = match call_result { let e = match call_result {
Ok(t) => { Ok((turn, meta)) => {
self.llm_manager.mark_success(cur_name).await; self.llm_manager.mark_success(cur_name).await;
return RoundLlm::Turn(t); // Persist the payload (request/response bodies + headers) to the
// user's own database. Fire-and-forget — a failed write must not
// break the turn. The metadata row is already written by the
// logging wrapper to system.db with the same request_id.
if let Some(meta) = meta {
let pool = Arc::clone(&self.db);
let rid = request_id.clone();
tokio::spawn(async move {
let row = llm_request_payloads::PayloadRow {
request_id: rid,
request_json: meta.request_body.map(|v| v.to_string()).unwrap_or_default(),
request_headers: meta.request_headers.map(|v| v.to_string()),
response_json: meta.response_body.map(|v| v.to_string()),
response_headers: meta.response_headers.map(|v| v.to_string()),
};
if let Err(e) = llm_request_payloads::insert(&pool, row).await {
tracing::warn!(error = %e, "llm_request_payloads: failed to insert");
}
});
}
return RoundLlm::Turn(turn);
} }
Err(e) => e, Err(e) => e,
}; };
@@ -262,6 +262,9 @@ impl ApprovalDecision {
pub struct ChatSessionHandler { pub struct ChatSessionHandler {
pub session_id: i64, pub session_id: i64,
pub(super) db: Arc<SqlitePool>, pub(super) db: Arc<SqlitePool>,
/// The authenticated user who owns this session. Threaded into `ChatOptions`
/// so the telemetry metadata row in `system.db` carries `user_id`.
pub(super) user_id: String,
pub(super) llm_manager: Arc<LlmManager>, pub(super) llm_manager: Arc<LlmManager>,
pub(super) max_history_messages: usize, pub(super) max_history_messages: usize,
pub(super) max_tool_rounds: usize, pub(super) max_tool_rounds: usize,
@@ -329,6 +332,7 @@ impl ChatSessionHandler {
pub fn new( pub fn new(
session_id: i64, session_id: i64,
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
@@ -353,6 +357,7 @@ impl ChatSessionHandler {
Self { Self {
session_id, session_id,
db, db,
user_id,
llm_manager, llm_manager,
max_history_messages, max_history_messages,
max_tool_rounds, max_tool_rounds,
+4
View File
@@ -22,6 +22,7 @@ use super::handler::ChatSessionHandler;
pub struct ChatSessionManager { pub struct ChatSessionManager {
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
@@ -47,6 +48,7 @@ pub struct ChatSessionManager {
impl ChatSessionManager { impl ChatSessionManager {
pub fn new( pub fn new(
db: Arc<SqlitePool>, db: Arc<SqlitePool>,
user_id: String,
llm_manager: Arc<LlmManager>, llm_manager: Arc<LlmManager>,
max_history_messages: usize, max_history_messages: usize,
max_tool_rounds: usize, max_tool_rounds: usize,
@@ -66,6 +68,7 @@ impl ChatSessionManager {
) -> Self { ) -> Self {
Self { Self {
db, db,
user_id,
llm_manager, llm_manager,
max_history_messages, max_history_messages,
max_tool_rounds, max_tool_rounds,
@@ -152,6 +155,7 @@ impl ChatSessionManager {
let handler = Arc::new(ChatSessionHandler::new( let handler = Arc::new(ChatSessionHandler::new(
session_id, session_id,
self.db.clone(), self.db.clone(),
self.user_id.clone(),
Arc::clone(&self.llm_manager), Arc::clone(&self.llm_manager),
self.max_history_messages, self.max_history_messages,
self.max_tool_rounds, self.max_tool_rounds,
+3 -10
View File
@@ -72,16 +72,8 @@ impl Models {
let provider_registry = Arc::new(provider_registry); let provider_registry = Arc::new(provider_registry);
info!("provider registry ready ({} built-in providers)", provider_registry.all().len()); info!("provider registry ready ({} built-in providers)", provider_registry.all().len());
let log_flags = config.llm.requests_log.as_ref().filter(|r| r.enabled).map(|r| { let log_enabled = config.llm.requests_log.as_ref().is_some_and(|r| r.enabled);
use crate::chatbot::logging::LogSaveFlags; let llm_manager = LlmManager::new(Arc::clone(&rt.db), Arc::clone(&provider_registry), log_enabled).await?;
LogSaveFlags {
request_payload: r.request_payload_save,
response_payload: r.response_payload_save,
request_headers: r.request_header_save,
response_headers: r.response_header_save,
}
});
let llm_manager = LlmManager::new(Arc::clone(&rt.db), Arc::clone(&provider_registry), log_flags).await?;
let client_count = llm_manager.client_names().await.len().saturating_sub(1); let client_count = llm_manager.client_names().await.len().saturating_sub(1);
let default_client = llm_manager.default_name().await; let default_client = llm_manager.default_name().await;
info!(clients = client_count, default = %default_client, "LLM clients loaded"); info!(clients = client_count, default = %default_client, "LLM clients loaded");
@@ -346,6 +338,7 @@ impl Conversation {
let manager = Arc::new(ChatSessionManager::new( let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&rt.db), Arc::clone(&rt.db),
String::new(),
Arc::clone(&models.llm_manager), Arc::clone(&models.llm_manager),
config.llm.max_history_messages, config.llm.max_history_messages,
config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS), config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS),
@@ -45,6 +45,7 @@ use crate::inbox::Inbox;
use crate::llm::LlmManager; use crate::llm::LlmManager;
use crate::mcp::McpManager; use crate::mcp::McpManager;
use crate::memory::MemoryManager; use crate::memory::MemoryManager;
use crate::projects::tickets::ProjectTicketManager;
use crate::run_context::RunContextManager; use crate::run_context::RunContextManager;
use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS}; use crate::session::handler::{DEFAULT_MAX_PARALLEL_SUBAGENTS, DEFAULT_MAX_TOOL_ROUNDS};
use crate::session::manager::ChatSessionManager; use crate::session::manager::ChatSessionManager;
@@ -62,6 +63,7 @@ pub struct UserContext {
pub sessions: Arc<ChatSessionManager>, pub sessions: Arc<ChatSessionManager>,
pub chat_hub: Arc<ChatHub>, pub chat_hub: Arc<ChatHub>,
pub cron: Arc<TaskManager>, pub cron: Arc<TaskManager>,
pub tickets: Arc<ProjectTicketManager>,
pub approval: Arc<ApprovalManager>, pub approval: Arc<ApprovalManager>,
pub clarification: Arc<ClarificationManager>, pub clarification: Arc<ClarificationManager>,
pub elicitation: Arc<ElicitationManager>, pub elicitation: Arc<ElicitationManager>,
@@ -154,6 +156,7 @@ impl UserContextFactory {
let manager = Arc::new(ChatSessionManager::new( let manager = Arc::new(ChatSessionManager::new(
Arc::clone(&pool), Arc::clone(&pool),
user_id.to_string(),
Arc::clone(&self.llm_manager), Arc::clone(&self.llm_manager),
self.max_history_messages, self.max_history_messages,
self.max_tool_rounds, self.max_tool_rounds,
@@ -189,12 +192,29 @@ impl UserContextFactory {
cron.set_self_arc(Arc::clone(&cron)); cron.set_self_arc(Arc::clone(&cron));
chat_hub.set_task_mgr(Arc::clone(&cron)); chat_hub.set_task_mgr(Arc::clone(&cron));
// Per-user ticket manager — wired to the per-user TaskManager so
// `start_ticket` spawns jobs in the user's own pool.
let tickets = ProjectTicketManager::new(Arc::clone(&pool));
tickets.set_task_manager(Arc::clone(&cron));
// Per-user cron loop. `start()` observes the shutdown token, so it stops on // Per-user cron loop. `start()` observes the shutdown token, so it stops on
// shutdown; adopting it lets the supervisor also join it. The name is leaked // shutdown; adopting it lets the supervisor also join it. The name is leaked
// to satisfy the `&'static str` label — bounded by the (small) user count. // to satisfy the `&'static str` label — bounded by the (small) user count.
let name: &'static str = Box::leak(format!("cron:{user_id}").into_boxed_str()); let name: &'static str = Box::leak(format!("cron:{user_id}").into_boxed_str());
self.supervisor.adopt(name, Arc::clone(&cron).start(self.shutdown_token.clone())); self.supervisor.adopt(name, Arc::clone(&cron).start(self.shutdown_token.clone()));
// Per-user ticket-listener: reacts to JobCompleted events for this user's
// tickets. All users' listeners receive the event (global system bus); only
// the one that owns the ticket does the UPDATE — others no-op on 0 rows.
let tname: &'static str = Box::leak(format!("tickets:{user_id}").into_boxed_str());
self.supervisor.adopt_one(
tname,
Arc::clone(&tickets).start_listener(
Arc::clone(&self.system_bus),
self.shutdown_token.clone(),
),
);
Ok(Arc::new(UserContext { Ok(Arc::new(UserContext {
user_id: user_id.to_string(), user_id: user_id.to_string(),
pool, pool,
@@ -202,6 +222,7 @@ impl UserContextFactory {
sessions: manager, sessions: manager,
chat_hub, chat_hub,
cron, cron,
tickets,
approval, approval,
clarification, clarification,
elicitation, elicitation,
+17 -38
View File
@@ -1,6 +1,11 @@
//! Post-construction wiring: the `OnceLock` cycle-breakers and the background-task //! Post-construction wiring: the `OnceLock` cycle-breakers and the background-task
//! spawns, each concentrated in one readable place instead of being scattered //! spawns, each concentrated in one readable place instead of being scattered
//! through the constructor. //! through the constructor.
//!
//! Owner-bound background loops (cron, session-cancel, ticket-listener, tic) have
//! moved per-user into `UserContextFactory::build`. What remains here are the
//! instance-wide tasks: LLM-log cleanup on the registry pool, and MCP server
//! initialization.
use std::sync::Arc; use std::sync::Arc;
@@ -14,6 +19,10 @@ use super::runtime::Runtime;
/// Resolves the construction cycles (`cron ↔ session ↔ hub`, `ticket → cron`, /// Resolves the construction cycles (`cron ↔ session ↔ hub`, `ticket → cron`,
/// `mcp → elicitation`) via the managers' `OnceLock` setters. /// `mcp → elicitation`) via the managers' `OnceLock` setters.
///
/// These wire the **global** bundles, which are transitional and will be removed
/// once all call-sites are per-user (Phase 6). They remain constructed so any
/// not-yet-migrated accessor does not panic on a `None` OnceLock.
pub(super) fn wire( pub(super) fn wire(
tasks: &Tasks, tasks: &Tasks,
conversation: &Conversation, conversation: &Conversation,
@@ -29,14 +38,16 @@ pub(super) fn wire(
info!("ChatHub initialised"); info!("ChatHub initialised");
} }
/// Spawns every long-lived background task, each registered by name with the /// Spawns the instance-wide background tasks.
/// supervisor so it is joined on shutdown. MCP `initialize()` is spawned here — ///
/// after `wire()` has installed the elicitation handler — so stdio servers start /// Owner-bound loops (cron, session-cancel, ticket-listener, tic) are **not**
/// with a handler for server-initiated `elicitation/create` requests. /// spawned here — they run per-user inside `UserContext`. Session cancellation is
/// handled directly by the API handlers (which have `AuthUser` and resolve the
/// per-user context). TIC is deferred until connectors return (§13).
pub(super) fn spawn_background( pub(super) fn spawn_background(
rt: &Runtime, rt: &Runtime,
tasks: &Tasks, _tasks: &Tasks,
conversation: &Conversation, _conversation: &Conversation,
integrations: &Integrations, integrations: &Integrations,
config: &CoreConfig, config: &CoreConfig,
) { ) {
@@ -52,29 +63,6 @@ pub(super) fn spawn_background(
); );
} }
// Session-cancellation subscriber: fans SessionCancelled events on the system
// bus into cancel_session() so any in-flight turn / approval / clarification
// all unblock.
{
let manager_ref = Arc::clone(&conversation.manager);
let mut rx = rt.system_bus.subscribe();
let sd = rt.shutdown_token.clone();
rt.supervisor.spawn("session-cancel", async move {
loop {
tokio::select! {
_ = sd.cancelled() => break,
event = rx.recv() => match event {
Ok(core_api::system_bus::SystemEvent::SessionCancelled { session_id }) => {
manager_ref.cancel_session(session_id).await;
}
Ok(_) => {}
Err(_) => break,
}
}
}
});
}
// MCP servers connect in the background. `initialize()` does not itself observe // MCP servers connect in the background. `initialize()` does not itself observe
// the cancellation token, so race it against shutdown: on cancel the task exits // the cancellation token, so race it against shutdown: on cancel the task exits
// promptly (dropping the in-flight connection attempts) instead of blocking the // promptly (dropping the in-flight connection attempts) instead of blocking the
@@ -89,13 +77,4 @@ pub(super) fn spawn_background(
} }
}); });
} }
rt.supervisor.adopt("cron", Arc::clone(&tasks.cron).start(rt.shutdown_token.clone()));
info!("cron scheduler started");
rt.supervisor.adopt_one(
"ticket-listener",
Arc::clone(&tasks.ticket_manager).start_listener(Arc::clone(&rt.system_bus), rt.shutdown_token.clone()),
);
rt.supervisor.adopt_one("tic", Arc::clone(&conversation.tic_manager).start(rt.shutdown_token.clone()));
info!("TicManager started");
} }
+5
View File
@@ -507,6 +507,11 @@ mod tests {
let system = db::init_system_pool(dir.join("system.db").to_str().unwrap()) let system = db::init_system_pool(dir.join("system.db").to_str().unwrap())
.await .await
.unwrap(); .unwrap();
// Seed the non-admin role some tests use, so the FK on
// users.role_id → roles(id) holds.
db::roles::insert(&system, "children", "Children", "default", None)
.await
.unwrap();
let users = UserManager { let users = UserManager {
system: Arc::new(system), system: Arc::new(system),
+5 -4
View File
@@ -6,6 +6,7 @@ use serde::Deserialize;
use serde_json::{Value, json}; use serde_json::{Value, json};
use skald_core::approval::NewApprovalRule; use skald_core::approval::NewApprovalRule;
use skald_core::db::approval_rules;
use skald_core::tool_catalog::{AllTools, McpServerMeta, ToolInfo}; use skald_core::tool_catalog::{AllTools, McpServerMeta, ToolInfo};
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
@@ -18,7 +19,7 @@ use super::{ApiError, guard::AuthUser, require_context};
pub async fn list_rules( pub async fn list_rules(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let rules = skald.approval().list_rules().await?; let rules = approval_rules::list(skald.db()).await?;
Ok(Json(json!(rules))) Ok(Json(json!(rules)))
} }
@@ -28,7 +29,7 @@ pub async fn create_rule(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Json(body): Json<NewApprovalRule>, Json(body): Json<NewApprovalRule>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
let id = skald.approval().add_rule(body).await?; let id = approval_rules::insert(skald.db(), body).await?;
Ok(Json(json!({ "id": id }))) Ok(Json(json!({ "id": id })))
} }
@@ -42,7 +43,7 @@ pub async fn update_rule(
Path(p): Path<RulePath>, Path(p): Path<RulePath>,
Json(body): Json<NewApprovalRule>, Json(body): Json<NewApprovalRule>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
skald.approval().update_rule(p.id, body).await?; approval_rules::update(skald.db(), p.id, body).await?;
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
@@ -52,7 +53,7 @@ pub async fn delete_rule(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Path(p): Path<RulePath>, Path(p): Path<RulePath>,
) -> Result<Json<Value>, ApiError> { ) -> Result<Json<Value>, ApiError> {
skald.approval().delete_rule(p.id).await?; approval_rules::delete(skald.db(), p.id).await?;
Ok(Json(json!({ "ok": true }))) Ok(Json(json!({ "ok": true })))
} }
+32 -15
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
Json, Json, Extension,
extract::{Path, State}, extract::{Path, State},
http::StatusCode, http::StatusCode,
}; };
@@ -8,7 +8,7 @@ use serde::Deserialize;
use skald_core::db::{scheduled_jobs, job_runs}; use skald_core::db::{scheduled_jobs, job_runs};
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};
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
pub struct JobResponse { pub struct JobResponse {
@@ -29,8 +29,12 @@ pub struct JobResponse {
pub running_since: Option<String>, pub running_since: Option<String>,
} }
pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<JobResponse>>, ApiError> { pub async fn list(
let jobs = scheduled_jobs::list(skald.db()).await?; State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<JobResponse>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let jobs = scheduled_jobs::list(&ctx.pool).await?;
Ok(Json(jobs.into_iter().map(|j| JobResponse { Ok(Json(jobs.into_iter().map(|j| JobResponse {
id: j.id, id: j.id,
title: j.title, title: j.title,
@@ -51,22 +55,26 @@ pub async fn list(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<JobRespons
} }
pub async fn delete_job( pub async fn delete_job(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let found = scheduled_jobs::delete(skald.db(), id).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let found = scheduled_jobs::delete(&ctx.pool, id).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) } if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
} }
pub async fn toggle( pub async fn toggle(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
Json(body): Json<serde_json::Value>, Json(body): Json<serde_json::Value>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let enabled = body["enabled"] let enabled = body["enabled"]
.as_bool() .as_bool()
.ok_or_else(|| ApiError::bad_request("'enabled' boolean required"))?; .ok_or_else(|| ApiError::bad_request("'enabled' boolean required"))?;
let found = scheduled_jobs::set_enabled(skald.db(), id, enabled).await?; let found = scheduled_jobs::set_enabled(&ctx.pool, id, enabled).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) } if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
} }
@@ -76,15 +84,17 @@ pub struct SetRunContextBody {
} }
pub async fn set_run_context( pub async fn set_run_context(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
Json(body): Json<SetRunContextBody>, Json(body): Json<SetRunContextBody>,
) -> Result<(), ApiError> { ) -> Result<(), ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
use skald_core::run_context::RunContext; use skald_core::run_context::RunContext;
let json = body.security_group.as_ref().map(|sg| { let json = body.security_group.as_ref().map(|sg| {
RunContext::with_security_group(Some(sg.clone())).to_db() RunContext::with_security_group(Some(sg.clone())).to_db()
}); });
let found = scheduled_jobs::set_run_context(skald.db(), id, json.as_deref()).await?; let found = scheduled_jobs::set_run_context(&ctx.pool, id, json.as_deref()).await?;
if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) } if found { Ok(()) } else { Err(ApiError::not_found(format!("job {id} not found"))) }
} }
@@ -106,19 +116,26 @@ pub struct JobRunResponse {
} }
pub async fn kill_job( pub async fn kill_job(
Path(id): Path<i64>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let job = scheduled_jobs::get_by_id(skald.db(), id).await? let ctx = require_context(&skald, &auth.user_id).await?;
let job = scheduled_jobs::get_by_id(&ctx.pool, id).await?
.ok_or_else(|| ApiError::not_found(format!("job {id} not found")))?; .ok_or_else(|| ApiError::not_found(format!("job {id} not found")))?;
let session_id = job.running_session_id let session_id = job.running_session_id
.ok_or_else(|| ApiError::bad_request("job is not currently running"))?; .ok_or_else(|| ApiError::bad_request("job is not currently running"))?;
skald.system_bus().send(core_api::system_bus::SystemEvent::SessionCancelled { session_id }); // Direct cancel on the user's own session manager — no system bus fan-out.
ctx.sessions.cancel_session(session_id).await;
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
pub async fn list_runs(State(skald): State<Arc<Skald>>) -> Result<Json<Vec<JobRunResponse>>, ApiError> { pub async fn list_runs(
let runs = job_runs::list_all(skald.db(), 200).await?; State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<JobRunResponse>>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let runs = job_runs::list_all(&ctx.pool, 200).await?;
Ok(Json(runs.into_iter().map(|r| JobRunResponse { Ok(Json(runs.into_iter().map(|r| JobRunResponse {
id: r.id, id: r.id,
job_id: r.job_id, job_id: r.job_id,
+71 -66
View File
@@ -1,13 +1,13 @@
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
response::IntoResponse, response::IntoResponse,
Json, Json, Extension,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
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};
const KEY: &str = "DEBUG_MODE"; const KEY: &str = "DEBUG_MODE";
@@ -44,18 +44,14 @@ const PAGE_SIZE: i64 = 20;
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LlmRequestsQuery { pub struct LlmRequestsQuery {
pub agent_id: Option<String>, pub from: Option<String>,
pub source: Option<String>, pub to: Option<String>,
pub from: Option<String>, pub page: Option<i64>,
pub to: Option<String>,
pub page: Option<i64>,
} }
#[derive(Serialize)] #[derive(Serialize)]
pub struct LlmRequestItem { pub struct LlmRequestItem {
pub id: i64, pub id: i64,
pub agent_id: Option<String>,
pub source: Option<String>,
pub model_name: String, pub model_name: String,
pub created_at: String, pub created_at: String,
pub input_tokens: Option<i64>, pub input_tokens: Option<i64>,
@@ -76,37 +72,34 @@ pub struct LlmRequestsResponse {
pub async fn list_llm_requests( pub async fn list_llm_requests(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Query(params): Query<LlmRequestsQuery>, Query(params): Query<LlmRequestsQuery>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let page = params.page.unwrap_or(1).max(1); let page = params.page.unwrap_or(1).max(1);
let offset = (page - 1) * PAGE_SIZE; let offset = (page - 1) * PAGE_SIZE;
// Bind optional filters twice each: once for the IS NULL check, once for the // Metadata-only query on system.db. No JOIN with chat_sessions (that table
// equality check. SQLite evaluates `? IS NULL` against the bound value itself. // lives in the per-user owner bucket). Filters by user_id so each user sees
let items = sqlx::query_as::<_, (i64, Option<String>, Option<String>, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>)>( // only their own requests.
let items = sqlx::query_as::<_, (i64, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>)>(
"SELECT "SELECT
r.id, id,
s.agent_id, model_name,
s.source, created_at,
r.model_name, input_tokens,
r.created_at, output_tokens,
r.input_tokens, cache_read_tokens,
r.output_tokens, cache_creation_tokens,
r.cache_read_tokens, duration_ms,
r.cache_creation_tokens, error_text
r.duration_ms, FROM llm_requests
r.error_text WHERE user_id = ?
FROM llm_requests r AND (? IS NULL OR created_at >= ?)
LEFT JOIN chat_sessions s ON s.id = r.session_id AND (? IS NULL OR created_at <= ?)
WHERE (? IS NULL OR s.agent_id = ?) ORDER BY created_at DESC
AND (? IS NULL OR s.source = ?)
AND (? IS NULL OR r.created_at >= ?)
AND (? IS NULL OR r.created_at <= ?)
ORDER BY r.created_at DESC
LIMIT ? OFFSET ?", LIMIT ? OFFSET ?",
) )
.bind(&params.agent_id).bind(&params.agent_id) .bind(&auth.user_id)
.bind(&params.source).bind(&params.source)
.bind(&params.from).bind(&params.from) .bind(&params.from).bind(&params.from)
.bind(&params.to).bind(&params.to) .bind(&params.to).bind(&params.to)
.bind(PAGE_SIZE) .bind(PAGE_SIZE)
@@ -114,22 +107,19 @@ pub async fn list_llm_requests(
.fetch_all(&**skald.db()) .fetch_all(&**skald.db())
.await? .await?
.into_iter() .into_iter()
.map(|(id, agent_id, source, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text)| { .map(|(id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text)| {
LlmRequestItem { id, agent_id, source, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text } LlmRequestItem { id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text }
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let total = sqlx::query_scalar::<_, i64>( let total = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) "SELECT COUNT(*)
FROM llm_requests r FROM llm_requests
LEFT JOIN chat_sessions s ON s.id = r.session_id WHERE user_id = ?
WHERE (? IS NULL OR s.agent_id = ?) AND (? IS NULL OR created_at >= ?)
AND (? IS NULL OR s.source = ?) AND (? IS NULL OR created_at <= ?)",
AND (? IS NULL OR r.created_at >= ?)
AND (? IS NULL OR r.created_at <= ?)",
) )
.bind(&params.agent_id).bind(&params.agent_id) .bind(&auth.user_id)
.bind(&params.source).bind(&params.source)
.bind(&params.from).bind(&params.from) .bind(&params.from).bind(&params.from)
.bind(&params.to).bind(&params.to) .bind(&params.to).bind(&params.to)
.fetch_one(&**skald.db()) .fetch_one(&**skald.db())
@@ -143,8 +133,7 @@ pub async fn list_llm_requests(
#[derive(Serialize)] #[derive(Serialize)]
pub struct LlmRequestDetail { pub struct LlmRequestDetail {
pub id: i64, pub id: i64,
pub agent_id: Option<String>, pub request_id: Option<String>,
pub source: Option<String>,
pub stack_id: Option<i64>, pub stack_id: Option<i64>,
pub model_name: String, pub model_name: String,
pub created_at: String, pub created_at: String,
@@ -154,6 +143,8 @@ pub struct LlmRequestDetail {
pub cache_creation_tokens: Option<i64>, pub cache_creation_tokens: Option<i64>,
pub duration_ms: i64, pub duration_ms: i64,
pub error_text: Option<String>, pub error_text: Option<String>,
// Payload fields — now live in llm_request_payloads in the user's own database.
// Left as None for now; a future cross-pool lookup via request_id can fill them.
pub request_json: Option<String>, pub request_json: Option<String>,
pub request_headers: Option<String>, pub request_headers: Option<String>,
pub response_json: Option<String>, pub response_json: Option<String>,
@@ -162,40 +153,54 @@ pub struct LlmRequestDetail {
pub async fn get_llm_request( pub async fn get_llm_request(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(id): Path<i64>, Path(id): Path<i64>,
) -> Result<impl IntoResponse, ApiError> { ) -> Result<impl IntoResponse, ApiError> {
let row = sqlx::query_as::<_, (i64, Option<String>, Option<String>, Option<i64>, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)>( let row = sqlx::query_as::<_, (i64, Option<String>, Option<i64>, String, String, Option<i64>, Option<i64>, Option<i64>, Option<i64>, i64, Option<String>)>(
"SELECT "SELECT
r.id, id,
s.agent_id, request_id,
s.source, stack_id,
r.stack_id, model_name,
r.model_name, created_at,
r.created_at, input_tokens,
r.input_tokens, output_tokens,
r.output_tokens, cache_read_tokens,
r.cache_read_tokens, cache_creation_tokens,
r.cache_creation_tokens, duration_ms,
r.duration_ms, error_text
r.error_text, FROM llm_requests
NULLIF(r.request_json, '') AS request_json, WHERE id = ? AND user_id = ?",
r.request_headers,
r.response_json,
r.response_headers
FROM llm_requests r
LEFT JOIN chat_sessions s ON s.id = r.session_id
WHERE r.id = ?",
) )
.bind(id) .bind(id)
.bind(&auth.user_id)
.fetch_optional(&**skald.db()) .fetch_optional(&**skald.db())
.await?; .await?;
let Some((id, agent_id, source, stack_id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text, request_json, request_headers, response_json, response_headers)) = row else { let Some((id, request_id, stack_id, model_name, created_at, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, duration_ms, error_text)) = row else {
return Err(ApiError::not_found(format!("llm_request {id} not found"))); return Err(ApiError::not_found(format!("llm_request {id} not found")));
}; };
// Try to fetch the payload from the user's own database via request_id.
let payload = if let Some(ref rid) = request_id {
let ctx = require_context(&skald, &auth.user_id).await.ok();
if let Some(ctx) = ctx {
sqlx::query_as::<_, (Option<String>, Option<String>, Option<String>, Option<String>)>(
"SELECT request_json, request_headers, response_json, response_headers
FROM llm_request_payloads WHERE request_id = ?",
)
.bind(rid)
.fetch_optional(&*ctx.pool)
.await
.ok()
.flatten()
} else { None }
} else { None };
let (request_json, request_headers, response_json, response_headers) = payload.unwrap_or((None, None, None, None));
Ok(Json(LlmRequestDetail { Ok(Json(LlmRequestDetail {
id, agent_id, source, stack_id, model_name, created_at, id, request_id, stack_id, model_name, created_at,
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens,
duration_ms, error_text, duration_ms, error_text,
request_json, request_headers, response_json, response_headers, request_json, request_headers, response_json, response_headers,
-9
View File
@@ -139,8 +139,6 @@ pub fn router() -> Router<Arc<Skald>> {
// Config properties // Config properties
.route("/config", get(config::list_properties)) .route("/config", get(config::list_properties))
.route("/config/{key}", put(config::set_property)) .route("/config/{key}", put(config::set_property))
// TIC
.route("/tic/trigger", post(tic_trigger))
// Plugins // Plugins
.route("/plugins", get(plugins::list)) .route("/plugins", get(plugins::list))
.route("/plugins/{id}", put(plugins::update)) .route("/plugins/{id}", put(plugins::update))
@@ -164,13 +162,6 @@ pub fn router() -> Router<Arc<Skald>> {
.route("/file", delete(files::delete_file)) .route("/file", delete(files::delete_file))
} }
async fn tic_trigger(State(skald): State<Arc<Skald>>) -> impl IntoResponse {
tokio::spawn(async move {
Arc::clone(skald.tic_manager()).tick_now().await;
});
StatusCode::ACCEPTED
}
pub struct ApiError { pub struct ApiError {
status: StatusCode, status: StatusCode,
message: String, message: String,
+88 -37
View File
@@ -6,9 +6,11 @@ use axum::{
http::StatusCode, http::StatusCode,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use skald_core::db::project_tickets::ProjectTicket; use skald_core::db::project_tickets::ProjectTicket;
use skald_core::db::projects::Project; use skald_core::db::projects::Project;
use skald_core::db::{project_tickets, projects};
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, guard::AuthUser, require_context}; use super::{ApiError, guard::AuthUser, require_context};
@@ -130,62 +132,70 @@ impl<'de> Deserialize<'de> for TicketPath {
pub async fn list( pub async fn list(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
) -> Result<Json<Vec<ProjectResponse>>, ApiError> { ) -> Result<Json<Vec<ProjectResponse>>, ApiError> {
let projects = skald.projects().list().await?; let ctx = require_context(&skald, &auth.user_id).await?;
Ok(Json(projects.into_iter().map(Into::into).collect())) let items = projects::list(&ctx.pool).await?;
Ok(Json(items.into_iter().map(Into::into).collect()))
} }
pub async fn create( pub async fn create(
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Json(body): Json<ProjectBody>, Json(body): Json<ProjectBody>,
) -> Result<(StatusCode, Json<ProjectResponse>), ApiError> { ) -> Result<(StatusCode, Json<ProjectResponse>), ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let rc_json = body.rc_json(); let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db); let project = projects::create(
let project = skald.projects().create( &ctx.pool,
&body.name, &body.name,
&body.path, &body.path,
body.description.as_deref().unwrap_or(""), body.description.as_deref().unwrap_or(""),
rc.as_ref(), rc_json.as_deref(),
).await?; ).await?;
Ok((StatusCode::CREATED, Json(project.into()))) Ok((StatusCode::CREATED, Json(project.into())))
} }
pub async fn get_project( pub async fn get_project(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
) -> Result<Json<ProjectResponse>, ApiError> { ) -> Result<Json<ProjectResponse>, ApiError> {
let project = skald.projects().get(p.id).await? let ctx = require_context(&skald, &auth.user_id).await?;
let project = projects::get(&ctx.pool, p.id).await?
.ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?; .ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?;
Ok(Json(project.into())) Ok(Json(project.into()))
} }
pub async fn update( pub async fn update(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
Json(body): Json<ProjectBody>, Json(body): Json<ProjectBody>,
) -> Result<Json<ProjectResponse>, ApiError> { ) -> Result<Json<ProjectResponse>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let rc_json = body.rc_json(); let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db); let found = projects::update(
let found = skald.projects().update( &ctx.pool, p.id,
p.id, &body.name, &body.path,
&body.name,
&body.path,
body.description.as_deref().unwrap_or(""), body.description.as_deref().unwrap_or(""),
rc.as_ref(), rc_json.as_deref(),
).await?; ).await?;
if !found { if !found {
return Err(ApiError::not_found(format!("project {} not found", p.id))); return Err(ApiError::not_found(format!("project {} not found", p.id)));
} }
let project = skald.projects().get(p.id).await? let project = projects::get(&ctx.pool, p.id).await?
.ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?; .ok_or_else(|| ApiError::not_found(format!("project {} not found", p.id)))?;
Ok(Json(project.into())) Ok(Json(project.into()))
} }
pub async fn delete( pub async fn delete(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let found = skald.projects().delete(p.id).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let found = projects::delete(&ctx.pool, p.id).await?;
if found { Ok(StatusCode::NO_CONTENT) } if found { Ok(StatusCode::NO_CONTENT) }
else { Err(ApiError::not_found(format!("project {} not found", p.id))) } else { Err(ApiError::not_found(format!("project {} not found", p.id))) }
} }
@@ -193,56 +203,97 @@ pub async fn delete(
// ── Ticket handlers ─────────────────────────────────────────────────────────── // ── Ticket handlers ───────────────────────────────────────────────────────────
pub async fn list_tickets( pub async fn list_tickets(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
) -> Result<Json<Vec<TicketResponse>>, ApiError> { ) -> Result<Json<Vec<TicketResponse>>, ApiError> {
let tickets = skald.ticket_manager().list(p.id).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let tickets = project_tickets::list_for_project(&ctx.pool, p.id).await?;
Ok(Json(tickets.into_iter().map(Into::into).collect())) Ok(Json(tickets.into_iter().map(Into::into).collect()))
} }
pub async fn create_ticket( pub async fn create_ticket(
Path(p): Path<ProjectPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(p): Path<ProjectPath>,
Json(body): Json<TicketBody>, Json(body): Json<TicketBody>,
) -> Result<(StatusCode, Json<TicketResponse>), ApiError> { ) -> Result<(StatusCode, Json<TicketResponse>), ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?;
let rc_json = body.rc_json(); let rc_json = body.rc_json();
let rc = rc_json.as_deref().and_then(RunContext::from_db);
// Tickets run a task sub-agent — no default. The agent's `type == task` is enforced
// when the ticket starts, via TaskManager::spawn_async_job (require_task_agent).
let agent_id = body.agent_id.as_deref().map(str::trim).filter(|s| !s.is_empty()) let agent_id = body.agent_id.as_deref().map(str::trim).filter(|s| !s.is_empty())
.ok_or_else(|| ApiError::bad_request("agent_id is required — pick a task agent for this ticket"))?; .ok_or_else(|| ApiError::bad_request("agent_id is required — pick a task agent for this ticket"))?;
let ticket = skald.ticket_manager().create( let ticket = project_tickets::create(
p.id, &ctx.pool, p.id,
&body.title, &body.title,
body.description.as_deref().unwrap_or(""), body.description.as_deref().unwrap_or(""),
agent_id, agent_id,
rc.as_ref(), rc_json.as_deref(),
).await?; ).await?;
projects::touch(&ctx.pool, p.id).await?;
Ok((StatusCode::CREATED, Json(ticket.into()))) Ok((StatusCode::CREATED, Json(ticket.into())))
} }
pub async fn delete_ticket( pub async fn delete_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(tp): Path<TicketPath>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
let found = skald.ticket_manager().delete(tp.tid).await?; let ctx = require_context(&skald, &auth.user_id).await?;
if found { Ok(StatusCode::NO_CONTENT) } let ticket = project_tickets::get(&ctx.pool, tp.tid).await?;
else { Err(ApiError::not_found(format!("ticket {} not found", tp.tid))) } let found = project_tickets::delete(&ctx.pool, tp.tid).await?;
if found {
if let Some(t) = ticket {
projects::touch(&ctx.pool, t.project_id).await?;
}
Ok(StatusCode::NO_CONTENT)
} else {
Err(ApiError::not_found(format!("ticket {} not found", tp.tid)))
}
} }
pub async fn start_ticket( pub async fn start_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(tp): Path<TicketPath>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
skald.ticket_manager().start(tp.tid).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let ticket = project_tickets::get(&ctx.pool, tp.tid).await?
.ok_or_else(|| ApiError::not_found(format!("ticket {} not found", tp.tid)))?;
let project = projects::get(&ctx.pool, ticket.project_id).await?
.ok_or_else(|| ApiError::not_found(format!("project {} not found", ticket.project_id)))?;
let base: Option<RunContext> =
ticket.run_context.as_deref().and_then(RunContext::from_db)
.or_else(|| project.run_context.as_deref().and_then(RunContext::from_db));
let rc = skald_core::projects::build_runtime_run_context(&project, base);
let origin_ref = format!("PROJECT_TASK:{}", tp.tid);
let rc_json = rc.to_db();
let job = ctx.cron.spawn_async_job(
&ticket.title,
&ticket.description,
&ticket.description,
&ticket.agent_id,
Some(&rc_json),
&origin_ref,
)?;
project_tickets::start(&ctx.pool, tp.tid, job.id).await?;
projects::touch(&ctx.pool, ticket.project_id).await?;
Ok(StatusCode::ACCEPTED) Ok(StatusCode::ACCEPTED)
} }
pub async fn reset_ticket( pub async fn reset_ticket(
Path(tp): Path<TicketPath>,
State(skald): State<Arc<Skald>>, State(skald): State<Arc<Skald>>,
Extension(auth): Extension<AuthUser>,
Path(tp): Path<TicketPath>,
) -> Result<StatusCode, ApiError> { ) -> Result<StatusCode, ApiError> {
skald.ticket_manager().reset(tp.tid).await?; let ctx = require_context(&skald, &auth.user_id).await?;
let project_id = project_tickets::get(&ctx.pool, tp.tid).await?.map(|t| t.project_id);
project_tickets::reset(&ctx.pool, tp.tid).await?;
if let Some(pid) = project_id {
projects::touch(&ctx.pool, pid).await?;
}
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -261,7 +312,7 @@ pub struct SessionResponse {
/// provisioning config, shared by session-open and session-reset so the two never /// provisioning config, shared by session-open and session-reset so the two never
/// diverge. /// diverge.
pub async fn provisioning_for_source( pub async fn provisioning_for_source(
skald: &Skald, pool: &SqlitePool,
source: &str, source: &str,
) -> Result<(String, Option<RunContext>), ApiError> { ) -> Result<(String, Option<RunContext>), ApiError> {
let Some(id) = source let Some(id) = source
@@ -271,7 +322,7 @@ pub async fn provisioning_for_source(
return Ok(("main".to_string(), None)); return Ok(("main".to_string(), None));
}; };
let project = skald.projects().get(id).await? let project = projects::get(pool, id).await?
.ok_or_else(|| ApiError::not_found(format!("project {id} not found")))?; .ok_or_else(|| ApiError::not_found(format!("project {id} not found")))?;
let base = project.run_context.as_deref().and_then(RunContext::from_db); let base = project.run_context.as_deref().and_then(RunContext::from_db);
let rc = skald_core::projects::build_runtime_run_context(&project, base); let rc = skald_core::projects::build_runtime_run_context(&project, base);
@@ -288,7 +339,7 @@ pub async fn open_session(
) -> Result<Json<SessionResponse>, ApiError> { ) -> Result<Json<SessionResponse>, ApiError> {
let ctx = require_context(&skald, &auth.user_id).await?; 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(&ctx.pool, &source).await?;
let session_id = ctx.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?;
+1 -1
View File
@@ -38,7 +38,7 @@ pub async fn create(
let ctx = require_context(&skald, &auth.user_id).await?; 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(&ctx.pool, &q.source).await?;
ctx.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!({})))
} }