1682 lines
57 KiB
Rust
1682 lines
57 KiB
Rust
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use chrono::{TimeDelta, Utc};
|
|
use lazyboy_contracts::{
|
|
BrowserProfileMode, ComputerCapabilities, ComputerMode, ComputerStatus, ControlHolder,
|
|
DEFAULT_SCREEN_HEIGHT, DEFAULT_SCREEN_WIDTH,
|
|
};
|
|
use lazyboy_control::{
|
|
AdapterContext, CommandRequest, EnsureScreenRequest, ProvisionRequest, admit_gui,
|
|
admit_new_screen, attach_display_for_tool, browser_profile_path,
|
|
execution_blocks_user_takeover, profile_lock_key, screen_layout, team_bot_workspace_directory,
|
|
};
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
use crate::db::{
|
|
Actor, ComputerRow, ScreenRow, parse_holder, parse_kind, parse_mode, parse_profile_mode,
|
|
parse_run_status, parse_state,
|
|
};
|
|
use crate::state::AppState;
|
|
|
|
pub const STEP_BOOTING: &str = "電腦啟動中";
|
|
pub const STEP_WAKING: &str = "喚醒中";
|
|
pub const STEP_HANDOFF: &str = "換手中";
|
|
|
|
pub fn status_from(
|
|
bot_id: &str,
|
|
computer: &ComputerRow,
|
|
screen: Option<&ScreenRow>,
|
|
busy_bot_name: Option<String>,
|
|
) -> ComputerStatus {
|
|
let control_holder = screen
|
|
.map(|row| parse_holder(&row.control_holder))
|
|
.unwrap_or_else(|| parse_holder(&computer.control_holder));
|
|
let control_bot_id = screen
|
|
.and_then(|row| {
|
|
if parse_holder(&row.control_holder) == ControlHolder::User {
|
|
Some(row.bot_id.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.or_else(|| computer.control_bot_id.clone());
|
|
ComputerStatus {
|
|
bot_id: bot_id.to_string(),
|
|
mode: parse_mode(&computer.scope),
|
|
kind: parse_kind(&computer.kind),
|
|
state: parse_state(&computer.state),
|
|
shared_input: true,
|
|
control_holder,
|
|
control_bot_id,
|
|
takeover_requested: false,
|
|
screen_available: computer.provider_ref.is_some()
|
|
&& computer.state == "running"
|
|
&& screen.is_some(),
|
|
screen_width: DEFAULT_SCREEN_WIDTH,
|
|
screen_height: DEFAULT_SCREEN_HEIGHT,
|
|
home_revision: Some(computer.home_revision.clone()),
|
|
busy_bot_name,
|
|
busy_session_id: None,
|
|
busy_run_id: None,
|
|
busy_step: None,
|
|
using_computer: false,
|
|
waiting_run_id: None,
|
|
waiting_session_id: None,
|
|
queued_runs: 0,
|
|
multi_screen: true,
|
|
screen_id: screen.map(|row| row.id.clone()),
|
|
display: screen.map(|row| row.display.clone()),
|
|
profile_mode: screen
|
|
.map(|row| parse_profile_mode(&row.profile_mode))
|
|
.unwrap_or_else(|| parse_profile_mode(&computer.browser_profile_mode)),
|
|
}
|
|
}
|
|
|
|
pub fn home_path(data_dir: &str, home_key: &str) -> PathBuf {
|
|
PathBuf::from(data_dir).join("homes").join(home_key)
|
|
}
|
|
|
|
pub fn adapter_context(actor: &Actor, bot_id: &str, operation: &str) -> AdapterContext {
|
|
AdapterContext {
|
|
operation_id: operation.into(),
|
|
space_id: actor.space_id.clone(),
|
|
user_id: actor.user_id.clone(),
|
|
bot_id: Some(bot_id.into()),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn adapter_context_for(
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
operation: &str,
|
|
screen: Option<&ScreenRow>,
|
|
run_id: Option<&str>,
|
|
) -> AdapterContext {
|
|
let mut ctx = adapter_context(actor, bot_id, operation);
|
|
ctx.run_id = run_id.map(str::to_string);
|
|
if let Some(screen) = screen {
|
|
ctx.screen_id = Some(screen.id.clone());
|
|
ctx.screen_slot = Some(screen.slot as u32);
|
|
ctx.display = Some(screen.display.clone());
|
|
ctx.profile_path = Some(screen.profile_path.clone());
|
|
ctx.screen_lease_id = screen.execution_run_id.clone();
|
|
}
|
|
ctx
|
|
}
|
|
|
|
pub struct BoundScreen {
|
|
pub row: Option<ScreenRow>,
|
|
pub gui_block: Option<String>,
|
|
}
|
|
|
|
/// Refresh presentation on an existing running screen without opening apps,
|
|
/// changing the Cua session, or booting a stopped computer.
|
|
pub async fn refresh_cursor_color(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
) -> Result<(), String> {
|
|
let Some(bot) = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let Some(computer_id) = bot.computer_id else {
|
|
return Ok(());
|
|
};
|
|
let Some(computer) = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
if computer.state != "running" {
|
|
return Ok(());
|
|
}
|
|
let Some(target) = computer_ref(&computer) else {
|
|
return Ok(());
|
|
};
|
|
let Some(screen) = state
|
|
.db
|
|
.get_screen(&computer_id, bot_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
else {
|
|
return Ok(());
|
|
};
|
|
let result = state
|
|
.sandbox
|
|
.execute(
|
|
&target,
|
|
CommandRequest {
|
|
argv: vec![
|
|
"/usr/local/bin/lazyboy-screen".into(),
|
|
"color".into(),
|
|
screen.slot.to_string(),
|
|
bot.avatar_color,
|
|
],
|
|
cwd: None,
|
|
timeout_ms: Some(5_000),
|
|
stdin: None,
|
|
..CommandRequest::default()
|
|
},
|
|
&adapter_context_for(actor, bot_id, "cursor-color", Some(&screen), None),
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
if result.code != 0 {
|
|
return Err("cursor color refresh failed".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn ensure_bot_screen(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
computer: &ComputerRow,
|
|
run_id: Option<&str>,
|
|
) -> Result<BoundScreen, String> {
|
|
let Some(computer_ref) = computer_ref(computer) else {
|
|
return Err("computer is not running".into());
|
|
};
|
|
let caps = state
|
|
.sandbox
|
|
.capabilities(&computer_ref, &adapter_context(actor, bot_id, "caps"))
|
|
.await
|
|
.unwrap_or(ComputerCapabilities { multi_screen: true });
|
|
let existing = state
|
|
.db
|
|
.get_screen(&computer.id, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
let used: Vec<u32> = state
|
|
.db
|
|
.list_screen_slots(&computer.id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.into_iter()
|
|
.map(|slot| slot as u32)
|
|
.collect();
|
|
let slot = match admit_new_screen(&caps, &used, existing.as_ref().map(|row| row.slot as u32)) {
|
|
Ok(slot) => slot,
|
|
Err(block) => {
|
|
return Ok(BoundScreen {
|
|
row: existing,
|
|
gui_block: Some(block.message()),
|
|
});
|
|
}
|
|
};
|
|
let layout = screen_layout(slot).map_err(|error| error.to_string())?;
|
|
let profile_mode = parse_profile_mode(&computer.browser_profile_mode);
|
|
let profile_path = browser_profile_path(profile_mode, bot_id, run_id);
|
|
let row = if let Some(existing) = existing {
|
|
if existing.profile_path != profile_path && profile_mode == BrowserProfileMode::PerTask {
|
|
sqlx::query(
|
|
"UPDATE computer_screens SET profile_path = $2, updated_at = now() WHERE id = $1",
|
|
)
|
|
.bind(&existing.id)
|
|
.bind(&profile_path)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
}
|
|
state
|
|
.db
|
|
.get_screen(&computer.id, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.unwrap_or(existing)
|
|
} else {
|
|
let id = Uuid::new_v4().to_string();
|
|
let inserted = sqlx::query_as::<_, ScreenRow>(
|
|
"INSERT INTO computer_screens (
|
|
id, computer_id, bot_id, slot, display, view_port, profile_mode, profile_path
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
|
|
ON CONFLICT (computer_id, bot_id) DO UPDATE SET updated_at = now()
|
|
RETURNING 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",
|
|
)
|
|
.bind(&id)
|
|
.bind(&computer.id)
|
|
.bind(bot_id)
|
|
.bind(slot as i32)
|
|
.bind(&layout.display)
|
|
.bind(layout.view_port as i32)
|
|
.bind(profile_mode.as_str())
|
|
.bind(&profile_path)
|
|
.fetch_one(state.pool())
|
|
.await;
|
|
match inserted {
|
|
Ok(row) => row,
|
|
Err(error)
|
|
if error
|
|
.to_string()
|
|
.contains("computer_screens_computer_id_slot") =>
|
|
{
|
|
return Ok(BoundScreen {
|
|
row: None,
|
|
gui_block: Some(
|
|
admit_new_screen(&caps, &used, None)
|
|
.err()
|
|
.map(|block| block.message())
|
|
.unwrap_or_else(|| lazyboy_contracts::TEAM_SCREENS_FULL.to_string()),
|
|
),
|
|
});
|
|
}
|
|
Err(error) => return Err(error.to_string()),
|
|
}
|
|
};
|
|
let ctx = adapter_context_for(actor, bot_id, "screen", Some(&row), run_id);
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
let request = EnsureScreenRequest {
|
|
slot: row.slot as u32,
|
|
profile_path: row.profile_path.clone(),
|
|
bot_id: bot_id.to_string(),
|
|
bot_name: bot.as_ref().map(|bot| bot.name.clone()).unwrap_or_default(),
|
|
bot_color: bot.map(|bot| bot.avatar_color).unwrap_or_default(),
|
|
};
|
|
let mut last_error = None;
|
|
for attempt in 0..8 {
|
|
match state
|
|
.sandbox
|
|
.ensure_screen(&computer_ref, request.clone(), &ctx)
|
|
.await
|
|
{
|
|
Ok(_) => {
|
|
last_error = None;
|
|
break;
|
|
}
|
|
Err(error) => {
|
|
let busy = error.to_string().contains("busy starting");
|
|
last_error = Some(error);
|
|
if !busy || attempt == 7 {
|
|
break;
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(400)).await;
|
|
}
|
|
}
|
|
}
|
|
if let Some(error) = last_error {
|
|
tracing::warn!("ensure screen {}: {error}", row.display);
|
|
return Err(format!("ensure screen {}: {error}", row.display));
|
|
}
|
|
Ok(BoundScreen {
|
|
row: Some(row),
|
|
gui_block: None,
|
|
})
|
|
}
|
|
|
|
async fn restore_computer_screens(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
computer: &ComputerRow,
|
|
skip_bot_id: &str,
|
|
) {
|
|
let Ok(screens) = state.db.list_screens(&computer.id).await else {
|
|
return;
|
|
};
|
|
let Some(computer_ref) = computer_ref(computer) else {
|
|
return;
|
|
};
|
|
for screen in screens {
|
|
if screen.bot_id == skip_bot_id {
|
|
continue;
|
|
}
|
|
let ctx = adapter_context_for(actor, &screen.bot_id, "screen", Some(&screen), None);
|
|
let bot = state.db.get_bot(actor, &screen.bot_id).await.ok().flatten();
|
|
let _ = state
|
|
.sandbox
|
|
.ensure_screen(
|
|
&computer_ref,
|
|
EnsureScreenRequest {
|
|
slot: screen.slot as u32,
|
|
profile_path: screen.profile_path.clone(),
|
|
bot_id: screen.bot_id.clone(),
|
|
bot_name: bot.as_ref().map(|bot| bot.name.clone()).unwrap_or_default(),
|
|
bot_color: bot.map(|bot| bot.avatar_color).unwrap_or_default(),
|
|
},
|
|
&ctx,
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn guest_has_screens(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
computer: &ComputerRow,
|
|
) -> bool {
|
|
matches!(
|
|
probe_computer_container(state, actor, bot_id, computer).await,
|
|
ContainerProbe::Alive
|
|
)
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum ContainerProbe {
|
|
Alive,
|
|
Missing,
|
|
Unknown,
|
|
}
|
|
|
|
fn container_missing_error(error: &str) -> bool {
|
|
let error = error.to_ascii_lowercase();
|
|
error.contains("is not running")
|
|
|| error.contains("no such container")
|
|
|| error.contains("404 not found")
|
|
|| error.contains("status code 409")
|
|
}
|
|
|
|
async fn probe_computer_container(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
computer: &ComputerRow,
|
|
) -> ContainerProbe {
|
|
let Some(computer_ref) = computer_ref(computer) else {
|
|
return ContainerProbe::Missing;
|
|
};
|
|
match state
|
|
.sandbox
|
|
.execute(
|
|
&computer_ref,
|
|
CommandRequest {
|
|
argv: vec![
|
|
"test".into(),
|
|
"-x".into(),
|
|
"/usr/local/bin/lazyboy-screen".into(),
|
|
],
|
|
cwd: None,
|
|
timeout_ms: Some(5_000),
|
|
stdin: None,
|
|
..CommandRequest::default()
|
|
},
|
|
&adapter_context(actor, bot_id, "probe"),
|
|
)
|
|
.await
|
|
{
|
|
Ok(output) if output.code == 0 => ContainerProbe::Alive,
|
|
Ok(_) => ContainerProbe::Missing,
|
|
Err(error) if container_missing_error(&error.to_string()) => ContainerProbe::Missing,
|
|
Err(error) => {
|
|
tracing::warn!("computer {} probe failed: {error}", computer.id);
|
|
ContainerProbe::Unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn mark_computer_missing(state: &AppState, computer: &mut ComputerRow) {
|
|
if computer.state != "running" {
|
|
return;
|
|
}
|
|
tracing::warn!(
|
|
"computer {} is marked running but the container is gone",
|
|
computer.id
|
|
);
|
|
if let Err(error) = sqlx::query(
|
|
"UPDATE computers SET state = 'error', updated_at = now() WHERE id = $1 AND state = 'running'",
|
|
)
|
|
.bind(&computer.id)
|
|
.execute(state.pool())
|
|
.await
|
|
{
|
|
tracing::error!("failed to mark computer {} missing: {error}", computer.id);
|
|
return;
|
|
}
|
|
computer.state = "error".into();
|
|
}
|
|
|
|
pub async fn take_screen_execution(
|
|
state: &AppState,
|
|
screen: &ScreenRow,
|
|
run_id: &str,
|
|
) -> Result<ScreenRow, String> {
|
|
sqlx::query(
|
|
"UPDATE computer_screens
|
|
SET execution_run_id = $2, execution_lease_expires_at = $3,
|
|
execution_fence = execution_fence + 1,
|
|
control_holder = 'none', control_lease_id = NULL, control_lease_expires_at = NULL,
|
|
updated_at = now()
|
|
WHERE id = $1",
|
|
)
|
|
.bind(&screen.id)
|
|
.bind(run_id)
|
|
.bind(Utc::now() + TimeDelta::minutes(5))
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
state
|
|
.db
|
|
.get_screen(&screen.computer_id, &screen.bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "screen missing after lease".to_string())
|
|
}
|
|
|
|
pub async fn release_screen_execution(state: &AppState, run_id: &str) -> Result<(), String> {
|
|
sqlx::query(
|
|
"UPDATE computer_screens
|
|
SET execution_run_id = NULL, execution_lease_expires_at = NULL, updated_at = now()
|
|
WHERE execution_run_id = $1",
|
|
)
|
|
.bind(run_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
sqlx::query("DELETE FROM computer_profile_locks WHERE run_id = $1")
|
|
.bind(run_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn take_profile_lock(
|
|
state: &AppState,
|
|
computer: &ComputerRow,
|
|
bot_id: &str,
|
|
bot_name: &str,
|
|
run_id: &str,
|
|
screen: &ScreenRow,
|
|
) -> Result<Option<String>, String> {
|
|
let mode = parse_profile_mode(&screen.profile_mode);
|
|
let key = profile_lock_key(mode, bot_id, Some(run_id));
|
|
let expires = Utc::now() + TimeDelta::minutes(5);
|
|
let taken: Option<String> = sqlx::query_scalar(
|
|
"INSERT INTO computer_profile_locks (computer_id, profile_key, bot_id, run_id, expires_at)
|
|
VALUES ($1,$2,$3,$4,$5)
|
|
ON CONFLICT (computer_id, profile_key) DO UPDATE
|
|
SET bot_id = EXCLUDED.bot_id, run_id = EXCLUDED.run_id, expires_at = EXCLUDED.expires_at
|
|
WHERE computer_profile_locks.bot_id = EXCLUDED.bot_id
|
|
OR computer_profile_locks.expires_at < now()
|
|
RETURNING bot_id",
|
|
)
|
|
.bind(&computer.id)
|
|
.bind(&key)
|
|
.bind(bot_id)
|
|
.bind(run_id)
|
|
.bind(expires)
|
|
.fetch_optional(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
if taken.as_deref() == Some(bot_id) {
|
|
return Ok(None);
|
|
}
|
|
let holder: Option<String> = sqlx::query_scalar(
|
|
"SELECT b.name FROM computer_profile_locks l JOIN bots b ON b.id = l.bot_id
|
|
WHERE l.computer_id = $1 AND l.profile_key = $2 AND l.expires_at > now()",
|
|
)
|
|
.bind(&computer.id)
|
|
.bind(&key)
|
|
.fetch_optional(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
let other = holder.unwrap_or_else(|| bot_name.to_string());
|
|
Ok(Some(
|
|
admit_gui(
|
|
&ComputerCapabilities { multi_screen: true },
|
|
bot_id,
|
|
true,
|
|
None,
|
|
Some("other"),
|
|
Some(&other),
|
|
)
|
|
.err()
|
|
.map(|block| block.message())
|
|
.unwrap_or_else(|| {
|
|
format!(
|
|
"Another bot is using this shared browser profile. Currently in use by {other}."
|
|
)
|
|
}),
|
|
))
|
|
}
|
|
|
|
pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> {
|
|
boot_for(state, actor, bot_id, true).await
|
|
}
|
|
|
|
/// `need_gui=false` provisions the Runner only: no `ensure_screen`, no viewer.
|
|
pub async fn boot_for(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
need_gui: bool,
|
|
) -> Result<ComputerStatus, String> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "bot not found".to_string())?;
|
|
let computer_id = bot
|
|
.computer_id
|
|
.clone()
|
|
.ok_or_else(|| "bot has no computer".to_string())?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "computer not found".to_string())?;
|
|
if computer.state == "running" && computer.provider_ref.is_some() {
|
|
if guest_has_screens(state, actor, bot_id, &computer).await {
|
|
let screen = if attach_display_for_tool(need_gui) {
|
|
ensure_bot_screen(state, actor, bot_id, &computer, None)
|
|
.await
|
|
.ok()
|
|
.and_then(|bound| bound.row)
|
|
} else {
|
|
None
|
|
};
|
|
if attach_display_for_tool(need_gui) {
|
|
restore_computer_screens(state, actor, &computer, bot_id).await;
|
|
}
|
|
return Ok(status_from(bot_id, &computer, screen.as_ref(), None));
|
|
}
|
|
if let Some(computer_ref) = computer_ref(&computer) {
|
|
let ctx = adapter_context(actor, bot_id, "reprovision");
|
|
let _ = state.sandbox.stop(&computer_ref, &ctx).await;
|
|
let _ = state.sandbox.destroy(&computer_ref, &ctx).await;
|
|
}
|
|
sqlx::query(
|
|
"UPDATE computers SET state = 'stopped', provider_ref = NULL, generation = generation + 1, updated_at = now() WHERE id = $1",
|
|
)
|
|
.bind(&computer_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
interrupt_computer_jobs(state, &computer_id).await;
|
|
}
|
|
if computer.state == "suspended" && computer.provider_ref.is_some() {
|
|
match resume_paused(state, actor, bot_id, &computer, need_gui).await {
|
|
Ok(status) => return Ok(status),
|
|
Err(error) => {
|
|
tracing::warn!("computer {computer_id} resume failed: {error}");
|
|
let _ = sqlx::query(
|
|
"UPDATE computers SET state = 'stopped', updated_at = now()
|
|
WHERE id = $1 AND state = 'suspended'",
|
|
)
|
|
.bind(&computer_id)
|
|
.execute(state.pool())
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
let claimed = sqlx::query(
|
|
"UPDATE computers SET state = 'booting', updated_at = now()
|
|
WHERE id = $1 AND state IN ('stopped','suspended','error')",
|
|
)
|
|
.bind(&computer_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
// Only the request that atomically transitions the row to booting may
|
|
// provision it. Allowing every caller that observes `booting` through
|
|
// here starts duplicate containers and can leave the row inconsistent.
|
|
if claimed.rows_affected() != 1 {
|
|
return Err("Computer is busy".into());
|
|
}
|
|
let ctx = adapter_context(actor, bot_id, "boot");
|
|
let home = home_path(&state.data_dir, &computer.home_key);
|
|
if let Err(error) = tokio::fs::create_dir_all(&home).await {
|
|
let message = error.to_string();
|
|
mark_boot_error(state, &computer_id, None).await;
|
|
return Err(message);
|
|
}
|
|
let provisioned = match tokio::time::timeout(
|
|
Duration::from_secs(120),
|
|
state.sandbox.provision(
|
|
ProvisionRequest {
|
|
home_key: computer.home_key.clone(),
|
|
home_path: home.to_string_lossy().into_owned(),
|
|
provider_ref: computer.provider_ref.clone(),
|
|
runner_only: !need_gui,
|
|
},
|
|
&ctx,
|
|
),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(provisioned)) => provisioned,
|
|
Ok(Err(error)) => {
|
|
let message = error.to_string();
|
|
mark_boot_error(state, &computer_id, None).await;
|
|
return Err(message);
|
|
}
|
|
Err(_) => {
|
|
let message = "computer provision timed out after 120 seconds".to_string();
|
|
mark_boot_error(state, &computer_id, None).await;
|
|
return Err(message);
|
|
}
|
|
};
|
|
if parse_mode(&computer.scope) == ComputerMode::Team {
|
|
let folder = match team_bot_workspace_directory(bot_id) {
|
|
Ok(folder) => folder,
|
|
Err(error) => {
|
|
let message = error.to_string();
|
|
mark_boot_error(state, &computer_id, None).await;
|
|
return Err(message);
|
|
}
|
|
};
|
|
let setup = tokio::time::timeout(
|
|
Duration::from_secs(15),
|
|
state.sandbox.execute(
|
|
&provisioned,
|
|
CommandRequest {
|
|
argv: vec!["mkdir".into(), "-p".into(), "shared".into(), folder],
|
|
cwd: None,
|
|
timeout_ms: Some(10_000),
|
|
stdin: None,
|
|
..CommandRequest::default()
|
|
},
|
|
&ctx,
|
|
),
|
|
)
|
|
.await;
|
|
if let Err(error) = match setup {
|
|
Ok(result) => result.map_err(|error| error.to_string()),
|
|
Err(_) => Err("computer workspace setup timed out after 15 seconds".into()),
|
|
} {
|
|
// Workspace setup is additive; a failed mkdir should not make an
|
|
// otherwise usable desktop unavailable. The next run can retry it.
|
|
tracing::warn!("computer workspace setup failed for {bot_id}: {error}");
|
|
}
|
|
}
|
|
// Record the display backend the container was actually started with, so
|
|
// health reports what runs rather than the column default.
|
|
let running = sqlx::query(
|
|
"UPDATE computers SET state = 'running', provider_ref = $2, kind = $3,
|
|
display_backend = $4, updated_at = now()
|
|
WHERE id = $1 AND state = 'booting'",
|
|
)
|
|
.bind(&computer_id)
|
|
.bind(&provisioned.provider_ref)
|
|
.bind(provisioned.kind.as_str())
|
|
.bind(lazyboy_control::DisplayBackend::from_env().as_str())
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
if running.rows_affected() != 1 {
|
|
return Err("computer boot was superseded".into());
|
|
}
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.unwrap();
|
|
let screen = if attach_display_for_tool(need_gui) {
|
|
ensure_bot_screen(state, actor, bot_id, &computer, None)
|
|
.await
|
|
.ok()
|
|
.and_then(|bound| bound.row)
|
|
} else {
|
|
None
|
|
};
|
|
if attach_display_for_tool(need_gui) {
|
|
restore_computer_screens(state, actor, &computer, bot_id).await;
|
|
}
|
|
Ok(status_from(bot_id, &computer, screen.as_ref(), None))
|
|
}
|
|
|
|
async fn resume_paused(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
computer: &ComputerRow,
|
|
need_gui: bool,
|
|
) -> Result<ComputerStatus, String> {
|
|
let ctx = adapter_context(actor, bot_id, "resume");
|
|
let home = home_path(&state.data_dir, &computer.home_key);
|
|
let resumed = match tokio::time::timeout(
|
|
Duration::from_secs(20),
|
|
state.sandbox.resume(
|
|
ProvisionRequest {
|
|
home_key: computer.home_key.clone(),
|
|
home_path: home.to_string_lossy().into_owned(),
|
|
provider_ref: computer.provider_ref.clone(),
|
|
runner_only: !need_gui,
|
|
},
|
|
&ctx,
|
|
),
|
|
)
|
|
.await
|
|
{
|
|
Ok(Ok(resumed)) => resumed,
|
|
Ok(Err(error)) => return Err(error.to_string()),
|
|
Err(_) => return Err("computer resume timed out after 20 seconds".into()),
|
|
};
|
|
let running = sqlx::query(
|
|
"UPDATE computers SET state = 'running', provider_ref = $2, kind = $3, updated_at = now()
|
|
WHERE id = $1 AND state = 'suspended'",
|
|
)
|
|
.bind(&computer.id)
|
|
.bind(&resumed.provider_ref)
|
|
.bind(resumed.kind.as_str())
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
if running.rows_affected() != 1 {
|
|
let current = state
|
|
.db
|
|
.get_computer(&computer.id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "computer not found".to_string())?;
|
|
if current.state == "running" {
|
|
let screen = if attach_display_for_tool(need_gui) {
|
|
ensure_bot_screen(state, actor, bot_id, ¤t, None)
|
|
.await
|
|
.ok()
|
|
.and_then(|bound| bound.row)
|
|
} else {
|
|
None
|
|
};
|
|
return Ok(status_from(bot_id, ¤t, screen.as_ref(), None));
|
|
}
|
|
return Err("computer resume was superseded".into());
|
|
}
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer.id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "computer not found".to_string())?;
|
|
let screen = if attach_display_for_tool(need_gui) {
|
|
ensure_bot_screen(state, actor, bot_id, &computer, None)
|
|
.await
|
|
.ok()
|
|
.and_then(|bound| bound.row)
|
|
} else {
|
|
None
|
|
};
|
|
if attach_display_for_tool(need_gui) {
|
|
restore_computer_screens(state, actor, &computer, bot_id).await;
|
|
}
|
|
Ok(status_from(bot_id, &computer, screen.as_ref(), None))
|
|
}
|
|
|
|
async fn mark_boot_error(state: &AppState, computer_id: &str, provider_ref: Option<&str>) {
|
|
if let Err(error) = sqlx::query(
|
|
"UPDATE computers SET state = 'error', provider_ref = COALESCE($2, provider_ref), updated_at = now()
|
|
WHERE id = $1 AND state = 'booting'",
|
|
)
|
|
.bind(computer_id)
|
|
.bind(provider_ref)
|
|
.execute(state.pool())
|
|
.await
|
|
{
|
|
tracing::error!("failed to mark computer {computer_id} boot error: {error}");
|
|
}
|
|
}
|
|
|
|
pub async fn stop(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "bot not found".to_string())?;
|
|
let computer_id = bot
|
|
.computer_id
|
|
.clone()
|
|
.ok_or_else(|| "bot has no computer".to_string())?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "computer not found".to_string())?;
|
|
if computer_has_active_work(state, &computer_id).await {
|
|
return Err("電腦仍有工作或示範進行中,請先停止工作再關閉電腦。".into());
|
|
}
|
|
if let Some(provider_ref) = &computer.provider_ref {
|
|
let ctx = adapter_context(actor, bot_id, "stop");
|
|
state
|
|
.sandbox
|
|
.stop(
|
|
&lazyboy_control::ComputerRef {
|
|
id: provider_ref.clone(),
|
|
home_key: computer.home_key.clone(),
|
|
kind: parse_kind(&computer.kind),
|
|
provider_ref: provider_ref.clone(),
|
|
fresh: false,
|
|
},
|
|
&ctx,
|
|
)
|
|
.await
|
|
.map_err(|error| format!("無法關閉電腦:{error}"))?;
|
|
}
|
|
sqlx::query(
|
|
"UPDATE computers SET state = 'stopped', control_holder = 'none', control_lease_id = NULL,
|
|
control_lease_expires_at = NULL, control_bot_id = NULL, control_run_id = NULL, updated_at = now()
|
|
WHERE id = $1",
|
|
)
|
|
.bind(&computer_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|e| e.to_string())?
|
|
.unwrap();
|
|
Ok(status_from(bot_id, &computer, None, None))
|
|
}
|
|
|
|
pub async fn restart(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
) -> Result<ComputerStatus, String> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "bot not found".to_string())?;
|
|
let computer_id = bot
|
|
.computer_id
|
|
.clone()
|
|
.ok_or_else(|| "bot has no computer".to_string())?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "computer not found".to_string())?;
|
|
if let Some(computer_ref) = computer_ref(&computer) {
|
|
let ctx = adapter_context(actor, bot_id, "restart");
|
|
if tokio::time::timeout(
|
|
Duration::from_secs(20),
|
|
state.sandbox.stop(&computer_ref, &ctx),
|
|
)
|
|
.await
|
|
.is_err()
|
|
{
|
|
tracing::warn!(
|
|
"computer stop timed out during restart: {}",
|
|
computer_ref.id
|
|
);
|
|
}
|
|
if tokio::time::timeout(
|
|
Duration::from_secs(20),
|
|
state.sandbox.destroy(&computer_ref, &ctx),
|
|
)
|
|
.await
|
|
.is_err()
|
|
{
|
|
tracing::warn!(
|
|
"computer destroy timed out during restart: {}",
|
|
computer_ref.id
|
|
);
|
|
}
|
|
}
|
|
sqlx::query(
|
|
"UPDATE computers SET state = 'stopped', provider_ref = NULL, control_holder = 'none',
|
|
control_lease_id = NULL, control_lease_expires_at = NULL, control_bot_id = NULL,
|
|
control_run_id = NULL, execution_bot_id = NULL, execution_run_id = NULL,
|
|
execution_lease_expires_at = NULL, generation = generation + 1, updated_at = now()
|
|
WHERE id = $1",
|
|
)
|
|
.bind(&computer_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
interrupt_computer_jobs(state, &computer_id).await;
|
|
boot(state, actor, bot_id).await
|
|
}
|
|
|
|
pub async fn takeover(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
) -> Result<(String, String), String> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "bot not found".to_string())?;
|
|
let computer_id = bot
|
|
.computer_id
|
|
.clone()
|
|
.ok_or_else(|| "bot has no computer".to_string())?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "computer not found".to_string())?;
|
|
if computer.state != "running" || computer.provider_ref.is_none() {
|
|
return Err("computer must be running".into());
|
|
}
|
|
let bound = ensure_bot_screen(state, actor, bot_id, &computer, None).await?;
|
|
let screen = bound.row.ok_or_else(|| {
|
|
bound
|
|
.gui_block
|
|
.unwrap_or_else(|| "screen unavailable".into())
|
|
})?;
|
|
let active = state
|
|
.db
|
|
.active_run(bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
let run_status = active
|
|
.as_ref()
|
|
.and_then(|(_, status, _)| parse_run_status(status));
|
|
if execution_blocks_user_takeover(
|
|
screen.execution_run_id.is_some(),
|
|
screen.execution_lease_expires_at,
|
|
run_status,
|
|
Utc::now(),
|
|
) {
|
|
return Err("Stop the task first".into());
|
|
}
|
|
let lease_id = Uuid::new_v4().to_string();
|
|
let expires = Utc::now() + TimeDelta::minutes(15);
|
|
sqlx::query(
|
|
"UPDATE computer_screens SET control_holder = 'user', control_lease_id = $2, control_lease_expires_at = $3,
|
|
updated_at = now() WHERE id = $1",
|
|
)
|
|
.bind(&screen.id)
|
|
.bind(&lease_id)
|
|
.bind(expires)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
sqlx::query(
|
|
"UPDATE computers SET control_holder = 'user', control_lease_id = $2, control_lease_expires_at = $3,
|
|
control_bot_id = $4, updated_at = now() WHERE id = $1",
|
|
)
|
|
.bind(&computer_id)
|
|
.bind(&lease_id)
|
|
.bind(expires)
|
|
.bind(bot_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
let paused: Vec<(String, String)> = sqlx::query_as(
|
|
"UPDATE runs SET status = 'waiting_takeover',
|
|
checkpoint = COALESCE(checkpoint, '{}'::jsonb)
|
|
|| jsonb_build_object('resumeAfterTakeover', true),
|
|
updated_at = now()
|
|
WHERE bot_id = $1 AND status IN ('queued','leased','running')
|
|
RETURNING id, thread_id",
|
|
)
|
|
.bind(bot_id)
|
|
.fetch_all(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
for (run_id, thread_id) in paused {
|
|
crate::runs::set_run_step(state, &run_id, STEP_HANDOFF).await;
|
|
let _ = crate::runs::append_bot_message(
|
|
state,
|
|
&thread_id,
|
|
&run_id,
|
|
bot_id,
|
|
"我已暫停等你操作。完成後按「完成,繼續」,我會從目前畫面接著做。",
|
|
)
|
|
.await;
|
|
}
|
|
Ok((lease_id, expires.to_rfc3339()))
|
|
}
|
|
|
|
pub async fn release(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(), String> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "bot not found".to_string())?;
|
|
let Some(computer_id) = bot.computer_id else {
|
|
return Ok(());
|
|
};
|
|
sqlx::query(
|
|
"UPDATE computer_screens SET control_holder = 'none', control_lease_id = NULL,
|
|
control_lease_expires_at = NULL, updated_at = now()
|
|
WHERE computer_id = $1 AND bot_id = $2",
|
|
)
|
|
.bind(&computer_id)
|
|
.bind(bot_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
sqlx::query(
|
|
"UPDATE computers SET control_holder = 'none', control_lease_id = NULL, control_lease_expires_at = NULL,
|
|
control_bot_id = NULL, control_run_id = NULL, updated_at = now()
|
|
WHERE id = $1 AND control_bot_id = $2",
|
|
)
|
|
.bind(computer_id)
|
|
.bind(bot_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
// Releasing human control resumes work that was paused by either the bot or a forced takeover.
|
|
sqlx::query(
|
|
"UPDATE runs SET status = 'queued', updated_at = now()
|
|
WHERE bot_id = $1 AND status = 'waiting_takeover'",
|
|
)
|
|
.bind(bot_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn heartbeat(state: &AppState, actor: &Actor, bot_id: &str) -> Result<(), String> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "bot not found".to_string())?;
|
|
let Some(computer_id) = bot.computer_id else {
|
|
return Ok(());
|
|
};
|
|
let expires = Utc::now() + TimeDelta::minutes(15);
|
|
sqlx::query("UPDATE computers SET updated_at = now() WHERE id = $1")
|
|
.bind(&computer_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
sqlx::query(
|
|
"UPDATE computer_screens SET control_lease_expires_at = $2, updated_at = now()
|
|
WHERE computer_id = $1 AND bot_id = $3 AND control_holder = 'user'",
|
|
)
|
|
.bind(&computer_id)
|
|
.bind(expires)
|
|
.bind(bot_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
sqlx::query(
|
|
"UPDATE computers SET control_lease_expires_at = $2, updated_at = now()
|
|
WHERE id = $1 AND control_holder = 'user' AND control_bot_id = $3",
|
|
)
|
|
.bind(computer_id)
|
|
.bind(expires)
|
|
.bind(bot_id)
|
|
.execute(state.pool())
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Viewing/input does not acquire the human pause lease. Agent execution and
|
|
/// explicit requests for human assistance retain their own pause/resume flow.
|
|
pub fn user_can_interact(computer: &ComputerRow, screen: Option<&ScreenRow>, bot_id: &str) -> bool {
|
|
computer.state == "running"
|
|
&& computer.provider_ref.is_some()
|
|
&& screen.is_some_and(|screen| screen.bot_id == bot_id && screen.computer_id == computer.id)
|
|
}
|
|
|
|
pub async fn idle_loop(state: AppState) {
|
|
loop {
|
|
tokio::time::sleep(Duration::from_secs(60)).await;
|
|
crate::attachments::sweep_all_inboxes(&state.data_dir).await;
|
|
pause_idle_computers(&state).await;
|
|
stop_parked_computers(&state).await;
|
|
}
|
|
}
|
|
|
|
/// `computer_jobs.status` is only written when a tool asks the Computer, so a
|
|
/// background job nobody polled would keep the Computer "busy" forever. Before
|
|
/// the idle reaper trusts those rows, ask the Computer whether they still run.
|
|
async fn refresh_running_jobs(state: &AppState, computer: &ComputerRow) {
|
|
let Some(computer_ref) = computer_ref(computer) else {
|
|
return;
|
|
};
|
|
let ids: Vec<String> = sqlx::query_scalar(
|
|
"SELECT id FROM computer_jobs WHERE computer_id=$1 AND status='running'
|
|
ORDER BY updated_at LIMIT 16",
|
|
)
|
|
.bind(&computer.id)
|
|
.fetch_all(state.pool())
|
|
.await
|
|
.unwrap_or_default();
|
|
for id in ids {
|
|
let outcome = state
|
|
.sandbox
|
|
.execute(
|
|
&computer_ref,
|
|
CommandRequest {
|
|
argv: Vec::new(),
|
|
timeout_ms: Some(5_000),
|
|
job_id: Some(id.clone()),
|
|
job_op: Some("status".into()),
|
|
..CommandRequest::default()
|
|
},
|
|
&idle_adapter(computer, "job-status"),
|
|
)
|
|
.await;
|
|
let status = match outcome {
|
|
Ok(result) => match result.status {
|
|
Some(status) if status != "running" => status,
|
|
_ => continue,
|
|
},
|
|
Err(error) if error.to_string().contains("unknown job") => "interrupted".into(),
|
|
Err(_) => continue,
|
|
};
|
|
let _ = sqlx::query(
|
|
"UPDATE computer_jobs SET status=$2, updated_at=now() WHERE id=$1 AND status='running'",
|
|
)
|
|
.bind(&id)
|
|
.bind(&status)
|
|
.execute(state.pool())
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn computer_has_active_work(state: &AppState, computer_id: &str) -> bool {
|
|
// Row presence only: `SELECT 1` is INT4 and decoding it as i64 used to fail
|
|
// on every call, which silently turned this guard into "never busy".
|
|
let active = sqlx::query(
|
|
"SELECT 1 FROM runs WHERE status IN ('queued','leased','running','waiting_input','waiting_takeover')
|
|
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
|
|
UNION ALL
|
|
SELECT 1 FROM taught_skills WHERE status IN ('recording','drafting')
|
|
AND bot_id IN (SELECT id FROM bots WHERE computer_id = $1)
|
|
UNION ALL
|
|
SELECT 1 FROM computer_jobs WHERE computer_id = $1 AND status IN ('running','accepted')
|
|
LIMIT 1",
|
|
)
|
|
.bind(computer_id)
|
|
.fetch_optional(state.pool())
|
|
.await;
|
|
matches!(active, Ok(Some(_)))
|
|
}
|
|
|
|
fn idle_adapter(computer: &ComputerRow, operation: &str) -> AdapterContext {
|
|
AdapterContext {
|
|
operation_id: operation.into(),
|
|
space_id: computer.space_id.clone(),
|
|
user_id: computer.user_id.clone(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
async fn pause_idle_computers(state: &AppState) {
|
|
let cutoff = Utc::now() - TimeDelta::minutes(10);
|
|
let rows = sqlx::query_as::<_, ComputerRow>(
|
|
"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, generation
|
|
FROM computers WHERE state = 'running' AND updated_at < $1",
|
|
)
|
|
.bind(cutoff)
|
|
.fetch_all(state.pool())
|
|
.await;
|
|
let Ok(rows) = rows else { return };
|
|
for computer in rows {
|
|
refresh_running_jobs(state, &computer).await;
|
|
if computer_has_active_work(state, &computer.id).await {
|
|
continue;
|
|
}
|
|
if let Some(computer_ref) = computer_ref(&computer)
|
|
&& state
|
|
.sandbox
|
|
.suspend(&computer_ref, &idle_adapter(&computer, "idle"))
|
|
.await
|
|
.is_err()
|
|
{
|
|
continue;
|
|
}
|
|
let _ = sqlx::query(
|
|
"UPDATE computers SET state = 'suspended', updated_at = now()
|
|
WHERE id = $1 AND state = 'running'",
|
|
)
|
|
.bind(&computer.id)
|
|
.execute(state.pool())
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn stop_parked_computers(state: &AppState) {
|
|
let cutoff = Utc::now() - TimeDelta::hours(6);
|
|
let rows = sqlx::query_as::<_, ComputerRow>(
|
|
"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, generation
|
|
FROM computers WHERE state = 'suspended' AND updated_at < $1",
|
|
)
|
|
.bind(cutoff)
|
|
.fetch_all(state.pool())
|
|
.await;
|
|
let Ok(rows) = rows else { return };
|
|
for computer in rows {
|
|
if computer_has_active_work(state, &computer.id).await {
|
|
continue;
|
|
}
|
|
if let Some(computer_ref) = computer_ref(&computer) {
|
|
let _ = state
|
|
.sandbox
|
|
.stop(&computer_ref, &idle_adapter(&computer, "parked"))
|
|
.await;
|
|
}
|
|
let _ = sqlx::query(
|
|
"UPDATE computers SET state = 'stopped', updated_at = now()
|
|
WHERE id = $1 AND state = 'suspended'",
|
|
)
|
|
.bind(&computer.id)
|
|
.execute(state.pool())
|
|
.await;
|
|
}
|
|
}
|
|
|
|
async fn interrupt_computer_jobs(state: &AppState, computer_id: &str) {
|
|
crate::job_store::interrupt_computer(computer_id);
|
|
let _ = sqlx::query(
|
|
"UPDATE computer_jobs SET status='interrupted', updated_at=now()
|
|
WHERE computer_id=$1 AND status IN ('running','accepted')",
|
|
)
|
|
.bind(computer_id)
|
|
.execute(state.pool())
|
|
.await;
|
|
}
|
|
|
|
pub async fn component_health(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
) -> Result<serde_json::Value, String> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "bot not found".to_string())?;
|
|
let computer_id = bot
|
|
.computer_id
|
|
.ok_or_else(|| "bot has no computer".to_string())?;
|
|
let computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "computer not found".to_string())?;
|
|
let display_backend: String =
|
|
sqlx::query_scalar("SELECT display_backend FROM computers WHERE id=$1")
|
|
.bind(&computer.id)
|
|
.fetch_optional(state.pool())
|
|
.await
|
|
.ok()
|
|
.flatten()
|
|
.unwrap_or_else(|| {
|
|
std::env::var("LAZYBOY_DISPLAY_BACKEND").unwrap_or_else(|_| "xvfb_x11vnc".into())
|
|
});
|
|
let jobs_running: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM computer_jobs WHERE computer_id=$1 AND status='running'",
|
|
)
|
|
.bind(&computer.id)
|
|
.fetch_one(state.pool())
|
|
.await
|
|
.unwrap_or(0);
|
|
let jobs_quota = lazyboy_control::native_job_concurrency() as i64;
|
|
let sandbox = if computer.state != "running" || computer.provider_ref.is_none() {
|
|
json!({"name":"sandbox","ok":true,"detail":"computer not running"})
|
|
} else {
|
|
match probe_computer_container(state, actor, bot_id, &computer).await {
|
|
ContainerProbe::Alive => json!({"name":"sandbox","ok":true,"detail":"alive"}),
|
|
ContainerProbe::Missing => {
|
|
json!({"name":"sandbox","ok":false,"detail":"container missing"})
|
|
}
|
|
ContainerProbe::Unknown => json!({"name":"sandbox","ok":false,"detail":"probe failed"}),
|
|
}
|
|
};
|
|
let journal_ok = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM computer_operations")
|
|
.fetch_one(state.pool())
|
|
.await
|
|
.is_ok();
|
|
let components = vec![
|
|
json!({"name":"database","ok":true,"detail":"reachable"}),
|
|
sandbox,
|
|
json!({"name":"display","ok":true,"detail":display_backend}),
|
|
json!({
|
|
"name":"jobs",
|
|
"ok": jobs_running <= jobs_quota,
|
|
"detail": format!("{jobs_running}/{jobs_quota} running")
|
|
}),
|
|
json!({
|
|
"name":"operations",
|
|
"ok": journal_ok,
|
|
"detail": if journal_ok { "available" } else { "unreachable" }
|
|
}),
|
|
];
|
|
let ok = components
|
|
.iter()
|
|
.all(|item| item["ok"].as_bool() == Some(true));
|
|
Ok(json!({
|
|
"computerId": computer.id,
|
|
"botId": bot_id,
|
|
"state": computer.state,
|
|
"generation": computer.generation,
|
|
"displayBackend": display_backend,
|
|
"jobsRunning": jobs_running,
|
|
"jobsQuota": jobs_quota,
|
|
"ok": ok,
|
|
"components": components,
|
|
}))
|
|
}
|
|
|
|
pub fn computer_ref(computer: &ComputerRow) -> Option<lazyboy_control::ComputerRef> {
|
|
let provider_ref = computer.provider_ref.clone()?;
|
|
Some(lazyboy_control::ComputerRef {
|
|
id: provider_ref.clone(),
|
|
home_key: computer.home_key.clone(),
|
|
kind: parse_kind(&computer.kind),
|
|
provider_ref,
|
|
fresh: false,
|
|
})
|
|
}
|
|
|
|
pub async fn current_status(
|
|
state: &AppState,
|
|
actor: &Actor,
|
|
bot_id: &str,
|
|
) -> Result<ComputerStatus, String> {
|
|
let bot = state
|
|
.db
|
|
.get_bot(actor, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "bot not found".to_string())?;
|
|
let computer_id = bot
|
|
.computer_id
|
|
.ok_or_else(|| "bot has no computer".to_string())?;
|
|
let mut computer = state
|
|
.db
|
|
.get_computer(&computer_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?
|
|
.ok_or_else(|| "computer not found".to_string())?;
|
|
if computer.state == "running"
|
|
&& probe_computer_container(state, actor, bot_id, &computer).await
|
|
== ContainerProbe::Missing
|
|
{
|
|
mark_computer_missing(state, &mut computer).await;
|
|
}
|
|
let screen = state
|
|
.db
|
|
.get_screen(&computer.id, bot_id)
|
|
.await
|
|
.map_err(|error| error.to_string())?;
|
|
// Newest-first is wrong here: a message queued behind a paused run must not
|
|
// hide the pause. Rank by what the user needs to know about.
|
|
let active: Vec<ActiveRunRow> = sqlx::query_as(
|
|
"SELECT id, status, thread_id, checkpoint->>'step' AS step FROM runs
|
|
WHERE bot_id = $1
|
|
AND status IN ('queued','leased','running','waiting_input','waiting_takeover')
|
|
ORDER BY CASE status
|
|
WHEN 'running' THEN 0 WHEN 'leased' THEN 1
|
|
WHEN 'waiting_takeover' THEN 2 WHEN 'waiting_input' THEN 3
|
|
ELSE 4 END,
|
|
created_at ASC",
|
|
)
|
|
.bind(bot_id)
|
|
.fetch_all(state.pool())
|
|
.await
|
|
.unwrap_or_default();
|
|
let waiting = active.iter().find(|run| run.status == "waiting_takeover");
|
|
let busy = active.iter().find(|run| {
|
|
parse_run_status(&run.status).is_some_and(|status| {
|
|
status.is_active() && status != lazyboy_contracts::RunStatus::WaitingTakeover
|
|
})
|
|
});
|
|
// While the bot is paused for the human, later messages just queue up; the
|
|
// spinner would lie, so report them as queued instead of busy.
|
|
let busy = if waiting.is_some() {
|
|
busy.filter(|run| run.status != "queued")
|
|
} else {
|
|
busy
|
|
};
|
|
let busy_bot_name = busy.map(|_| bot.name.clone());
|
|
let mut status = status_from(bot_id, &computer, screen.as_ref(), busy_bot_name);
|
|
status.takeover_requested = waiting.is_some();
|
|
if let Some(run) = busy {
|
|
status.busy_run_id = Some(run.id.clone());
|
|
status.busy_session_id = Some(run.thread_id.clone());
|
|
status.busy_step = run.step.clone();
|
|
status.using_computer = screen
|
|
.as_ref()
|
|
.and_then(|row| row.execution_run_id.as_deref())
|
|
== Some(run.id.as_str())
|
|
|| computer.execution_run_id.as_deref() == Some(run.id.as_str());
|
|
}
|
|
if let Some(run) = waiting {
|
|
status.waiting_run_id = Some(run.id.clone());
|
|
status.waiting_session_id = Some(run.thread_id.clone());
|
|
}
|
|
status.queued_runs = active
|
|
.iter()
|
|
.filter(|run| {
|
|
run.status == "queued" && Some(run.id.as_str()) != busy.map(|b| b.id.as_str())
|
|
})
|
|
.count() as u32;
|
|
Ok(status)
|
|
}
|
|
|
|
#[derive(sqlx::FromRow)]
|
|
struct ActiveRunRow {
|
|
id: String,
|
|
status: String,
|
|
thread_id: String,
|
|
step: Option<String>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod shared_input_tests {
|
|
use super::*;
|
|
|
|
fn desktop() -> (ComputerRow, ScreenRow) {
|
|
let computer = ComputerRow {
|
|
id: "computer".into(),
|
|
space_id: "space".into(),
|
|
user_id: "user".into(),
|
|
scope: "team".into(),
|
|
scope_key: "team:space".into(),
|
|
home_key: "home".into(),
|
|
home_revision: "1".into(),
|
|
kind: "docker".into(),
|
|
provider_ref: Some("container".into()),
|
|
state: "running".into(),
|
|
control_holder: "bot".into(),
|
|
control_lease_id: None,
|
|
control_lease_expires_at: None,
|
|
control_bot_id: Some("bot".into()),
|
|
control_run_id: None,
|
|
execution_run_id: Some("run".into()),
|
|
execution_bot_id: Some("bot".into()),
|
|
execution_lease_expires_at: None,
|
|
execution_fence: 1,
|
|
browser_profile_mode: "per-bot".into(),
|
|
generation: 1,
|
|
};
|
|
let screen = ScreenRow {
|
|
id: "screen".into(),
|
|
computer_id: computer.id.clone(),
|
|
bot_id: "bot".into(),
|
|
slot: 1,
|
|
display: ":1".into(),
|
|
view_port: 6080,
|
|
profile_mode: "per-bot".into(),
|
|
profile_path: "/tmp/profile".into(),
|
|
control_holder: "bot".into(),
|
|
control_lease_id: None,
|
|
control_lease_expires_at: None,
|
|
execution_run_id: Some("run".into()),
|
|
execution_lease_expires_at: None,
|
|
execution_fence: 1,
|
|
};
|
|
(computer, screen)
|
|
}
|
|
|
|
#[test]
|
|
fn human_input_does_not_require_or_change_the_agent_lease() {
|
|
let (computer, mut screen) = desktop();
|
|
for holder in ["bot", "none", "user"] {
|
|
screen.control_holder = holder.into();
|
|
assert!(user_can_interact(&computer, Some(&screen), "bot"));
|
|
assert_eq!(screen.execution_run_id.as_deref(), Some("run"));
|
|
assert!(status_from("bot", &computer, Some(&screen), None).shared_input);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn shared_input_requires_the_bots_own_live_screen() {
|
|
let (mut computer, mut screen) = desktop();
|
|
assert!(!user_can_interact(&computer, None, "bot"));
|
|
assert!(!user_can_interact(&computer, Some(&screen), "other-bot"));
|
|
screen.computer_id = "other-computer".into();
|
|
assert!(!user_can_interact(&computer, Some(&screen), "bot"));
|
|
screen.computer_id = computer.id.clone();
|
|
for state in ["stopped", "booting", "suspended", "error"] {
|
|
computer.state = state.into();
|
|
assert!(!user_can_interact(&computer, Some(&screen), "bot"));
|
|
}
|
|
computer.state = "running".into();
|
|
computer.provider_ref = None;
|
|
assert!(!user_can_interact(&computer, Some(&screen), "bot"));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod idle_job_tests {
|
|
use super::*;
|
|
|
|
fn app(pool: sqlx::PgPool) -> AppState {
|
|
AppState {
|
|
db: crate::db::Db { pool },
|
|
sandbox: std::sync::Arc::new(lazyboy_sandbox::FakeSandbox::new()),
|
|
data_dir: String::new(),
|
|
auth: crate::auth::AuthConfig::from_env(),
|
|
memory: crate::memory::MemoryService::from_env(),
|
|
mcp: crate::mcp::McpHub::new(),
|
|
calls: crate::state::CallRegistry::default(),
|
|
wakes: crate::state::WakeBus::default(),
|
|
}
|
|
}
|
|
|
|
async fn job_status(pool: &sqlx::PgPool, id: &str) -> String {
|
|
sqlx::query_scalar("SELECT status FROM computer_jobs WHERE id=$1")
|
|
.bind(id)
|
|
.fetch_one(pool)
|
|
.await
|
|
.unwrap()
|
|
}
|
|
|
|
/// A background job that finished (or vanished with its supervisor) while
|
|
/// nobody polled it must not keep the Computer busy forever.
|
|
#[sqlx::test(migrations = "../../migrations")]
|
|
async fn idle_reaper_reconciles_unpolled_jobs_with_the_computer(pool: sqlx::PgPool) {
|
|
sqlx::query("INSERT INTO users(id,name) VALUES ('u','test')")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO computers(id,space_id,user_id,scope,scope_key,home_key,provider_ref,state)
|
|
VALUES ('c','s','u','team','team:s','home','container','running')",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
let state = app(pool.clone());
|
|
let computer: ComputerRow = 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, generation
|
|
FROM computers WHERE id='c'",
|
|
)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.unwrap();
|
|
let computer_ref = computer_ref(&computer).unwrap();
|
|
let adapter = idle_adapter(&computer, "test");
|
|
|
|
// Three jobs the DB believes are running: one really is, one finished
|
|
// unobserved, one the Computer has never heard of (supervisor restart).
|
|
for (id, argv) in [("job-live", "/bin/sleep"), ("job-done", "/bin/true")] {
|
|
state
|
|
.sandbox
|
|
.execute(
|
|
&computer_ref,
|
|
CommandRequest {
|
|
argv: vec![argv.into(), "30".into()],
|
|
background: true,
|
|
job_id: Some(id.into()),
|
|
..CommandRequest::default()
|
|
},
|
|
&adapter,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
for id in ["job-live", "job-done", "job-lost"] {
|
|
sqlx::query(
|
|
"INSERT INTO computer_jobs(id,computer_id,bot_id,status) VALUES ($1,'c','b','running')",
|
|
)
|
|
.bind(id)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
}
|
|
assert!(computer_has_active_work(&state, "c").await);
|
|
|
|
refresh_running_jobs(&state, &computer).await;
|
|
assert_eq!(job_status(&pool, "job-live").await, "running");
|
|
assert_eq!(job_status(&pool, "job-done").await, "succeeded");
|
|
assert_eq!(job_status(&pool, "job-lost").await, "interrupted");
|
|
assert!(computer_has_active_work(&state, "c").await);
|
|
|
|
sqlx::query("DELETE FROM computer_jobs WHERE id='job-live'")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(!computer_has_active_work(&state, "c").await);
|
|
|
|
// A live run on a bot bound to this Computer is work too (this guard
|
|
// used to be dead: `SELECT 1` never decoded).
|
|
sqlx::query(
|
|
"INSERT INTO bots(id,space_id,user_id,name,computer_id) VALUES ('b','s','u','bot','c')",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("INSERT INTO threads(id,space_id,user_id,bot_id) VALUES ('t','s','u','b')")
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(
|
|
"INSERT INTO runs(id,space_id,user_id,bot_id,thread_id,status,trigger,prompt)
|
|
VALUES ('r','s','u','b','t','running','message','go')",
|
|
)
|
|
.execute(&pool)
|
|
.await
|
|
.unwrap();
|
|
assert!(computer_has_active_work(&state, "c").await);
|
|
}
|
|
}
|