P1 backend: chat persistence, tc-llm providers, runtime loop, gateway SSE
- tc-db: sessions/messages/steps/runs/run_events repos (atomic seq assignment, history with ordered step traces, journal replay-from-offset); migration 0003 - tc-llm: provider-neutral ChatRequest/LlmEvent; ScriptedProvider (scenario TOML, word-level deltas, multi-turn tool legs — ships in production for e2e/air-gap smoke), AnthropicProvider (Messages SSE), OpenAiCompatProvider (vLLM/Ollama/llama.cpp); opt-in live tests via TC_LIVE_LLM=1 - tc-runtime: run loop with persist-before-emit event journal, real built-in clock.now tool, step rows on the reply message, tool-error resilience, broadcast channels for live attach - tc-api: agent CRUD + settings/full (tenant-isolated, RBAC'd, audited), sessions create/list/history?tools=true, POST /api/gateway SSE with monotonic ids and exact resumeFrom journal replay (tested equal to live) - teamclaw-server: config-driven provider factory 83 Rust tests green, all against real Postgres / real TCP. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
fc173f170d
commit
32008c9ef0
@@ -53,6 +53,67 @@ pub async fn insert(pool: &PgPool, agent: &Agent, policy: &AccessPolicy) -> Resu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, workspace_id, name, job_title, system_prompt, avatar,
|
||||
accent, wallpaper, managed_by, status
|
||||
FROM agents WHERE id = $1 AND deleted_at IS NULL",
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(Agent {
|
||||
id: AgentId::from(row.id),
|
||||
workspace_id: WorkspaceId::from(row.workspace_id),
|
||||
name: row.name,
|
||||
job_title: row.job_title,
|
||||
system_prompt: row.system_prompt,
|
||||
avatar: row.avatar,
|
||||
accent: row.accent,
|
||||
wallpaper: row.wallpaper,
|
||||
managed_by: UserId::from(row.managed_by),
|
||||
status: row.status.parse().expect("status CHECK constraint"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Patch-style profile update (§7.7 Edit profile): only provided fields
|
||||
/// change; the system prompt is the Job Description textarea verbatim.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn update_profile(
|
||||
pool: &PgPool,
|
||||
agent_id: AgentId,
|
||||
name: Option<&str>,
|
||||
job_title: Option<&str>,
|
||||
system_prompt: Option<&str>,
|
||||
avatar: Option<&str>,
|
||||
accent: Option<&str>,
|
||||
wallpaper: Option<&str>,
|
||||
) -> Result<Agent, DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE agents SET
|
||||
name = COALESCE($2, name),
|
||||
job_title = COALESCE($3, job_title),
|
||||
system_prompt = COALESCE($4, system_prompt),
|
||||
avatar = COALESCE($5, avatar),
|
||||
accent = COALESCE($6, accent),
|
||||
wallpaper = COALESCE($7, wallpaper)
|
||||
WHERE id = $1 AND deleted_at IS NULL",
|
||||
agent_id.as_uuid(),
|
||||
name,
|
||||
job_title,
|
||||
system_prompt,
|
||||
avatar,
|
||||
accent,
|
||||
wallpaper,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
get(pool, agent_id).await
|
||||
}
|
||||
|
||||
/// The left-rail roster (§4): live agents of a workspace, oldest first.
|
||||
pub async fn roster(pool: &PgPool, workspace_id: WorkspaceId) -> Result<Vec<Agent>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
use sqlx::PgPool;
|
||||
use tc_domain::{Message, MessageId, MessageRole, MessageWithSteps, SessionId, Step, StepStatus};
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// Appends a message, assigning the next sequence number atomically.
|
||||
/// Sessions have a single writer (the run loop) per side, so the rare
|
||||
/// concurrent collision surfaces as `Conflict` for the caller to retry.
|
||||
pub async fn append(
|
||||
pool: &PgPool,
|
||||
session_id: SessionId,
|
||||
role: MessageRole,
|
||||
content: serde_json::Value,
|
||||
) -> Result<Message, DbError> {
|
||||
let id = MessageId::new();
|
||||
let row = sqlx::query!(
|
||||
"INSERT INTO messages (id, session_id, seq, role, content)
|
||||
SELECT $1, $2, COALESCE(MAX(seq), 0) + 1, $3, $4
|
||||
FROM messages WHERE session_id = $2
|
||||
RETURNING seq, created_at",
|
||||
id.as_uuid(),
|
||||
session_id.as_uuid(),
|
||||
role.as_str(),
|
||||
content,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(Message {
|
||||
id,
|
||||
session_id,
|
||||
seq: row.seq,
|
||||
role,
|
||||
content,
|
||||
created_at: row.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replaces a message's content. The run loop creates the agent reply row
|
||||
/// before streaming (so steps can attach) and finalizes its text here.
|
||||
pub async fn set_content(
|
||||
pool: &PgPool,
|
||||
message_id: MessageId,
|
||||
content: serde_json::Value,
|
||||
) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE messages SET content = $2 WHERE id = $1",
|
||||
message_id.as_uuid(),
|
||||
content,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Full transcript with step traces, oldest first (`?tools=true` shape).
|
||||
pub async fn history(
|
||||
pool: &PgPool,
|
||||
session_id: SessionId,
|
||||
) -> Result<Vec<MessageWithSteps>, DbError> {
|
||||
let message_rows = sqlx::query!(
|
||||
"SELECT id, session_id, seq, role, content, created_at
|
||||
FROM messages WHERE session_id = $1 ORDER BY seq",
|
||||
session_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let step_rows = sqlx::query!(
|
||||
"SELECT s.id, s.message_id, s.seq, s.kind, s.tool_name, s.input,
|
||||
s.output, s.taint, s.status
|
||||
FROM steps s
|
||||
JOIN messages m ON m.id = s.message_id
|
||||
WHERE m.session_id = $1
|
||||
ORDER BY m.seq, s.seq",
|
||||
session_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut result: Vec<MessageWithSteps> = message_rows
|
||||
.into_iter()
|
||||
.map(|row| MessageWithSteps {
|
||||
message: Message {
|
||||
id: MessageId::from(row.id),
|
||||
session_id: SessionId::from(row.session_id),
|
||||
seq: row.seq,
|
||||
role: row.role.parse().expect("role CHECK constraint"),
|
||||
content: row.content,
|
||||
created_at: row.created_at,
|
||||
},
|
||||
steps: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
for row in step_rows {
|
||||
let message_id = MessageId::from(row.message_id);
|
||||
let step = Step {
|
||||
id: row.id,
|
||||
message_id,
|
||||
seq: row.seq,
|
||||
kind: row.kind,
|
||||
tool_name: row.tool_name,
|
||||
input: row.input,
|
||||
output: row.output,
|
||||
taint: row.taint,
|
||||
status: row.status.parse::<StepStatus>().expect("status values"),
|
||||
};
|
||||
if let Some(entry) = result.iter_mut().find(|m| m.message.id == message_id) {
|
||||
entry.steps.push(step);
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
pub mod agents;
|
||||
pub mod audit;
|
||||
pub mod credits;
|
||||
pub mod messages;
|
||||
pub mod run_events;
|
||||
pub mod runs;
|
||||
pub mod sessions;
|
||||
pub mod steps;
|
||||
pub mod users;
|
||||
pub mod workspaces;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
/// One persisted gateway event (§13): the journal the SSE stream and
|
||||
/// reconnect replay both read from.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RunEvent {
|
||||
pub run_id: Uuid,
|
||||
pub seq: i64,
|
||||
pub event_type: String,
|
||||
pub payload: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Persists an event. Called BEFORE the event is emitted to any client so
|
||||
/// the journal is always at least as complete as what observers saw.
|
||||
pub async fn append(
|
||||
pool: &PgPool,
|
||||
run_id: Uuid,
|
||||
seq: i64,
|
||||
event_type: &str,
|
||||
payload: serde_json::Value,
|
||||
) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO run_events (run_id, seq, event_type, payload)
|
||||
VALUES ($1, $2, $3, $4)",
|
||||
run_id,
|
||||
seq,
|
||||
event_type,
|
||||
payload,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Events after a client's `resumeFrom` offset, in order.
|
||||
pub async fn list_after(
|
||||
pool: &PgPool,
|
||||
run_id: Uuid,
|
||||
after_seq: i64,
|
||||
) -> Result<Vec<RunEvent>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT run_id, seq, event_type, payload
|
||||
FROM run_events WHERE run_id = $1 AND seq > $2 ORDER BY seq",
|
||||
run_id,
|
||||
after_seq,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| RunEvent {
|
||||
run_id: row.run_id,
|
||||
seq: row.seq,
|
||||
event_type: row.event_type,
|
||||
payload: row.payload,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use sqlx::PgPool;
|
||||
use tc_domain::{AgentRun, RunState, SessionId};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
pub async fn create(pool: &PgPool, session_id: SessionId) -> Result<Uuid, DbError> {
|
||||
let id = Uuid::now_v7();
|
||||
sqlx::query!(
|
||||
"INSERT INTO agent_runs (id, session_id, state) VALUES ($1, $2, 'running')",
|
||||
id,
|
||||
session_id.as_uuid(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: Uuid) -> Result<AgentRun, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, session_id, state, last_event_id, error FROM agent_runs WHERE id = $1",
|
||||
id,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(AgentRun {
|
||||
id: row.id,
|
||||
session_id: SessionId::from(row.session_id),
|
||||
state: row.state.parse().expect("state CHECK constraint"),
|
||||
last_event_id: row.last_event_id,
|
||||
error: row.error,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_state(
|
||||
pool: &PgPool,
|
||||
id: Uuid,
|
||||
state: RunState,
|
||||
error: Option<&str>,
|
||||
) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE agent_runs
|
||||
SET state = $2, error = COALESCE($3, error), updated_at = now()
|
||||
WHERE id = $1",
|
||||
id,
|
||||
state.as_str(),
|
||||
error,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records the highest event seq persisted for this run (used by resume).
|
||||
pub async fn set_last_event(pool: &PgPool, id: Uuid, last_event_id: i64) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"UPDATE agent_runs SET last_event_id = $2, updated_at = now() WHERE id = $1",
|
||||
id,
|
||||
last_event_id,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The most recent run for a session, if any (gateway re-attach).
|
||||
pub async fn latest_for_session(
|
||||
pool: &PgPool,
|
||||
session_id: SessionId,
|
||||
) -> Result<Option<AgentRun>, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, session_id, state, last_event_id, error
|
||||
FROM agent_runs WHERE session_id = $1
|
||||
ORDER BY created_at DESC, id DESC LIMIT 1",
|
||||
session_id.as_uuid(),
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| AgentRun {
|
||||
id: row.id,
|
||||
session_id: SessionId::from(row.session_id),
|
||||
state: row.state.parse().expect("state CHECK constraint"),
|
||||
last_event_id: row.last_event_id,
|
||||
error: row.error,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use sqlx::PgPool;
|
||||
use tc_domain::{shard_of, AgentId, Session, SessionId, WorkspaceId};
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
fn row_to_session(
|
||||
id: uuid::Uuid,
|
||||
agent_id: uuid::Uuid,
|
||||
workspace_id: uuid::Uuid,
|
||||
title: String,
|
||||
shard: i16,
|
||||
created_at: time::OffsetDateTime,
|
||||
last_active_at: time::OffsetDateTime,
|
||||
) -> Session {
|
||||
Session {
|
||||
id: SessionId::from(id),
|
||||
agent_id: AgentId::from(agent_id),
|
||||
workspace_id: WorkspaceId::from(workspace_id),
|
||||
title,
|
||||
shard: shard as u16,
|
||||
created_at,
|
||||
last_active_at,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
pool: &PgPool,
|
||||
agent_id: AgentId,
|
||||
workspace_id: WorkspaceId,
|
||||
title: &str,
|
||||
) -> Result<Session, DbError> {
|
||||
let id = SessionId::new();
|
||||
let shard = shard_of(agent_id);
|
||||
let row = sqlx::query!(
|
||||
"INSERT INTO sessions (id, agent_id, workspace_id, title, shard)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING created_at, last_active_at",
|
||||
id.as_uuid(),
|
||||
agent_id.as_uuid(),
|
||||
workspace_id.as_uuid(),
|
||||
title,
|
||||
shard as i16,
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(Session {
|
||||
id,
|
||||
agent_id,
|
||||
workspace_id,
|
||||
title: title.to_owned(),
|
||||
shard,
|
||||
created_at: row.created_at,
|
||||
last_active_at: row.last_active_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get(pool: &PgPool, id: SessionId) -> Result<Session, DbError> {
|
||||
let row = sqlx::query!(
|
||||
"SELECT id, agent_id, workspace_id, title, shard, created_at, last_active_at
|
||||
FROM sessions WHERE id = $1",
|
||||
id.as_uuid(),
|
||||
)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row_to_session(
|
||||
row.id,
|
||||
row.agent_id,
|
||||
row.workspace_id,
|
||||
row.title,
|
||||
row.shard,
|
||||
row.created_at,
|
||||
row.last_active_at,
|
||||
))
|
||||
}
|
||||
|
||||
/// Sessions column ordering (§6): most recently active first.
|
||||
pub async fn list_by_agent(pool: &PgPool, agent_id: AgentId) -> Result<Vec<Session>, DbError> {
|
||||
let rows = sqlx::query!(
|
||||
"SELECT id, agent_id, workspace_id, title, shard, created_at, last_active_at
|
||||
FROM sessions WHERE agent_id = $1
|
||||
ORDER BY last_active_at DESC, id DESC",
|
||||
agent_id.as_uuid(),
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
row_to_session(
|
||||
row.id,
|
||||
row.agent_id,
|
||||
row.workspace_id,
|
||||
row.title,
|
||||
row.shard,
|
||||
row.created_at,
|
||||
row.last_active_at,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Marks a session as just-used so it sorts to the top of the column.
|
||||
pub async fn touch(pool: &PgPool, id: SessionId) -> Result<(), DbError> {
|
||||
let result = sqlx::query!(
|
||||
"UPDATE sessions SET last_active_at = clock_timestamp() WHERE id = $1",
|
||||
id.as_uuid(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use sqlx::PgPool;
|
||||
use tc_domain::Step;
|
||||
|
||||
use crate::DbError;
|
||||
|
||||
pub async fn append(pool: &PgPool, step: &Step) -> Result<(), DbError> {
|
||||
sqlx::query!(
|
||||
"INSERT INTO steps
|
||||
(id, message_id, seq, kind, tool_name, input, output, taint, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
step.id,
|
||||
step.message_id.as_uuid(),
|
||||
step.seq,
|
||||
step.kind,
|
||||
step.tool_name.as_deref(),
|
||||
step.input.clone(),
|
||||
step.output.clone(),
|
||||
&step.taint,
|
||||
step.status.as_str(),
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user