fix session

This commit is contained in:
王性驊 2026-09-13 15:16:47 +08:00
parent b60d4c91ae
commit d71c9900de
12 changed files with 161 additions and 74 deletions

File diff suppressed because one or more lines are too long

View File

@ -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>

View File

@ -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}

View File

@ -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",

View File

@ -27,7 +27,7 @@ export const zhTW = {
results: "成果",
advancedSettings: "進階設定",
sharedDesktop: "共同操作",
sharedNeedsUser: "{name} 已暫停等你完成畫面上的步驟。完成後按「完成,繼續」。",
sharedNeedsUser: "{name} 已暫停等你完成畫面上的步驟。按「接手操作」打開畫面,完成後按「完成,繼續」。",
doneContinue: "完成,繼續",
search: "搜尋", sharedComputer: "共用電腦", privateComputer: "私人電腦",
stopped: "已關閉", booting: "啟動中", suspended: "休眠中", error: "發生錯誤",

View File

@ -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}

View File

@ -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}

View File

@ -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>;

View File

@ -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, &current, None)
.await
.ok()
.and_then(|bound| bound.row)
} else {
None
};
let screen = attached_screen(state, actor, bot_id, &current, need_gui).await?;
return Ok(status_from(bot_id, &current, 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,

View File

@ -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());
}

View File

@ -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)

View File

@ -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');
});