feat: guarded Worker Restore from shared Web actions (T-701)

This commit is contained in:
2026-10-06 17:45:54 +09:00
parent 1a6d391f83
commit fdce12a47b
54 changed files with 7214 additions and 527 deletions
+62 -19
View File
@@ -29,6 +29,7 @@ trait BackendWorkerLifecycle {
async fn restore(
&self,
target: &client::BackendRuntimeTarget,
request: client::BackendWorkerRestoreRequest,
) -> Result<BackendWorkerRestoreResponse, io::Error>;
async fn observe(
@@ -44,13 +45,19 @@ impl BackendWorkerLifecycle for LiveBackendWorkerLifecycle {
async fn restore(
&self,
target: &client::BackendRuntimeTarget,
request: client::BackendWorkerRestoreRequest,
) -> Result<BackendWorkerRestoreResponse, io::Error> {
restore_backend_worker(target).await.map_err(|error| {
io::Error::other(format!(
"failed to restore Backend Worker {}: {error}",
target.display_label()
))
})
restore_backend_worker(target, request)
.await
.map_err(|error| {
if error.is_restore_observation_conflict() {
return io::Error::other("Restore observation changed; refresh the Worker list and select again. No new Restore was started.");
}
io::Error::other(format!(
"failed to restore Backend Worker {}: {error}",
target.display_label()
))
})
}
async fn observe(
@@ -133,8 +140,20 @@ pub(crate) async fn run(
let attach_target = target
.runtime_target(selected.runtime_id.clone(), selected.worker_id.clone())
.map_err(|error| io::Error::other(error.to_string()))?;
let attach_target =
prepare_selected_worker(&LiveBackendWorkerLifecycle, attach_target, intent).await?;
let restore_request =
selected
.restore_observation_token
.map(|token| client::BackendWorkerRestoreRequest {
expected_observation_token: token,
request_id: uuid::Uuid::now_v7().to_string(),
});
let attach_target = prepare_selected_worker(
&LiveBackendWorkerLifecycle,
attach_target,
intent,
restore_request,
)
.await?;
return console::run_backend_runtime(attach_target).await;
}
}
@@ -143,9 +162,13 @@ async fn prepare_selected_worker(
lifecycle: &impl BackendWorkerLifecycle,
mut target: client::BackendRuntimeTarget,
intent: BackendWorkerPickerIntent,
restore_request: Option<client::BackendWorkerRestoreRequest>,
) -> Result<client::BackendRuntimeTarget, io::Error> {
if intent == BackendWorkerPickerIntent::Resume {
let response = lifecycle.restore(&target).await?;
let request = restore_request.ok_or_else(|| {
io::Error::other("Worker has no current Restore observation; refresh and select again")
})?;
let response = lifecycle.restore(&target, request).await?;
ensure_restore_accepted(&response)?;
let observation = lifecycle.observe(&target).await?;
require_live_resume_observation(observation.observation)?;
@@ -158,7 +181,10 @@ async fn prepare_selected_worker(
}
fn is_resume_candidate(worker: &BackendWorkerSummary) -> bool {
worker.worker_state.is_none()
worker.availability == protocol::subscription::SubscriptionWorkerAvailability::Observed
&& worker.state == "stopped"
&& worker.worker_state.is_none()
&& worker.restore_observation_token.is_some()
}
fn ensure_restore_accepted(response: &BackendWorkerRestoreResponse) -> Result<(), io::Error> {
@@ -570,6 +596,7 @@ mod tests {
fn worker(runtime_id: &str, worker_id: &str, profile: Option<&str>) -> BackendWorkerSummary {
BackendWorkerSummary {
restore_observation_token: Some("observed-worker-generation".into()),
runtime_id: runtime_id.to_string(),
worker_id: worker_id.to_string(),
resource_key: "W-1".to_string(),
@@ -642,6 +669,7 @@ mod tests {
async fn restore(
&self,
_target: &client::BackendRuntimeTarget,
_request: client::BackendWorkerRestoreRequest,
) -> Result<BackendWorkerRestoreResponse, io::Error> {
self.calls.lock().unwrap().push("restore");
Ok(self.restore_response.clone())
@@ -689,10 +717,17 @@ mod tests {
"worker-a",
);
let prepared =
prepare_selected_worker(&lifecycle, target, BackendWorkerPickerIntent::Resume)
.await
.unwrap();
let prepared = prepare_selected_worker(
&lifecycle,
target,
BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(),
request_id: "resume-operation".into(),
}),
)
.await
.unwrap();
assert_eq!(*lifecycle.calls.lock().unwrap(), ["restore", "observe"]);
assert!(prepared.initial_snapshot.is_none());
@@ -716,10 +751,18 @@ mod tests {
"worker-a",
);
let error = prepare_selected_worker(&lifecycle, target, BackendWorkerPickerIntent::Resume)
.await
.unwrap_err()
.to_string();
let error = prepare_selected_worker(
&lifecycle,
target,
BackendWorkerPickerIntent::Resume,
Some(client::BackendWorkerRestoreRequest {
expected_observation_token: "observed-worker-generation".into(),
request_id: "resume-operation".into(),
}),
)
.await
.unwrap_err()
.to_string();
assert!(error.contains("Rejected"));
assert_eq!(*lifecycle.calls.lock().unwrap(), ["restore"]);
@@ -736,7 +779,7 @@ mod tests {
assert!(!is_resume_candidate(&live));
assert!(is_resume_candidate(&stopped));
assert!(is_resume_candidate(&unknown));
assert!(!is_resume_candidate(&unknown));
}
#[test]