llm request tracking, user context cleanup, minor fixes
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,8 +516,8 @@ 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
+68
-63
@@ -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,8 +44,6 @@ const PAGE_SIZE: i64 = 20;
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct LlmRequestsQuery {
|
pub struct LlmRequestsQuery {
|
||||||
pub agent_id: Option<String>,
|
|
||||||
pub source: Option<String>,
|
|
||||||
pub from: Option<String>,
|
pub from: Option<String>,
|
||||||
pub to: Option<String>,
|
pub to: Option<String>,
|
||||||
pub page: Option<i64>,
|
pub page: Option<i64>,
|
||||||
@@ -54,8 +52,6 @@ pub struct LlmRequestsQuery {
|
|||||||
#[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(¶ms.agent_id).bind(¶ms.agent_id)
|
.bind(&auth.user_id)
|
||||||
.bind(¶ms.source).bind(¶ms.source)
|
|
||||||
.bind(¶ms.from).bind(¶ms.from)
|
.bind(¶ms.from).bind(¶ms.from)
|
||||||
.bind(¶ms.to).bind(¶ms.to)
|
.bind(¶ms.to).bind(¶ms.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(¶ms.agent_id).bind(¶ms.agent_id)
|
.bind(&auth.user_id)
|
||||||
.bind(¶ms.source).bind(¶ms.source)
|
|
||||||
.bind(¶ms.from).bind(¶ms.from)
|
.bind(¶ms.from).bind(¶ms.from)
|
||||||
.bind(¶ms.to).bind(¶ms.to)
|
.bind(¶ms.to).bind(¶ms.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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|||||||
@@ -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!({})))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user