2026-09-09 16:59:32 +00:00
|
|
|
use axum::Json;
|
|
|
|
|
use axum::extract::FromRequestParts;
|
|
|
|
|
use axum::http::StatusCode;
|
|
|
|
|
use axum::http::request::Parts;
|
|
|
|
|
use axum::response::{IntoResponse, Response};
|
2026-09-03 12:46:14 +00:00
|
|
|
use chrono::{DateTime, Utc};
|
|
|
|
|
use lazyboy_contracts::{
|
2026-09-04 09:08:56 +00:00
|
|
|
Bot, BrowserProfileMode, ComputerMode, ComputerState, ControlHolder, RunStatus, SandboxKind,
|
|
|
|
|
computer_home_key, computer_scope_key,
|
2026-09-03 12:46:14 +00:00
|
|
|
};
|
2026-09-09 16:59:32 +00:00
|
|
|
use serde_json::json;
|
2026-09-03 12:46:14 +00:00
|
|
|
use sqlx::{FromRow, PgPool};
|
|
|
|
|
use uuid::Uuid;
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct Db {
|
|
|
|
|
pub pool: PgPool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 16:59:32 +00:00
|
|
|
/// Who a request is acting as: one person and the workspace they own. Every
|
|
|
|
|
/// query for an agent, chat, run, or desktop is filtered by both, so two
|
|
|
|
|
/// people registered on the same server can never see — or collide with —
|
|
|
|
|
/// each other's work.
|
2026-09-03 12:46:14 +00:00
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct Actor {
|
|
|
|
|
pub user_id: String,
|
|
|
|
|
pub space_id: String,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 16:59:32 +00:00
|
|
|
/// Handlers ask for `actor: Actor` and axum fills it from the session the auth
|
|
|
|
|
/// middleware resolved, so no handler can accidentally run as somebody else.
|
|
|
|
|
impl<S: Sync> FromRequestParts<S> for Actor {
|
|
|
|
|
type Rejection = Response;
|
|
|
|
|
|
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
|
|
|
parts.extensions.get::<Actor>().cloned().ok_or_else(|| {
|
|
|
|
|
(
|
|
|
|
|
StatusCode::UNAUTHORIZED,
|
|
|
|
|
Json(json!({"message": "請先登入", "code": "unauthenticated"})),
|
|
|
|
|
)
|
|
|
|
|
.into_response()
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A registered person. `password_hash` is `None` for a legacy account that
|
|
|
|
|
/// predates logins and still has to be claimed.
|
|
|
|
|
#[derive(Debug, Clone)]
|
|
|
|
|
pub struct Account {
|
|
|
|
|
pub user_id: String,
|
|
|
|
|
pub username: String,
|
|
|
|
|
pub password_hash: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-03 12:46:14 +00:00
|
|
|
#[derive(Debug, Clone, FromRow)]
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub struct ComputerRow {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub space_id: String,
|
|
|
|
|
pub user_id: String,
|
|
|
|
|
pub scope: String,
|
|
|
|
|
pub scope_key: String,
|
|
|
|
|
pub home_key: String,
|
|
|
|
|
pub home_revision: String,
|
|
|
|
|
pub kind: String,
|
|
|
|
|
pub provider_ref: Option<String>,
|
|
|
|
|
pub state: String,
|
|
|
|
|
pub control_holder: String,
|
|
|
|
|
pub control_lease_id: Option<String>,
|
|
|
|
|
pub control_lease_expires_at: Option<DateTime<Utc>>,
|
|
|
|
|
pub control_bot_id: Option<String>,
|
|
|
|
|
pub control_run_id: Option<String>,
|
|
|
|
|
pub execution_run_id: Option<String>,
|
|
|
|
|
pub execution_bot_id: Option<String>,
|
|
|
|
|
pub execution_lease_expires_at: Option<DateTime<Utc>>,
|
|
|
|
|
pub execution_fence: i32,
|
|
|
|
|
pub browser_profile_mode: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, FromRow)]
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub struct ScreenRow {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub computer_id: String,
|
|
|
|
|
pub bot_id: String,
|
|
|
|
|
pub slot: i32,
|
|
|
|
|
pub display: String,
|
|
|
|
|
pub view_port: i32,
|
|
|
|
|
pub profile_mode: String,
|
|
|
|
|
pub profile_path: String,
|
|
|
|
|
pub control_holder: String,
|
|
|
|
|
pub control_lease_id: Option<String>,
|
|
|
|
|
pub control_lease_expires_at: Option<DateTime<Utc>>,
|
|
|
|
|
pub execution_run_id: Option<String>,
|
|
|
|
|
pub execution_lease_expires_at: Option<DateTime<Utc>>,
|
|
|
|
|
pub execution_fence: i32,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, FromRow)]
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub struct BotRow {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub space_id: String,
|
|
|
|
|
pub user_id: String,
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub title: String,
|
|
|
|
|
pub description: String,
|
2026-09-03 16:16:34 +00:00
|
|
|
pub avatar_color: String,
|
|
|
|
|
pub avatar_shape: String,
|
|
|
|
|
pub tags: Vec<String>,
|
|
|
|
|
pub pinned: bool,
|
|
|
|
|
pub hidden: bool,
|
|
|
|
|
pub group_name: Option<String>,
|
|
|
|
|
pub unread_count: i64,
|
|
|
|
|
pub last_message_at: Option<DateTime<Utc>>,
|
2026-09-03 12:46:14 +00:00
|
|
|
pub instructions: String,
|
|
|
|
|
pub computer_id: Option<String>,
|
|
|
|
|
pub model_provider: Option<String>,
|
|
|
|
|
pub model_id: Option<String>,
|
2026-09-03 23:43:37 +00:00
|
|
|
pub memory_enabled: bool,
|
2026-09-03 12:46:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 09:08:56 +00:00
|
|
|
#[derive(Debug, Clone, FromRow)]
|
|
|
|
|
#[allow(dead_code)]
|
|
|
|
|
pub struct SpaceRow {
|
|
|
|
|
pub id: String,
|
|
|
|
|
pub user_id: String,
|
|
|
|
|
pub name: String,
|
|
|
|
|
pub default_model_provider: String,
|
|
|
|
|
pub default_model_id: String,
|
|
|
|
|
pub default_model_base_url: Option<String>,
|
|
|
|
|
pub default_model_api_key: Option<String>,
|
2026-09-06 03:43:38 +00:00
|
|
|
pub voice_enabled: bool,
|
|
|
|
|
pub voice_provider: Option<String>,
|
|
|
|
|
pub voice_model_id: Option<String>,
|
|
|
|
|
pub voice_id: Option<String>,
|
|
|
|
|
pub voice_api_key: Option<String>,
|
2026-09-04 09:08:56 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-03 12:46:14 +00:00
|
|
|
impl Db {
|
2026-09-09 16:59:32 +00:00
|
|
|
/// Register a person.
|
|
|
|
|
///
|
|
|
|
|
/// The first account on an upgraded install claims the workspace that
|
|
|
|
|
/// already exists (it belonged to the shared-token install), so nobody
|
|
|
|
|
/// loses their agents, chats, or desktops; from the second account on, each
|
|
|
|
|
/// person starts in a workspace of their own.
|
|
|
|
|
pub async fn create_account(
|
|
|
|
|
&self,
|
|
|
|
|
username: &str,
|
|
|
|
|
password_hash: &str,
|
|
|
|
|
) -> Result<String, sqlx::Error> {
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
let claimable: Option<(String,)> = sqlx::query_as(
|
|
|
|
|
"SELECT id FROM users
|
|
|
|
|
WHERE password_hash IS NULL
|
|
|
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL)
|
|
|
|
|
ORDER BY created_at, id
|
|
|
|
|
LIMIT 1
|
|
|
|
|
FOR UPDATE",
|
|
|
|
|
)
|
|
|
|
|
.fetch_optional(&mut *tx)
|
|
|
|
|
.await?;
|
|
|
|
|
let user_id = match claimable {
|
|
|
|
|
Some((user_id,)) => {
|
|
|
|
|
// Guarded so two simultaneous first registrations cannot both
|
|
|
|
|
// take the same legacy row: the loser finds no row to change.
|
|
|
|
|
let updated = sqlx::query(
|
|
|
|
|
"UPDATE users
|
|
|
|
|
SET username = $2, password_hash = $3
|
|
|
|
|
WHERE id = $1
|
|
|
|
|
AND password_hash IS NULL
|
|
|
|
|
AND NOT EXISTS (SELECT 1 FROM users WHERE password_hash IS NOT NULL)",
|
|
|
|
|
)
|
|
|
|
|
.bind(&user_id)
|
|
|
|
|
.bind(username)
|
|
|
|
|
.bind(password_hash)
|
|
|
|
|
.execute(&mut *tx)
|
|
|
|
|
.await?;
|
|
|
|
|
if updated.rows_affected() != 1 {
|
|
|
|
|
return Err(sqlx::Error::RowNotFound);
|
|
|
|
|
}
|
|
|
|
|
user_id
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
let user_id = Uuid::new_v4().simple().to_string();
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO users (id, name, username, password_hash) VALUES ($1, $2, $2, $3)",
|
|
|
|
|
)
|
|
|
|
|
.bind(&user_id)
|
|
|
|
|
.bind(username)
|
|
|
|
|
.bind(password_hash)
|
|
|
|
|
.execute(&mut *tx)
|
|
|
|
|
.await?;
|
|
|
|
|
user_id
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
tx.commit().await?;
|
|
|
|
|
Ok(user_id)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn account_by_username(
|
|
|
|
|
&self,
|
|
|
|
|
username: &str,
|
|
|
|
|
) -> Result<Option<Account>, sqlx::Error> {
|
|
|
|
|
let found: Option<(String, String, Option<String>)> = sqlx::query_as(
|
|
|
|
|
"SELECT id, username, password_hash FROM users WHERE lower(username) = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(username)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(found.map(|(user_id, username, password_hash)| Account {
|
|
|
|
|
user_id,
|
|
|
|
|
username,
|
|
|
|
|
password_hash,
|
|
|
|
|
}))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// The workspace a person owns. An account without one is treated as
|
|
|
|
|
/// signed out rather than handed somebody else's.
|
|
|
|
|
pub async fn default_space(&self, user_id: &str) -> Result<Option<String>, sqlx::Error> {
|
|
|
|
|
let space: Option<(String,)> = sqlx::query_as(
|
|
|
|
|
"SELECT id FROM spaces WHERE user_id = $1 ORDER BY is_default DESC, created_at LIMIT 1",
|
|
|
|
|
)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(space.map(|(id,)| id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Create the workspace on first use, then return it. Provider and model
|
|
|
|
|
/// defaults are empty of credentials on purpose: a key belongs in the
|
|
|
|
|
/// workspace settings, and without one nothing runs.
|
|
|
|
|
pub async fn ensure_default_space(&self, user_id: &str) -> Result<String, sqlx::Error> {
|
|
|
|
|
if let Some(space) = self.default_space(user_id).await? {
|
|
|
|
|
return Ok(space);
|
|
|
|
|
}
|
|
|
|
|
let space_id = format!("space-{user_id}");
|
2026-09-03 12:46:14 +00:00
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO spaces (id, user_id, name, is_default, default_model_provider, default_model_id)
|
|
|
|
|
VALUES ($1, $2, $3, TRUE, 'xai', 'grok-4.6')
|
|
|
|
|
ON CONFLICT (id) DO NOTHING",
|
|
|
|
|
)
|
2026-09-09 16:59:32 +00:00
|
|
|
.bind(&space_id)
|
2026-09-03 12:46:14 +00:00
|
|
|
.bind(user_id)
|
2026-09-09 16:59:32 +00:00
|
|
|
.bind("My workspace")
|
2026-09-03 12:46:14 +00:00
|
|
|
.execute(&self.pool)
|
|
|
|
|
.await?;
|
2026-09-09 16:59:32 +00:00
|
|
|
Ok(self.default_space(user_id).await?.unwrap_or(space_id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Every account that can sign in, for background work that must run per
|
|
|
|
|
/// person instead of once for the whole server.
|
|
|
|
|
pub async fn actors(&self) -> Result<Vec<Actor>, sqlx::Error> {
|
|
|
|
|
let rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
|
|
|
|
"SELECT u.id,
|
|
|
|
|
(SELECT s.id FROM spaces s
|
|
|
|
|
WHERE s.user_id = u.id
|
|
|
|
|
ORDER BY s.is_default DESC, s.created_at LIMIT 1) AS space_id
|
|
|
|
|
FROM users u
|
|
|
|
|
WHERE u.password_hash IS NOT NULL",
|
|
|
|
|
)
|
|
|
|
|
.fetch_all(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
let mut actors = Vec::new();
|
|
|
|
|
for (user_id, space_id) in rows {
|
|
|
|
|
// An account with no workspace cannot act on anything, so it is
|
|
|
|
|
// skipped rather than given a guessed space.
|
|
|
|
|
if let Some(space_id) = space_id {
|
|
|
|
|
actors.push(Actor { user_id, space_id });
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(actors)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn create_session(
|
|
|
|
|
&self,
|
|
|
|
|
user_id: &str,
|
|
|
|
|
token_hash: &str,
|
|
|
|
|
ttl_seconds: i64,
|
|
|
|
|
) -> Result<(), sqlx::Error> {
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
sqlx::query("DELETE FROM app_sessions WHERE expires_at <= now()")
|
|
|
|
|
.execute(&mut *tx)
|
|
|
|
|
.await?;
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO app_sessions (token_hash, user_id, expires_at)
|
|
|
|
|
VALUES ($1, $2, now() + make_interval(secs => $3::double precision))",
|
|
|
|
|
)
|
|
|
|
|
.bind(token_hash)
|
|
|
|
|
.bind(user_id)
|
|
|
|
|
.bind(ttl_seconds as f64)
|
|
|
|
|
.execute(&mut *tx)
|
|
|
|
|
.await?;
|
|
|
|
|
tx.commit().await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn session_actor(&self, token_hash: &str) -> Result<Option<Actor>, sqlx::Error> {
|
|
|
|
|
let row: Option<(String, Option<String>)> = sqlx::query_as(
|
|
|
|
|
"SELECT s.user_id,
|
|
|
|
|
(SELECT x.id FROM spaces x
|
|
|
|
|
WHERE x.user_id = s.user_id
|
|
|
|
|
ORDER BY x.is_default DESC, x.created_at LIMIT 1) AS space_id
|
|
|
|
|
FROM app_sessions s
|
|
|
|
|
WHERE s.token_hash = $1 AND s.expires_at > now()",
|
|
|
|
|
)
|
|
|
|
|
.bind(token_hash)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
let Some((user_id, Some(space_id))) = row else {
|
|
|
|
|
return Ok(None);
|
|
|
|
|
};
|
|
|
|
|
Ok(Some(Actor { user_id, space_id }))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn session_username(&self, token_hash: &str) -> Result<Option<String>, sqlx::Error> {
|
|
|
|
|
let row: Option<(Option<String>,)> = sqlx::query_as(
|
|
|
|
|
"SELECT u.username FROM users u
|
|
|
|
|
JOIN app_sessions s ON s.user_id = u.id
|
|
|
|
|
WHERE s.token_hash = $1 AND s.expires_at > now()",
|
|
|
|
|
)
|
|
|
|
|
.bind(token_hash)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(row.and_then(|(username,)| username))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn delete_session(&self, token_hash: &str) -> Result<(), sqlx::Error> {
|
|
|
|
|
sqlx::query("DELETE FROM app_sessions WHERE token_hash = $1")
|
|
|
|
|
.bind(token_hash)
|
|
|
|
|
.execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
Ok(())
|
2026-09-03 12:46:14 +00:00
|
|
|
}
|
|
|
|
|
|
2026-09-04 09:08:56 +00:00
|
|
|
pub async fn get_space(&self, actor: &Actor) -> Result<Option<SpaceRow>, sqlx::Error> {
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
"SELECT id, user_id, name, default_model_provider, default_model_id,
|
2026-09-06 03:43:38 +00:00
|
|
|
default_model_base_url, default_model_api_key,
|
|
|
|
|
voice_enabled, voice_provider, voice_model_id, voice_id, voice_api_key
|
2026-09-04 09:08:56 +00:00
|
|
|
FROM spaces WHERE id = $1 AND user_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn update_workspace_model(
|
|
|
|
|
&self,
|
|
|
|
|
actor: &Actor,
|
|
|
|
|
provider: &str,
|
|
|
|
|
model_id: &str,
|
|
|
|
|
base_url: Option<&str>,
|
|
|
|
|
api_key: Option<Option<&str>>,
|
|
|
|
|
) -> Result<SpaceRow, sqlx::Error> {
|
|
|
|
|
match api_key {
|
|
|
|
|
Some(key) => {
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE spaces
|
|
|
|
|
SET default_model_provider = $3, default_model_id = $4,
|
|
|
|
|
default_model_base_url = $5, default_model_api_key = $6
|
|
|
|
|
WHERE id = $1 AND user_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.bind(provider)
|
|
|
|
|
.bind(model_id)
|
|
|
|
|
.bind(base_url)
|
|
|
|
|
.bind(key)
|
|
|
|
|
.execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE spaces
|
|
|
|
|
SET default_model_provider = $3, default_model_id = $4,
|
|
|
|
|
default_model_base_url = $5
|
|
|
|
|
WHERE id = $1 AND user_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.bind(provider)
|
|
|
|
|
.bind(model_id)
|
|
|
|
|
.bind(base_url)
|
|
|
|
|
.execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 03:43:38 +00:00
|
|
|
pub async fn update_voice_settings(
|
|
|
|
|
&self,
|
|
|
|
|
actor: &Actor,
|
|
|
|
|
enabled: Option<bool>,
|
|
|
|
|
provider: &str,
|
|
|
|
|
model_id: &str,
|
|
|
|
|
voice_id: &str,
|
|
|
|
|
api_key: Option<Option<&str>>,
|
|
|
|
|
) -> Result<SpaceRow, sqlx::Error> {
|
|
|
|
|
match api_key {
|
|
|
|
|
Some(key) => {
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE spaces
|
|
|
|
|
SET voice_provider = $3, voice_model_id = $4, voice_id = $5, voice_api_key = $6, voice_enabled = COALESCE($7, voice_enabled)
|
|
|
|
|
WHERE id = $1 AND user_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.bind(provider)
|
|
|
|
|
.bind(model_id)
|
|
|
|
|
.bind(voice_id)
|
|
|
|
|
.bind(key)
|
|
|
|
|
.bind(enabled)
|
|
|
|
|
.execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
None => {
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"UPDATE spaces
|
|
|
|
|
SET voice_provider = $3, voice_model_id = $4, voice_id = $5, voice_enabled = COALESCE($6, voice_enabled)
|
|
|
|
|
WHERE id = $1 AND user_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.bind(provider)
|
|
|
|
|
.bind(model_id)
|
|
|
|
|
.bind(voice_id)
|
|
|
|
|
.bind(enabled)
|
|
|
|
|
.execute(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
self.get_space(actor).await?.ok_or(sqlx::Error::RowNotFound)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 09:08:56 +00:00
|
|
|
pub async fn list_bots(
|
|
|
|
|
&self,
|
|
|
|
|
actor: &Actor,
|
|
|
|
|
) -> Result<Vec<(BotRow, String, ComputerRow)>, sqlx::Error> {
|
2026-09-03 12:46:14 +00:00
|
|
|
let bots: Vec<BotRow> = sqlx::query_as(
|
2026-09-03 16:16:34 +00:00
|
|
|
"SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags,
|
|
|
|
|
b.pinned, b.hidden, b.group_name,
|
|
|
|
|
(SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id
|
2026-09-04 05:41:09 +00:00
|
|
|
WHERE t.bot_id=b.id AND t.room_id IS NULL AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count,
|
|
|
|
|
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id AND t.room_id IS NULL) AS last_message_at,
|
2026-09-03 23:43:37 +00:00
|
|
|
b.instructions, b.computer_id, b.model_provider, b.model_id, b.memory_enabled
|
2026-09-03 16:16:34 +00:00
|
|
|
FROM bots b WHERE b.space_id = $1 AND b.user_id = $2 ORDER BY b.pinned DESC, b.created_at DESC",
|
2026-09-03 12:46:14 +00:00
|
|
|
)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.fetch_all(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
let mut out = Vec::new();
|
|
|
|
|
for bot in bots {
|
2026-09-04 09:08:56 +00:00
|
|
|
let thread_id: (String,) = sqlx::query_as(
|
|
|
|
|
"SELECT id FROM threads
|
2026-09-04 05:41:09 +00:00
|
|
|
WHERE bot_id = $1 AND space_id = $2 AND user_id = $3 AND room_id IS NULL
|
2026-09-03 23:43:37 +00:00
|
|
|
ORDER BY updated_at DESC, created_at ASC LIMIT 1",
|
2026-09-04 09:08:56 +00:00
|
|
|
)
|
|
|
|
|
.bind(&bot.id)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.fetch_one(&self.pool)
|
|
|
|
|
.await?;
|
|
|
|
|
let computer = self
|
|
|
|
|
.get_computer(bot.computer_id.as_deref().unwrap_or(""))
|
|
|
|
|
.await?;
|
2026-09-03 12:46:14 +00:00
|
|
|
if let Some(computer) = computer {
|
|
|
|
|
out.push((bot, thread_id.0, computer));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Ok(out)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 09:08:56 +00:00
|
|
|
pub async fn get_bot(
|
|
|
|
|
&self,
|
|
|
|
|
actor: &Actor,
|
|
|
|
|
bot_id: &str,
|
|
|
|
|
) -> Result<Option<BotRow>, sqlx::Error> {
|
2026-09-03 12:46:14 +00:00
|
|
|
sqlx::query_as(
|
2026-09-03 16:16:34 +00:00
|
|
|
"SELECT b.id, b.space_id, b.user_id, b.name, b.title, b.description, b.avatar_color, b.avatar_shape, b.tags,
|
|
|
|
|
b.pinned, b.hidden, b.group_name,
|
|
|
|
|
(SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id
|
2026-09-04 05:41:09 +00:00
|
|
|
WHERE t.bot_id=b.id AND t.room_id IS NULL AND m.role='assistant' AND m.created_at>b.last_read_at) AS unread_count,
|
|
|
|
|
(SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.bot_id=b.id AND t.room_id IS NULL) AS last_message_at,
|
2026-09-03 23:43:37 +00:00
|
|
|
b.instructions, b.computer_id, b.model_provider, b.model_id, b.memory_enabled
|
2026-09-03 16:16:34 +00:00
|
|
|
FROM bots b WHERE b.id = $1 AND b.space_id = $2 AND b.user_id = $3",
|
2026-09-03 12:46:14 +00:00
|
|
|
)
|
|
|
|
|
.bind(bot_id)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 09:08:56 +00:00
|
|
|
pub async fn get_computer(
|
|
|
|
|
&self,
|
|
|
|
|
computer_id: &str,
|
|
|
|
|
) -> Result<Option<ComputerRow>, sqlx::Error> {
|
2026-09-03 12:46:14 +00:00
|
|
|
sqlx::query_as(
|
|
|
|
|
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
|
|
|
|
|
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
|
|
|
|
|
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
|
|
|
|
|
browser_profile_mode
|
|
|
|
|
FROM computers WHERE id = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(computer_id)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn create_bot(
|
|
|
|
|
&self,
|
|
|
|
|
actor: &Actor,
|
|
|
|
|
name: &str,
|
|
|
|
|
title: &str,
|
|
|
|
|
description: &str,
|
|
|
|
|
instructions: &str,
|
|
|
|
|
mode: ComputerMode,
|
|
|
|
|
model_provider: Option<&str>,
|
|
|
|
|
model_id: Option<&str>,
|
2026-09-03 23:43:37 +00:00
|
|
|
memory_enabled: bool,
|
2026-09-03 12:46:14 +00:00
|
|
|
) -> Result<Bot, sqlx::Error> {
|
|
|
|
|
let mut tx = self.pool.begin().await?;
|
|
|
|
|
let bot_id = Uuid::new_v4().to_string();
|
|
|
|
|
let thread_id = Uuid::new_v4().to_string();
|
2026-09-03 16:16:34 +00:00
|
|
|
let computer = ensure_computer(
|
|
|
|
|
&mut tx,
|
|
|
|
|
actor,
|
|
|
|
|
mode,
|
|
|
|
|
(mode == ComputerMode::Dedicated).then_some(bot_id.as_str()),
|
|
|
|
|
)
|
|
|
|
|
.await?;
|
2026-09-03 12:46:14 +00:00
|
|
|
sqlx::query(
|
2026-09-03 23:43:37 +00:00
|
|
|
"INSERT INTO bots (id, space_id, user_id, name, title, description, instructions, computer_id, model_provider, model_id, memory_enabled)
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)",
|
2026-09-03 12:46:14 +00:00
|
|
|
)
|
|
|
|
|
.bind(&bot_id)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.bind(name)
|
|
|
|
|
.bind(title)
|
|
|
|
|
.bind(description)
|
|
|
|
|
.bind(instructions)
|
2026-09-03 16:16:34 +00:00
|
|
|
.bind(&computer.id)
|
2026-09-03 12:46:14 +00:00
|
|
|
.bind(model_provider)
|
|
|
|
|
.bind(model_id)
|
2026-09-03 23:43:37 +00:00
|
|
|
.bind(memory_enabled)
|
2026-09-03 12:46:14 +00:00
|
|
|
.execute(&mut *tx)
|
|
|
|
|
.await?;
|
2026-09-04 05:41:09 +00:00
|
|
|
sqlx::query("INSERT INTO threads (id, space_id, bot_id, user_id, title) VALUES ($1,$2,$3,$4,'新對話')")
|
2026-09-03 12:46:14 +00:00
|
|
|
.bind(&thread_id)
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&bot_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.execute(&mut *tx)
|
|
|
|
|
.await?;
|
|
|
|
|
tx.commit().await?;
|
|
|
|
|
Ok(Bot {
|
|
|
|
|
id: bot_id,
|
|
|
|
|
space_id: actor.space_id.clone(),
|
|
|
|
|
name: name.into(),
|
|
|
|
|
title: title.into(),
|
|
|
|
|
description: description.into(),
|
2026-09-03 16:16:34 +00:00
|
|
|
avatar_color: "#8B5CF6".into(),
|
|
|
|
|
avatar_shape: "blob".into(),
|
|
|
|
|
tags: Vec::new(),
|
|
|
|
|
pinned: false,
|
|
|
|
|
hidden: false,
|
|
|
|
|
group_name: None,
|
|
|
|
|
unread_count: 0,
|
|
|
|
|
last_message_at: None,
|
2026-09-03 12:46:14 +00:00
|
|
|
instructions: instructions.into(),
|
|
|
|
|
thread_id,
|
2026-09-03 16:16:34 +00:00
|
|
|
computer_id: computer.id,
|
|
|
|
|
computer_mode: mode,
|
2026-09-03 12:46:14 +00:00
|
|
|
model_provider: model_provider.and_then(|value| value.parse().ok()),
|
|
|
|
|
model_id: model_id.map(str::to_string),
|
2026-09-03 23:43:37 +00:00
|
|
|
memory_enabled,
|
2026-09-03 12:46:14 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 09:08:56 +00:00
|
|
|
pub async fn get_screen(
|
|
|
|
|
&self,
|
|
|
|
|
computer_id: &str,
|
|
|
|
|
bot_id: &str,
|
|
|
|
|
) -> Result<Option<ScreenRow>, sqlx::Error> {
|
2026-09-03 12:46:14 +00:00
|
|
|
sqlx::query_as(
|
|
|
|
|
"SELECT id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path,
|
|
|
|
|
control_holder, control_lease_id, control_lease_expires_at, execution_run_id,
|
|
|
|
|
execution_lease_expires_at, execution_fence
|
|
|
|
|
FROM computer_screens WHERE computer_id = $1 AND bot_id = $2",
|
|
|
|
|
)
|
|
|
|
|
.bind(computer_id)
|
|
|
|
|
.bind(bot_id)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_screen_slots(&self, computer_id: &str) -> Result<Vec<i32>, sqlx::Error> {
|
|
|
|
|
sqlx::query_scalar("SELECT slot FROM computer_screens WHERE computer_id = $1 ORDER BY slot")
|
|
|
|
|
.bind(computer_id)
|
|
|
|
|
.fetch_all(&self.pool)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub async fn list_screens(&self, computer_id: &str) -> Result<Vec<ScreenRow>, sqlx::Error> {
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
"SELECT id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path,
|
|
|
|
|
control_holder, control_lease_id, control_lease_expires_at, execution_run_id,
|
|
|
|
|
execution_lease_expires_at, execution_fence
|
|
|
|
|
FROM computer_screens WHERE computer_id = $1 ORDER BY slot",
|
|
|
|
|
)
|
|
|
|
|
.bind(computer_id)
|
|
|
|
|
.fetch_all(&self.pool)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-04 09:08:56 +00:00
|
|
|
pub async fn active_run(
|
|
|
|
|
&self,
|
|
|
|
|
bot_id: &str,
|
|
|
|
|
) -> Result<Option<(String, String, String)>, sqlx::Error> {
|
2026-09-03 12:46:14 +00:00
|
|
|
sqlx::query_as(
|
2026-09-04 05:41:09 +00:00
|
|
|
"SELECT id, status, thread_id FROM runs
|
2026-09-03 12:46:14 +00:00
|
|
|
WHERE bot_id = $1
|
|
|
|
|
AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
|
|
|
|
|
ORDER BY created_at DESC LIMIT 1",
|
|
|
|
|
)
|
|
|
|
|
.bind(bot_id)
|
|
|
|
|
.fetch_optional(&self.pool)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async fn ensure_computer(
|
|
|
|
|
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
|
|
|
|
actor: &Actor,
|
|
|
|
|
mode: ComputerMode,
|
|
|
|
|
bot_id: Option<&str>,
|
|
|
|
|
) -> Result<ComputerRow, sqlx::Error> {
|
|
|
|
|
let scope_key = computer_scope_key(mode, &actor.space_id, bot_id).expect("scope key");
|
|
|
|
|
let home_key = computer_home_key(mode, &actor.space_id, bot_id).expect("home key");
|
|
|
|
|
sqlx::query(
|
|
|
|
|
"INSERT INTO computers (id, space_id, user_id, scope, scope_key, home_key, kind, state)
|
|
|
|
|
VALUES ($1,$2,$3,$4,$5,$6,'docker','stopped')
|
|
|
|
|
ON CONFLICT (scope_key) DO NOTHING",
|
|
|
|
|
)
|
|
|
|
|
.bind(Uuid::new_v4().to_string())
|
|
|
|
|
.bind(&actor.space_id)
|
|
|
|
|
.bind(&actor.user_id)
|
|
|
|
|
.bind(mode.as_str())
|
|
|
|
|
.bind(&scope_key)
|
|
|
|
|
.bind(&home_key)
|
|
|
|
|
.execute(&mut **tx)
|
|
|
|
|
.await?;
|
|
|
|
|
sqlx::query_as(
|
|
|
|
|
"SELECT id, space_id, user_id, scope, scope_key, home_key, home_revision, kind, provider_ref, state,
|
|
|
|
|
control_holder, control_lease_id, control_lease_expires_at, control_bot_id, control_run_id,
|
|
|
|
|
execution_run_id, execution_bot_id, execution_lease_expires_at, execution_fence,
|
|
|
|
|
browser_profile_mode
|
|
|
|
|
FROM computers WHERE scope_key = $1",
|
|
|
|
|
)
|
|
|
|
|
.bind(scope_key)
|
|
|
|
|
.fetch_one(&mut **tx)
|
|
|
|
|
.await
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_mode(scope: &str) -> ComputerMode {
|
|
|
|
|
scope.parse().unwrap_or(ComputerMode::Team)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_state(state: &str) -> ComputerState {
|
|
|
|
|
match state {
|
|
|
|
|
"booting" => ComputerState::Booting,
|
|
|
|
|
"running" => ComputerState::Running,
|
|
|
|
|
"suspended" => ComputerState::Suspended,
|
|
|
|
|
"error" => ComputerState::Error,
|
|
|
|
|
_ => ComputerState::Stopped,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_holder(holder: &str) -> ControlHolder {
|
|
|
|
|
match holder {
|
|
|
|
|
"bot" => ControlHolder::Bot,
|
|
|
|
|
"user" => ControlHolder::User,
|
|
|
|
|
_ => ControlHolder::None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_kind(kind: &str) -> SandboxKind {
|
|
|
|
|
let _ = kind;
|
|
|
|
|
SandboxKind::Docker
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_profile_mode(value: &str) -> BrowserProfileMode {
|
|
|
|
|
value.parse().unwrap_or(BrowserProfileMode::PerBot)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn parse_run_status(status: &str) -> Option<RunStatus> {
|
|
|
|
|
match status {
|
|
|
|
|
"queued" => Some(RunStatus::Queued),
|
|
|
|
|
"leased" => Some(RunStatus::Leased),
|
|
|
|
|
"running" => Some(RunStatus::Running),
|
|
|
|
|
"waiting_input" => Some(RunStatus::WaitingInput),
|
|
|
|
|
"waiting_takeover" => Some(RunStatus::WaitingTakeover),
|
|
|
|
|
"completed" => Some(RunStatus::Completed),
|
|
|
|
|
"failed" => Some(RunStatus::Failed),
|
|
|
|
|
"cancelled" => Some(RunStatus::Cancelled),
|
|
|
|
|
_ => None,
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-09 16:59:32 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::{Actor, Db};
|
|
|
|
|
|
|
|
|
|
/// One install, many people: an account may only reach the workspace it owns.
|
|
|
|
|
/// This replaced a shared token that opened everything, so scoping is the
|
|
|
|
|
/// whole security model here and it is worth a database test.
|
|
|
|
|
#[sqlx::test(migrations = "../../migrations")]
|
|
|
|
|
async fn each_account_only_reaches_its_own_workspace(pool: sqlx::PgPool) {
|
|
|
|
|
// An install from before accounts existed: one row that cannot sign in,
|
|
|
|
|
// owning every agent.
|
|
|
|
|
sqlx::query("INSERT INTO users (id, name) VALUES ('local-user', 'Local')")
|
|
|
|
|
.execute(&pool)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let db = Db { pool: pool.clone() };
|
|
|
|
|
|
|
|
|
|
let alice = db.create_account("alice", "hash-a").await.unwrap();
|
|
|
|
|
assert_eq!(alice, "local-user", "the first account adopts the old data");
|
|
|
|
|
let alice_space = db.ensure_default_space(&alice).await.unwrap();
|
|
|
|
|
|
|
|
|
|
let bob = db.create_account("bob", "hash-b").await.unwrap();
|
|
|
|
|
let bob_space = db.ensure_default_space(&bob).await.unwrap();
|
|
|
|
|
assert_ne!(alice_space, bob_space, "nobody shares a workspace");
|
|
|
|
|
|
|
|
|
|
// A username is an address: one spelling, so `ALICE` is not a second door.
|
|
|
|
|
assert!(
|
|
|
|
|
db.create_account("ALICE", "hash-c").await.is_err(),
|
|
|
|
|
"a differently cased name must not open the same account"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
sqlx::query("INSERT INTO bots (id, space_id, user_id, name) VALUES ('bot-a', $1, $2, 'A')")
|
|
|
|
|
.bind(&alice_space)
|
|
|
|
|
.bind(&alice)
|
|
|
|
|
.execute(&pool)
|
|
|
|
|
.await
|
|
|
|
|
.unwrap();
|
|
|
|
|
let alice_actor = Actor {
|
|
|
|
|
user_id: alice.clone(),
|
|
|
|
|
space_id: alice_space,
|
|
|
|
|
};
|
|
|
|
|
let bob_actor = Actor {
|
|
|
|
|
user_id: bob.clone(),
|
|
|
|
|
space_id: bob_space,
|
|
|
|
|
};
|
|
|
|
|
assert!(db.get_bot(&alice_actor, "bot-a").await.unwrap().is_some());
|
|
|
|
|
assert!(
|
|
|
|
|
db.get_bot(&bob_actor, "bot-a").await.unwrap().is_none(),
|
|
|
|
|
"another account's agent is not even visible by id"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Background work iterates people, so each actor must carry only their
|
|
|
|
|
// own space.
|
|
|
|
|
let mut actors = db.actors().await.unwrap();
|
|
|
|
|
let scoped: Vec<(String, String)> = actors
|
|
|
|
|
.drain(..)
|
|
|
|
|
.map(|actor| (actor.user_id, actor.space_id))
|
|
|
|
|
.collect();
|
|
|
|
|
assert_eq!(
|
|
|
|
|
scoped.len(),
|
|
|
|
|
2,
|
|
|
|
|
"the account that still cannot sign in is left out"
|
|
|
|
|
);
|
|
|
|
|
assert!(
|
|
|
|
|
scoped.contains(&(alice, alice_actor.space_id)),
|
|
|
|
|
"each account is handed exactly its own workspace"
|
|
|
|
|
);
|
|
|
|
|
assert!(scoped.contains(&(bob, bob_actor.space_id)));
|
|
|
|
|
}
|
|
|
|
|
}
|