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:
Omar Sobh
2026-06-09 23:16:06 -05:00
co-authored by Claude Fable 5
parent fc173f170d
commit 32008c9ef0
58 changed files with 4378 additions and 11 deletions
+61
View File
@@ -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!(
+116
View File
@@ -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)
}
+5
View File
@@ -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;
+61
View File
@@ -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())
}
+89
View File
@@ -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,
}))
}
+114
View File
@@ -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(())
}
+24
View File
@@ -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(())
}