fix session
This commit is contained in:
parent
b60d4c91ae
commit
d71c9900de
File diff suppressed because one or more lines are too long
|
|
@ -175,7 +175,7 @@ export function CallOverlay({
|
|||
<button type="button" className="call-hangup" onClick={hangUp}>{t("hangUp")}</button>
|
||||
</div>
|
||||
{showTakeover ? (
|
||||
<button type="button" className="primary call-takeover" onClick={onTakeOver}>{t("loginOpenScreen")}</button>
|
||||
<button type="button" className="primary call-takeover" onClick={onTakeOver}>{t("takeOverNow")}</button>
|
||||
) : null}
|
||||
<p className="call-hint">{t("callShortcuts")}</p>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@
|
|||
.computer-caption{display:flex;align-items:center;justify-content:space-between;padding:14px 0;color:var(--muted)}
|
||||
.control-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px;border-top:1px solid var(--hairline);padding:16px 0}
|
||||
.computer-overlay{position:fixed;inset:0;z-index:50;display:flex;flex-direction:column;background:rgba(4,4,5,.98)}
|
||||
.computer-overlay>header{height:64px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid var(--line)}
|
||||
.computer-overlay>header>div{display:flex;align-items:center;gap:10px}
|
||||
.computer-overlay>header{min-height:64px;height:auto;display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:8px 18px;border-bottom:1px solid var(--line);overflow:visible}
|
||||
.computer-overlay>header>div{display:flex;align-items:center;flex-wrap:wrap;gap:10px;min-width:0}
|
||||
.computer-overlay .avatar{width:30px;height:30px}
|
||||
.control-badge{padding:5px 10px;border-radius:999px;background:rgba(48,162,75,.14);color:var(--success);font-size:13px}
|
||||
.overlay-screen{flex:1;min-height:0;padding:20px;display:flex;justify-content:center}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
|||
results: "Results",
|
||||
advancedSettings: "Advanced settings",
|
||||
sharedDesktop: "Shared control",
|
||||
sharedNeedsUser: "{name} is waiting for you to finish the steps on screen. Then press “Done, continue”.",
|
||||
sharedNeedsUser: "{name} is waiting for you to finish the steps on screen. Press “Take over” to open it, then “Done, continue”.",
|
||||
doneContinue: "Done, continue",
|
||||
search: "Search",
|
||||
sharedComputer: "Shared computer",
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ export const zhTW = {
|
|||
results: "成果",
|
||||
advancedSettings: "進階設定",
|
||||
sharedDesktop: "共同操作",
|
||||
sharedNeedsUser: "{name} 已暫停等你完成畫面上的步驟。完成後按「完成,繼續」。",
|
||||
sharedNeedsUser: "{name} 已暫停等你完成畫面上的步驟。按「接手操作」打開畫面,完成後按「完成,繼續」。",
|
||||
doneContinue: "完成,繼續",
|
||||
search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦",
|
||||
stopped: "已關閉", booting: "啟動中", suspended: "休眠中", error: "發生錯誤",
|
||||
|
|
|
|||
|
|
@ -468,7 +468,8 @@
|
|||
.error-banner{z-index:8;bottom:118px}
|
||||
|
||||
|
||||
.pause-banner{display:flex;align-items:center;gap:14px;width:min(760px,100%);margin:4px auto 18px;padding:12px 14px;border:1px solid var(--warn-border);border-radius:12px;background:var(--warn-bg);color:var(--warn-fg);font-size:13px;line-height:1.5}
|
||||
.pause-banner{display:flex;align-items:center;flex-wrap:wrap;gap:14px;width:min(760px,100%);margin:4px auto 18px;padding:12px 14px;border:1px solid var(--warn-border);border-radius:12px;background:var(--warn-bg);color:var(--warn-fg);font-size:13px;line-height:1.5}
|
||||
.pause-banner-actions{display:flex;flex-wrap:wrap;gap:8px;flex:0 0 auto}
|
||||
|
||||
|
||||
.pause-banner span{flex:1}
|
||||
|
|
|
|||
|
|
@ -34,5 +34,6 @@
|
|||
.sched-when>span{display:flex;flex-wrap:wrap;gap:6px;overflow-wrap:anywhere}
|
||||
.sched-editor .dialog-actions{flex-wrap:wrap}
|
||||
.login-chip,.sched-chip{width:100%;overflow-wrap:anywhere}
|
||||
.message:has(>.sched-chip),.message:has(>.login-chip){flex-direction:column;gap:8px}
|
||||
.login-chip>button{justify-self:start}
|
||||
.message:has(>.sched-chip),.message:has(>.login-chip){flex-direction:column;gap:8px;width:100%}
|
||||
.message.spoken>.sched-chip,.message.spoken>.login-chip{grid-column:2;max-width:100%;min-width:0}
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function TaskStatus({tasks,onTakeover}:{tasks:TaskSnapshot[];onTakeover:(
|
|||
const state=taskState(task,Date.now());const report=task.report;
|
||||
return <div className="task-status" key={task.runId}>
|
||||
<div className="task-status-heading" role="status"><i className={`task-dot ${state}`} aria-hidden="true"/><strong>{t(state)}</strong><span className="task-summary">{report?.summary}</span><RunProbe runId={task.runId} align="end" label={t("taskDetails")}><span className="task-probe" tabIndex={0}>{t("taskDetails")}</span></RunProbe></div>
|
||||
{task.status==="waiting_takeover"&&<button className="outline" onClick={onTakeover}>{t("loginOpenScreen")}</button>}
|
||||
{task.status==="waiting_takeover"&&<button className="primary" onClick={onTakeover}>{t("takeOverNow")}</button>}
|
||||
{task.status==="waiting_input"&&report?.request&&<p>{report.request}</p>}
|
||||
{report&&Boolean(report.remaining?.length||report.completed?.length)&&<details><summary>{t("taskDetails")}</summary>{report.completed?.length>0&&<p>{t("taskCompleted")}:{report.completed.join(";")}</p>}{report.remaining?.length>0&&<p>{t("taskFailed")}:{report.remaining.join(";")}</p>}{report.attempts?.map((attempt,i)=><p key={i}>{attempt}</p>)}{report.verification&&<p>{report.verification}</p>}</details>}
|
||||
</div>;
|
||||
|
|
|
|||
|
|
@ -311,7 +311,12 @@ pub async fn ensure_bot_screen(
|
|||
}
|
||||
};
|
||||
tx.commit().await.map_err(|e| e.to_string())?;
|
||||
crate::gui_publications::publish_screen(state, actor, computer, &row).await?;
|
||||
// A stale GUI fence must not block starting the desktop. Extra Team bots
|
||||
// otherwise get a screen row and no X server, so they cannot connect.
|
||||
if let Err(error) = crate::gui_publications::publish_screen(state, actor, computer, &row).await
|
||||
{
|
||||
tracing::warn!("GUI publication for {} deferred: {error}", row.display);
|
||||
}
|
||||
let mut ctx = adapter_context_for(actor, bot_id, "screen", Some(&row), run_id);
|
||||
ctx.computer_generation = Some(computer.generation);
|
||||
let bot = state
|
||||
|
|
@ -411,7 +416,14 @@ async fn restore_one_screen(
|
|||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or("screen bot is unavailable")?;
|
||||
crate::gui_publications::publish_screen(state, actor, computer, screen).await?;
|
||||
if let Err(error) =
|
||||
crate::gui_publications::publish_screen(state, actor, computer, screen).await
|
||||
{
|
||||
tracing::warn!(
|
||||
"GUI publication restore for {} deferred: {error}",
|
||||
screen.display
|
||||
);
|
||||
}
|
||||
let mut ctx = adapter_context_for(actor, &screen.bot_id, "screen-restore", Some(screen), None);
|
||||
ctx.computer_generation = Some(computer.generation);
|
||||
let result = state
|
||||
|
|
@ -653,6 +665,25 @@ pub async fn take_profile_lock(
|
|||
))
|
||||
}
|
||||
|
||||
async fn attached_screen(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
bot_id: &str,
|
||||
computer: &ComputerRow,
|
||||
need_gui: bool,
|
||||
) -> Result<Option<ScreenRow>, String> {
|
||||
if !attach_display_for_tool(need_gui) {
|
||||
return Ok(None);
|
||||
}
|
||||
let bound = ensure_bot_screen(state, actor, bot_id, computer, None).await?;
|
||||
match bound.row {
|
||||
Some(row) => Ok(Some(row)),
|
||||
None => Err(bound
|
||||
.gui_block
|
||||
.unwrap_or_else(|| "screen unavailable".into())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn boot(state: &AppState, actor: &Actor, bot_id: &str) -> Result<ComputerStatus, String> {
|
||||
boot_for(state, actor, bot_id, true).await
|
||||
}
|
||||
|
|
@ -708,14 +739,7 @@ pub async fn boot_for(
|
|||
return Err("Computer health could not be verified; existing provider retained".into());
|
||||
}
|
||||
if probe == ContainerProbe::Alive {
|
||||
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
|
||||
};
|
||||
let screen = attached_screen(state, actor, bot_id, &computer, need_gui).await?;
|
||||
if attach_display_for_tool(need_gui) {
|
||||
restore_computer_screens(state, actor, &computer, bot_id).await;
|
||||
}
|
||||
|
|
@ -846,14 +870,7 @@ pub async fn boot_for(
|
|||
.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
|
||||
};
|
||||
let screen = attached_screen(state, actor, bot_id, &computer, need_gui).await?;
|
||||
if attach_display_for_tool(need_gui) {
|
||||
restore_computer_screens(state, actor, &computer, bot_id).await;
|
||||
}
|
||||
|
|
@ -942,14 +959,7 @@ async fn resume_paused(
|
|||
.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
|
||||
};
|
||||
let screen = attached_screen(state, actor, bot_id, ¤t, need_gui).await?;
|
||||
return Ok(status_from(bot_id, ¤t, screen.as_ref(), None));
|
||||
}
|
||||
return Err("computer resume was superseded".into());
|
||||
|
|
@ -960,14 +970,7 @@ async fn resume_paused(
|
|||
.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
|
||||
};
|
||||
let screen = attached_screen(state, actor, bot_id, &computer, need_gui).await?;
|
||||
if attach_display_for_tool(need_gui) {
|
||||
restore_computer_screens(state, actor, &computer, bot_id).await;
|
||||
}
|
||||
|
|
@ -1141,12 +1144,16 @@ pub async fn takeover(
|
|||
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 screen = match attached_screen(state, actor, bot_id, &computer, true).await {
|
||||
Ok(Some(row)) => row,
|
||||
Ok(None) => return Err("screen unavailable".into()),
|
||||
Err(error) => state
|
||||
.db
|
||||
.get_screen(&computer.id, bot_id)
|
||||
.await
|
||||
.map_err(|error| error.to_string())?
|
||||
.ok_or(error)?,
|
||||
};
|
||||
let active = state
|
||||
.db
|
||||
.active_run(bot_id)
|
||||
|
|
@ -2482,6 +2489,76 @@ mod idle_job_tests {
|
|||
server.abort();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
async fn screen_starts_when_gui_publication_is_deferred(pool: sqlx::PgPool) {
|
||||
use axum::{Json, Router, http::StatusCode, routing::post};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
for statement in [
|
||||
"INSERT INTO users(id,name) VALUES ('u','test')",
|
||||
"INSERT INTO spaces(id,user_id,name) VALUES ('s','u','test')",
|
||||
"INSERT INTO computers(id,space_id,user_id,scope,scope_key,home_key,state,provider_ref) VALUES ('c','s','u','team','team:s','home','running','provider')",
|
||||
"INSERT INTO bots(id,space_id,user_id,name,computer_id) VALUES ('a','s','u','alpha','c'),('b','s','u','beta','c')",
|
||||
] {
|
||||
sqlx::query(statement).execute(&pool).await.unwrap();
|
||||
}
|
||||
let screens = Arc::new(AtomicUsize::new(0));
|
||||
let seen = screens.clone();
|
||||
let router = Router::new()
|
||||
.route(
|
||||
"/computers/provider/screens",
|
||||
post(move |Json(request): Json<EnsureScreenRequest>| {
|
||||
let seen = seen.clone();
|
||||
async move {
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
Json(json!({
|
||||
"slot": request.slot,
|
||||
"display": format!(":{}", request.slot + 1),
|
||||
"viewPort": 6080 + request.slot
|
||||
}))
|
||||
}
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/computers/provider/gui/publish",
|
||||
post(|| async { StatusCode::BAD_GATEWAY }),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut state = app(pool.clone());
|
||||
state.sandbox = Arc::new(lazyboy_sandbox::DockerSandbox::new(
|
||||
format!("http://{}", listener.local_addr().unwrap()),
|
||||
"fixture".into(),
|
||||
));
|
||||
let server = tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
let actor = Actor {
|
||||
user_id: "u".into(),
|
||||
space_id: "s".into(),
|
||||
};
|
||||
let computer = state.db.get_computer("c").await.unwrap().unwrap();
|
||||
let first = ensure_bot_screen(&state, &actor, "a", &computer, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
first.row.is_some(),
|
||||
"publication failure must not skip desktop start"
|
||||
);
|
||||
assert_eq!(first.row.as_ref().unwrap().slot, 0);
|
||||
let second = ensure_bot_screen(&state, &actor, "b", &computer, None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
second.row.unwrap().slot,
|
||||
1,
|
||||
"peer bot must get its own screen"
|
||||
);
|
||||
assert_eq!(screens.load(Ordering::SeqCst), 2);
|
||||
server.abort();
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../../migrations")]
|
||||
async fn abandoned_boot_expires_without_retiring_jobs_and_fences_late_replies(
|
||||
pool: sqlx::PgPool,
|
||||
|
|
|
|||
|
|
@ -51,13 +51,15 @@ pub async fn publish_screen(
|
|||
let now = Utc::now().timestamp_millis();
|
||||
let paused = agent_paused || screen.control_holder == "user";
|
||||
let mut expires = now + 120_000;
|
||||
if !paused && screen.execution_run_id.is_some() {
|
||||
expires = expires.min(
|
||||
screen
|
||||
.execution_lease_expires_at
|
||||
.ok_or("GUI execution lease missing")?
|
||||
.timestamp_millis(),
|
||||
);
|
||||
// An expired execution lease must not make the whole desktop unpublished:
|
||||
// other bots still need their screens, and takeover still needs a live view.
|
||||
if !paused {
|
||||
if let Some(lease_expires) = screen.execution_lease_expires_at {
|
||||
let lease_ms = lease_expires.timestamp_millis();
|
||||
if lease_ms > now {
|
||||
expires = expires.min(lease_ms);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut request = GuiPublicationRequest {
|
||||
home: captured.home_key.clone(),
|
||||
|
|
@ -152,9 +154,10 @@ async fn deliver(
|
|||
|| request.paused != (paused || screen.control_holder == "user")
|
||||
|| (!request.paused
|
||||
&& screen.execution_run_id.is_some()
|
||||
&& screen
|
||||
.execution_lease_expires_at
|
||||
.is_none_or(|e| request.lease_expires_at_ms > e.timestamp_millis()))
|
||||
&& screen.execution_lease_expires_at.is_some_and(|expires| {
|
||||
let lease_ms = expires.timestamp_millis();
|
||||
lease_ms > Utc::now().timestamp_millis() && request.lease_expires_at_ms > lease_ms
|
||||
}))
|
||||
{
|
||||
return Err("GUI publication no longer authorized".into());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -417,7 +417,7 @@ async fn room_status(
|
|||
JOIN bots b ON b.id=r.bot_id
|
||||
JOIN threads t ON t.id=r.thread_id
|
||||
WHERE t.room_id=$1 AND t.space_id=$2 AND t.user_id=$3
|
||||
AND r.status IN ('queued','leased','running')
|
||||
AND r.status IN ('queued','leased','running','waiting_input','waiting_takeover')
|
||||
ORDER BY b.name",
|
||||
)
|
||||
.bind(&id)
|
||||
|
|
|
|||
|
|
@ -835,15 +835,18 @@ test('taking the screen flips the mouse on the click, not on the reply',()=>{
|
|||
assert.equal((app.match(/holder==="user"\?"takeover":"release"/g)||[]).length,1,'one request path, one owner of it');
|
||||
});
|
||||
|
||||
test('shared CUA desktops hide takeover and keep the mouse live',()=>{
|
||||
test('shared CUA desktops keep the mouse live and still show takeover',()=>{
|
||||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||
assert.match(app,/sharedInput:true/,'the first paint already assumes shared input');
|
||||
assert.match(app,/if\(!computer\.sharedInput\)await setControl\("user",id\)/,'opening the login screen must not pause a shared run');
|
||||
assert.match(app,/if\(active&&!computer\.sharedInput\)void setControl\("user",active\.id\)/,'a call only takes exclusive control on older exclusive desktops');
|
||||
assert.match(app,/computer\.sharedInput\?\(computer\.takeoverRequested\|\|computer\.controlHolder==="user"\?<button className="primary" disabled=\{busy\|\|pending\} onClick=\{onRelease\}>\{t\("doneContinue"\)\}<\/button>:null\)/);
|
||||
assert.match(app,/await setControl\("user",id\)/,'opening the login screen pauses so the human can type');
|
||||
assert.doesNotMatch(app,/if\(!computer\.sharedInput\)await setControl\("user",id\)/,'shared desktops must not hide the pause');
|
||||
assert.match(app,/onTakeOver=\{\(\)=>void openLoginScreen\(\)\}/);
|
||||
assert.match(app,/computer\.takeoverRequested\|\|computer\.controlHolder==="user"\?<button className="primary" disabled=\{busy\|\|pending\} onClick=\{onRelease\}>\{computer\.sharedInput\?t\("doneContinue"\):t\("releaseControl"\)\}<\/button>/);
|
||||
assert.match(app,/<button className="primary" disabled=\{busy\|\|pending\} onClick=\{onTakeOver\}>\{working\|\|computer\.sharedInput\?t\("takeOverNow"\):t\("takeControl"\)\}<\/button>/);
|
||||
assert.match(app,/computer\.sharedInput\?t\("sharedDesktop"\)/);
|
||||
assert.match(app,/computer\.sharedInput\?t\("sharedNeedsUser"/);
|
||||
assert.match(app,/takeover=\{computer\.takeoverRequested\|\|\(!computer\.sharedInput&&computer\.controlHolder==="user"\)\}/);
|
||||
assert.match(app,/\{t\("takeOverNow"\)\}/);
|
||||
assert.match(app,/takeover=\{computer\.takeoverRequested\|\|computer\.controlHolder==="user"\}/);
|
||||
const vnc=fs.readFileSync('apps/web/vnc.html','utf8');
|
||||
assert.match(vnc,/rfb\.viewOnly = true;/,'a dropped RFB is muted locally');
|
||||
assert.doesNotMatch(vnc,/disconnect[\s\S]{0,400}applyViewOnly\(true\)/,'disconnect must not overwrite host intent');
|
||||
|
|
@ -1138,6 +1141,8 @@ test('task presence expires from server clock without inventing progress',()=>{
|
|||
assert.equal(taskState({...base,status:'completed',report:{state:'complete'}},2000),'taskCompleted');
|
||||
assert.equal(taskState({...base,status:'queued',aliveUntil:null},1000),'taskQueued');
|
||||
assert.equal(taskState({...base,status:'waiting_takeover'},999999),'taskWaiting');
|
||||
const taskSource=fs.readFileSync('apps/web/src/task-status.tsx','utf8');
|
||||
assert.match(taskSource,/waiting_takeover.*takeOverNow/s,'the waiting task exposes Take over, not a hidden screen link');
|
||||
assert.equal(taskState({...base,status:'failed',report:{state:'complete'}},2000),'taskFailed');
|
||||
assert.equal(taskState({...base,report:{state:'recovering'}},2000),'taskRecovering');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue