From 08cbf93f7ba698db3b0225fa9380a95a1d4a8972 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 29 Sep 2026 06:44:27 +0900 Subject: [PATCH 1/5] fix: reject unavailable retained sessions --- crates/tui/src/backend_worker_picker.rs | 45 ++++++++++++------------- 1 file changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index df62a759b..4472ea407 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -153,7 +153,7 @@ async fn prepare_selected_worker( } let observation = lifecycle.observe(&target).await?; - apply_worker_session_observation(&mut target, observation.observation); + apply_worker_session_observation(&mut target, observation.observation)?; Ok(target) } @@ -217,21 +217,19 @@ enum WorkerPickerResult { fn apply_worker_session_observation( target: &mut client::BackendRuntimeTarget, observation: WorkerSessionAvailability, -) { +) -> Result<(), io::Error> { match observation { - WorkerSessionAvailability::LiveProtocol => {} + WorkerSessionAvailability::LiveProtocol => Ok(()), WorkerSessionAvailability::RetainedSnapshot { snapshot, .. } => { target.initial_snapshot = Some(snapshot); target.initial_notice = Some("read-only retained Session snapshot".to_string()); + Ok(()) } - WorkerSessionAvailability::Unavailable { message, .. } => { - let bounded_message: String = message.chars().take(512).collect(); - target.initial_snapshot = Some(protocol::SessionSnapshot { - pending_submissions: protocol::PendingSubmissionsSnapshot::default(), - entries: Vec::new(), - }); - target.initial_notice = - Some(format!("retained Session unavailable: {bounded_message}")); + WorkerSessionAvailability::Unavailable { reason, message } => { + let message = message.chars().take(512).collect::(); + Err(io::Error::other(format!( + "Backend Worker Session is unavailable: {reason:?}: {message}" + ))) } } } @@ -770,14 +768,15 @@ mod tests { } #[test] - fn authoritative_observation_selects_live_retained_and_unavailable_detail_modes() { + fn authoritative_observation_opens_only_live_or_retained_sessions() { let mut live = client::BackendRuntimeTarget::new( "http://127.0.0.1:3000", "workspace-a", "runtime-a", "worker-a", ); - apply_worker_session_observation(&mut live, WorkerSessionAvailability::LiveProtocol); + apply_worker_session_observation(&mut live, WorkerSessionAvailability::LiveProtocol) + .unwrap(); assert!(live.initial_snapshot.is_none()); let snapshot = protocol::SessionSnapshot { @@ -795,25 +794,25 @@ mod tests { }, snapshot: snapshot.clone(), }, - ); + ) + .unwrap(); assert_eq!(retained.initial_snapshot, Some(snapshot)); assert!(retained.initial_notice.unwrap().contains("read-only")); let mut unavailable = live; - apply_worker_session_observation( + let error = apply_worker_session_observation( &mut unavailable, WorkerSessionAvailability::Unavailable { reason: client::WorkerSessionUnavailableReason::RetentionMissing, message: "retention expired".to_string(), }, - ); - assert!(unavailable.initial_snapshot.is_some()); - assert!( - unavailable - .initial_notice - .unwrap() - .contains("retention expired") - ); + ) + .unwrap_err() + .to_string(); + assert!(unavailable.initial_snapshot.is_none()); + assert!(unavailable.initial_notice.is_none()); + assert!(error.contains("RetentionMissing")); + assert!(error.contains("retention expired")); } #[test] From c979883463210e814dc53983ace50ce4cb55239a Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 29 Sep 2026 06:51:09 +0900 Subject: [PATCH 2/5] test: align merge request review fixtures --- .../src/lib/workspace/api/ticket-browser.test.ts | 5 +++++ web/workspace/test/merge-request-status.test.ts | 8 ++++++++ 2 files changed, 13 insertions(+) diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts index aa6ef8279..7b6c52d73 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -117,6 +117,7 @@ Deno.test("Ticket Browser parser accepts target collections and rejects singular item_revision: "revision-1", linked_objectives: [], merge_request: null, + merge_requests: [], priority: "P2", queued_at: null, queued_by: null, @@ -218,6 +219,10 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread", event_id: "event-1", sequence: 1, subject_ref: "abc123", + ticket_item_revision: "ticket-revision", + ticket_merge_request_subjects: [ + { merge_request_id: "mr-1", subject_ref: "abc123" }, + ], requested_by: { runtime_id: "runtime-a", worker_id: "worker-a" }, reviewer: { runtime_id: "runtime-a", worker_id: "reviewer-a" }, created_at: "2026-09-22T00:00:00Z", diff --git a/web/workspace/test/merge-request-status.test.ts b/web/workspace/test/merge-request-status.test.ts index 60307864e..d92f76940 100644 --- a/web/workspace/test/merge-request-status.test.ts +++ b/web/workspace/test/merge-request-status.test.ts @@ -40,6 +40,10 @@ function reviewRequested( event_id: eventId, sequence: 1, subject_ref: subjectRef, + ticket_item_revision: "ticket-revision", + ticket_merge_request_subjects: [ + { merge_request_id: "MR-1", subject_ref: subjectRef }, + ], requested_by: worker, reviewer: worker, created_at: createdAt, @@ -57,6 +61,10 @@ function review( sequence: 1, request_event_id: requestEventId, subject_ref: subjectRef, + ticket_item_revision: "ticket-revision", + ticket_merge_request_subjects: [ + { merge_request_id: "MR-1", subject_ref: subjectRef }, + ], decision: "approve", body: "approved", findings: [], From 09f8cfb7187baa4207dd77201be1e7b51082dd49 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 29 Sep 2026 07:36:03 +0900 Subject: [PATCH 3/5] fix: reject unresolved worker console targets --- .../console/worker-console.ui.test.ts | 15 +++-- .../lib/workspace/session-observation.test.ts | 25 +++++++++ .../src/lib/workspace/session-observation.ts | 24 +++++++- .../workers/[workerRef]/console/+page.svelte | 56 +++++++++++++------ .../workers/[workerRef]/console/+page.ts | 8 ++- .../console/console-display.browser.spec.ts | 23 ++++++++ 6 files changed, 124 insertions(+), 27 deletions(-) diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 1200041a7..245b47400 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -972,9 +972,14 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime routeLoad.includes("resourceKey(params.workerRef)") && routeLoad.includes("/workers/${encodeURIComponent(reference)}") && routeLoad.includes("canonicalResourceReference") && - routeLoad.includes("runtimeId: result.data?.runtime_id") && - routeLoad.includes("workerId: result.data?.worker_id"), - "route load should resolve the logical Worker reference before exposing its execution target", + routeLoad.includes("runtimeId: result.data?.runtime_id ?? null") && + routeLoad.includes("workerId: result.data?.worker_id ?? null") && + !routeLoad.includes('result.data?.runtime_id ?? ""') && + consolePage.includes( + "resolveWorkerSessionTarget(workspaceId, runtimeId, workerId)", + ) && + consolePage.includes("if (!target) return;"), + "route load should retain an unresolved Worker target as null and the Console must fail closed before Runtime APIs", ); assert( consolePage.includes("workspaceApiPath(workspaceId, path)") && @@ -1228,9 +1233,9 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection", consolePage.includes("const targetWorker = data.worker") && consolePage.includes("worker = targetWorker") && consolePage.includes( - "const consoleTarget = $derived({ workspaceId, runtimeId, workerId })", + "resolveWorkerSessionTarget(workspaceId, runtimeId, workerId)", ), - "A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker", + "A reused Console route should subscribe immediately only after resolving the new route Worker execution target", ); }); diff --git a/web/workspace/src/lib/workspace/session-observation.test.ts b/web/workspace/src/lib/workspace/session-observation.test.ts index 51e31cf02..27a7a2171 100644 --- a/web/workspace/src/lib/workspace/session-observation.test.ts +++ b/web/workspace/src/lib/workspace/session-observation.test.ts @@ -1,5 +1,6 @@ import { isCurrentWorkerSessionRequest, + resolveWorkerSessionTarget, workerSessionAction, type WorkerSessionRequestIdentity, workerSessionRequestInit, @@ -24,6 +25,30 @@ const snapshot: SessionSnapshot = { entries: [], }; +Deno.test("worker session target requires every routing identity", () => { + const target = resolveWorkerSessionTarget( + "workspace-a", + "runtime-a", + "worker-a", + ); + assert( + target?.runtimeId === "runtime-a" && target.workerId === "worker-a", + "resolved identities must produce an API target", + ); + for ( + const unresolved of [ + resolveWorkerSessionTarget("", "runtime-a", "worker-a"), + resolveWorkerSessionTarget("workspace-a", "", "worker-a"), + resolveWorkerSessionTarget("workspace-a", "runtime-a", ""), + resolveWorkerSessionTarget("workspace-a", null, "worker-a"), + resolveWorkerSessionTarget("workspace-a", "runtime-a", undefined), + resolveWorkerSessionTarget("workspace-a", " ", "worker-a"), + ] + ) { + assert(unresolved === null, "incomplete identities must fail closed"); + } +}); + Deno.test("worker session request includes same-origin credentials", () => { const controller = new AbortController(); const init = workerSessionRequestInit(controller.signal); diff --git a/web/workspace/src/lib/workspace/session-observation.ts b/web/workspace/src/lib/workspace/session-observation.ts index 9c5894507..861b0e5d7 100644 --- a/web/workspace/src/lib/workspace/session-observation.ts +++ b/web/workspace/src/lib/workspace/session-observation.ts @@ -10,13 +10,33 @@ export type WorkerSessionAction = | { kind: "apply_retained"; snapshot: SessionSnapshot } | { kind: "show_unavailable"; message: string }; -export interface WorkerSessionRequestIdentity { - token: number; +export interface WorkerSessionTarget { workspaceId: string; runtimeId: string; workerId: string; } +export interface WorkerSessionRequestIdentity extends WorkerSessionTarget { + token: number; +} + +export function resolveWorkerSessionTarget( + workspaceId: string, + runtimeId: string | null | undefined, + workerId: string | null | undefined, +): WorkerSessionTarget | null { + if ( + workspaceId.trim().length === 0 || + runtimeId?.trim().length === 0 || + workerId?.trim().length === 0 || + runtimeId == null || + workerId == null + ) { + return null; + } + return { workspaceId, runtimeId, workerId }; +} + export function workerSessionRequestInit(signal: AbortSignal): RequestInit { return { signal, diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 2b5abd0ac..af1b0eb5c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -1,4 +1,5 @@