diff --git a/.yoi/tickets/00001KX7838M2/item.md b/.yoi/tickets/00001KX7838M2/item.md index b0e04999..bcb0a3c0 100644 --- a/.yoi/tickets/00001KX7838M2/item.md +++ b/.yoi/tickets/00001KX7838M2/item.md @@ -1,8 +1,8 @@ --- title: 'Remove worker observation WebSocket cursor surface' -state: 'inprogress' +state: 'done' created_at: '2026-07-11T00:09:06Z' -updated_at: '2026-07-11T00:09:33Z' +updated_at: '2026-07-11T00:31:41Z' assignee: null queued_by: 'yoi ticket' queued_at: '2026-07-11T00:09:32Z' diff --git a/.yoi/tickets/00001KX7838M2/thread.md b/.yoi/tickets/00001KX7838M2/thread.md index 5308e9ea..13f2d1f9 100644 --- a/.yoi/tickets/00001KX7838M2/thread.md +++ b/.yoi/tickets/00001KX7838M2/thread.md @@ -39,4 +39,37 @@ Ticket を `yoi ticket` が queued にしました。 State changed to `inprogress`. +--- + + + +## Implementation report + +Removed the backend-local worker observation cursor surface. + +- Browser-facing worker observation envelopes no longer include `cursor`; they carry runtime/worker identity, runtime event id, and payload. +- Workspace-server observation proxy no longer owns cursor state, cursor validation, replay state, or store/open methods; it only maps upstream Runtime events to browser envelopes. +- Workspace-server WS attach ignores no cursor path because no cursor query is accepted/extracted. +- Backend Runtime client no longer sends cursor query params or deduplicates by backend cursor. +- Frontend Console no longer stores cursor/lastCursor and projects events by runtime event id. + +Validation: +- cargo test -q -p yoi-workspace-server +- cargo test -q -p client +- cd web/workspace && deno task check +- cd web/workspace && deno task test +- cargo test -q +- git diff --check +- nix build .#yoi --no-link + + +--- + + + +## State changed + +State changed to `done`. + + --- diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index ce06125b..4e85e433 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -270,12 +270,10 @@ fn backend_command_from_method(method: &Method) -> BackendCommand { } async fn observe_worker_events(target: BackendRuntimeTarget, tx: mpsc::UnboundedSender) { - let mut cursor: Option = None; - let mut last_sequence = 0_u64; let mut attempts = 0_usize; loop { - let url = observation_ws_url(&target, cursor.as_deref()); + let url = observation_ws_url(&target); match connect_async(&url).await { Ok((mut ws, _)) => { attempts = 0; @@ -295,19 +293,6 @@ async fn observe_worker_events(target: BackendRuntimeTarget, tx: mpsc::Unbounded ))); continue; } - if let Some(sequence) = decode_backend_cursor(&envelope.cursor) - { - if sequence <= last_sequence { - continue; - } - last_sequence = sequence; - } else { - let _ = tx.send(diagnostic_event(format!( - "Backend observation cursor was malformed: {}", - envelope.cursor - ))); - } - cursor = Some(envelope.cursor.clone()); let _ = tx.send(envelope.payload); } Ok(ClientWorkerEventWsFrame::Diagnostic { diagnostic }) => { @@ -316,11 +301,6 @@ async fn observe_worker_events(target: BackendRuntimeTarget, tx: mpsc::Unbounded diagnostic.code, diagnostic.message ); let _ = tx.send(diagnostic_event(message)); - if diagnostic.code == "backend.cursor_unknown_or_expired" { - cursor = None; - last_sequence = 0; - break; - } } Err(error) => { let _ = tx.send(diagnostic_event(format!( @@ -395,18 +375,13 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl Ok(()) } -fn observation_ws_url(target: &BackendRuntimeTarget, cursor: Option<&str>) -> String { +fn observation_ws_url(target: &BackendRuntimeTarget) -> String { let path = format!( "/api/runtimes/{}/workers/{}/events/ws", path_segment_encode(&target.runtime_id), path_segment_encode(&target.worker_id) ); - let mut url = join_base_and_path(&http_base_to_ws(&target.base_url), &path); - if let Some(cursor) = cursor { - url.push_str("?cursor="); - url.push_str(&query_value_encode(cursor)); - } - url + join_base_and_path(&http_base_to_ws(&target.base_url), &path) } fn http_base_to_ws(base: &str) -> String { @@ -423,26 +398,12 @@ fn join_base_and_path(base: &str, path: &str) -> String { format!("{}{}", base.trim_end_matches('/'), path) } -fn decode_backend_cursor(cursor: &str) -> Option { - let encoded = cursor.strip_prefix("bo_")?; - if encoded.len() != 16 { - return None; - } - u64::from_str_radix(encoded, 16).ok() -} - fn path_segment_encode(input: &str) -> String { percent_encode(input, |byte| { byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') }) } -fn query_value_encode(input: &str) -> String { - percent_encode(input, |byte| { - byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') - }) -} - fn percent_encode(input: &str, keep: impl Fn(u8) -> bool) -> String { let mut encoded = String::with_capacity(input.len()); for byte in input.bytes() { @@ -507,7 +468,6 @@ enum ClientWorkerEventWsFrame { #[derive(Debug, Deserialize)] struct ClientWorkerEventWsEnvelope { - cursor: String, runtime_id: String, worker_id: String, payload: Event, @@ -547,8 +507,8 @@ mod tests { let target = BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one"); assert_eq!( - observation_ws_url(&target, Some("bo_0000000000000001")), - "ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/events/ws?cursor=bo_0000000000000001" + observation_ws_url(&target), + "ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/events/ws" ); } } diff --git a/crates/workspace-server/src/observation.rs b/crates/workspace-server/src/observation.rs index 2d46eef3..1c3d5e2a 100644 --- a/crates/workspace-server/src/observation.rs +++ b/crates/workspace-server/src/observation.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, VecDeque}; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use worker_runtime::identity::WorkerRef; use worker_runtime::observation::{WorkerObservationCursor, WorkerObservationEvent}; @@ -100,7 +100,7 @@ impl std::fmt::Debug for RuntimeObservationSource { pub struct RuntimeObservationUpstreamEvent { pub runtime_id: String, pub worker_id: String, - pub runtime_cursor: String, + pub runtime_event_id: String, pub payload: protocol::Event, } @@ -116,11 +116,10 @@ pub enum ClientWorkerEventWsFrame { }, } -/// Backend-owned opaque event envelope. It intentionally omits Runtime endpoints, +/// Backend-owned event envelope. It intentionally omits Runtime endpoints, /// credentials, sockets and session paths. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ClientWorkerEventWsEnvelope { - pub cursor: String, pub event_id: String, pub runtime_id: String, pub worker_id: String, @@ -134,17 +133,10 @@ pub struct ClientWorkerEventWsDiagnostic { pub message: String, } -#[derive(Clone, Debug, Default, Deserialize)] -pub struct ClientWorkerEventsWsQuery { - pub cursor: Option, -} - #[derive(Clone, Debug, PartialEq, Eq)] pub enum ObservationProxyError { RuntimeUnavailable(String), WorkerNotFound(String), - CursorMalformed(String), - CursorUnknownOrExpired(String), UpstreamDisconnect(String), MalformedFrame(String), ObservationOnly, @@ -155,8 +147,6 @@ impl ObservationProxyError { match self { ObservationProxyError::RuntimeUnavailable(_) => "backend.runtime_unavailable", ObservationProxyError::WorkerNotFound(_) => "backend.worker_not_found", - ObservationProxyError::CursorMalformed(_) => "backend.cursor_malformed", - ObservationProxyError::CursorUnknownOrExpired(_) => "backend.cursor_unknown_or_expired", ObservationProxyError::UpstreamDisconnect(_) => "backend.upstream_disconnect", ObservationProxyError::MalformedFrame(_) => "backend.malformed_frame", ObservationProxyError::ObservationOnly => "backend.observation_only", @@ -167,8 +157,6 @@ impl ObservationProxyError { match self { ObservationProxyError::RuntimeUnavailable(message) | ObservationProxyError::WorkerNotFound(message) - | ObservationProxyError::CursorMalformed(message) - | ObservationProxyError::CursorUnknownOrExpired(message) | ObservationProxyError::UpstreamDisconnect(message) | ObservationProxyError::MalformedFrame(message) => message, ObservationProxyError::ObservationOnly => { @@ -193,46 +181,6 @@ impl ClientWorkerEventWsFrame { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct BackendObservationCursor { - pub sequence: u64, -} - -impl BackendObservationCursor { - pub fn new(sequence: u64) -> Self { - Self { sequence } - } - - pub fn zero() -> Self { - Self { sequence: 0 } - } - - pub fn encode(self) -> String { - format!("bo_{:016x}", self.sequence) - } - - pub fn decode(value: &str) -> Option { - let encoded = value.strip_prefix("bo_")?; - if encoded.len() != 16 { - return None; - } - u64::from_str_radix(encoded, 16) - .ok() - .map(|sequence| Self { sequence }) - } -} - -#[derive(Debug, Default)] -struct BackendObservationState { - next_sequence: u64, -} - -impl BackendObservationState { - fn new() -> Self { - Self { next_sequence: 1 } - } -} - #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] struct ObservationKey { runtime_id: String, @@ -243,14 +191,12 @@ struct ObservationKey { #[derive(Clone)] pub struct BackendObservationProxy { sources: Arc>, - state: Arc>, } impl std::fmt::Debug for BackendObservationProxy { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("BackendObservationProxy") .field("source_count", &self.sources.len()) - .field("state", &"") .finish() } } @@ -271,7 +217,6 @@ impl BackendObservationProxy { .collect(); Self { sources: Arc::new(sources), - state: Arc::new(Mutex::new(BackendObservationState::new())), } } @@ -294,41 +239,13 @@ impl BackendObservationProxy { }) } - pub fn open( - &self, - _runtime_id: &str, - _worker_id: &str, - cursor: Option<&str>, - ) -> Result<(), ObservationProxyError> { - if let Some(raw) = cursor { - BackendObservationCursor::decode(raw).ok_or_else(|| { - ObservationProxyError::CursorMalformed(format!( - "malformed backend observation cursor: {raw}" - )) - })?; - } - Ok(()) - } - - pub fn store( - &self, - event: RuntimeObservationUpstreamEvent, - ) -> Result { - let mut state = self.state.lock().map_err(|_| { - ObservationProxyError::RuntimeUnavailable( - "backend observation state lock poisoned".into(), - ) - })?; - let sequence = state.next_sequence; - state.next_sequence += 1; - let cursor = BackendObservationCursor::new(sequence).encode(); - Ok(ClientWorkerEventWsEnvelope { - cursor: cursor.clone(), - event_id: cursor, + pub fn map_event(&self, event: RuntimeObservationUpstreamEvent) -> ClientWorkerEventWsEnvelope { + ClientWorkerEventWsEnvelope { + event_id: event.runtime_event_id, runtime_id: event.runtime_id, worker_id: event.worker_id, payload: event.payload, - }) + } } } @@ -339,11 +256,6 @@ fn map_runtime_connect_error(error: TungsteniteError) -> ObservationProxyError { "runtime worker observation endpoint returned 404 not found".into(), ) } - TungsteniteError::Http(response) if response.status() == StatusCode::BAD_REQUEST => { - ObservationProxyError::CursorMalformed( - "runtime worker observation endpoint rejected the request as malformed".into(), - ) - } TungsteniteError::Http(response) => ObservationProxyError::RuntimeUnavailable(format!( "runtime worker observation endpoint rejected WebSocket upgrade with status {}", response.status() @@ -357,10 +269,9 @@ fn map_runtime_connect_error(error: TungsteniteError) -> ObservationProxyError { fn map_runtime_diagnostic(code: String, message: String) -> ObservationProxyError { match code.as_str() { "runtime.worker_not_found" => ObservationProxyError::WorkerNotFound(message), - "runtime.cursor_malformed" => ObservationProxyError::CursorMalformed(message), - "runtime.cursor_unknown_or_expired" | "runtime.cursor_expired" => { - ObservationProxyError::CursorUnknownOrExpired(message) - } + "runtime.cursor_malformed" + | "runtime.cursor_unknown_or_expired" + | "runtime.cursor_expired" => ObservationProxyError::RuntimeUnavailable(message), "runtime.unavailable" => ObservationProxyError::RuntimeUnavailable(message), "runtime.upstream_closed" | "runtime.websocket_error" => { ObservationProxyError::UpstreamDisconnect(message) @@ -384,15 +295,8 @@ pub struct RuntimeWsObservationClient { impl RuntimeWsObservationClient { pub async fn connect( source: &RuntimeObservationSourceConfig, - runtime_cursor: Option<&str>, ) -> Result { - let mut endpoint = source.endpoint.clone(); - if let Some(cursor) = runtime_cursor { - let separator = if endpoint.contains('?') { '&' } else { '?' }; - endpoint.push(separator); - endpoint.push_str("cursor="); - endpoint.push_str(cursor); - } + let endpoint = source.endpoint.clone(); let mut request = endpoint.into_client_request().map_err(|error| { ObservationProxyError::RuntimeUnavailable(format!( "failed to build runtime WebSocket request: {error}" @@ -481,7 +385,7 @@ impl RuntimeWsObservationClient { RuntimeObservationUpstreamEvent { runtime_id: self.runtime_id.clone(), worker_id: self.worker_id.clone(), - runtime_cursor: envelope.cursor, + runtime_event_id: envelope.event_id, payload: envelope.payload, } } @@ -493,18 +397,15 @@ pub enum RuntimeObservationClient { } impl RuntimeObservationClient { - pub async fn connect( - source: &RuntimeObservationSource, - runtime_cursor: Option<&str>, - ) -> Result { + pub async fn connect(source: &RuntimeObservationSource) -> Result { match source { RuntimeObservationSource::RemoteWs(config) => { - RuntimeWsObservationClient::connect(config, runtime_cursor) + RuntimeWsObservationClient::connect(config) .await .map(Self::RemoteWs) } RuntimeObservationSource::Embedded(source) => { - EmbeddedObservationClient::connect(source, runtime_cursor).map(Self::Embedded) + EmbeddedObservationClient::connect(source).map(Self::Embedded) } } } @@ -529,26 +430,16 @@ pub struct EmbeddedObservationClient { } impl EmbeddedObservationClient { - fn connect( - source: &EmbeddedRuntimeObservationSource, - runtime_cursor: Option<&str>, - ) -> Result { - let cursor = match runtime_cursor { - Some(raw) => WorkerObservationCursor::decode(raw).ok_or_else(|| { - ObservationProxyError::CursorMalformed( - "embedded runtime cursor is malformed".into(), - ) - })?, - None => source - .runtime - .worker_observation_cursor_now(&source.worker_ref) - .map_err(|err| { - ObservationProxyError::WorkerNotFound(format!( - "embedded Worker '{}' is not observable: {err}", - source.worker_id - )) - })?, - }; + fn connect(source: &EmbeddedRuntimeObservationSource) -> Result { + let cursor = source + .runtime + .worker_observation_cursor_now(&source.worker_ref) + .map_err(|err| { + ObservationProxyError::WorkerNotFound(format!( + "embedded Worker '{}' is not observable: {err}", + source.worker_id + )) + })?; let receiver = source .runtime .subscribe_worker_observation() @@ -559,29 +450,27 @@ impl EmbeddedObservationClient { )) })?; let mut queued = VecDeque::new(); - if runtime_cursor.is_none() { - let snapshot = source - .runtime - .worker_observation_snapshot(&source.worker_ref) - .map_err(|err| { - ObservationProxyError::WorkerNotFound(format!( - "embedded Worker '{}' snapshot is unavailable: {err}", - source.worker_id - )) - })?; - queued.push_back(RuntimeObservationUpstreamEvent { - runtime_id: source.runtime_id.clone(), - worker_id: source.worker_id.clone(), - runtime_cursor: cursor.encode(), - payload: snapshot, - }); - } + let snapshot = source + .runtime + .worker_observation_snapshot(&source.worker_ref) + .map_err(|err| { + ObservationProxyError::WorkerNotFound(format!( + "embedded Worker '{}' snapshot is unavailable: {err}", + source.worker_id + )) + })?; + queued.push_back(RuntimeObservationUpstreamEvent { + runtime_id: source.runtime_id.clone(), + worker_id: source.worker_id.clone(), + runtime_event_id: "snapshot".to_string(), + payload: snapshot, + }); for event in source .runtime .read_worker_observation_events(&source.worker_ref, cursor) .map_err(|err| { - ObservationProxyError::CursorUnknownOrExpired(format!( - "embedded Worker '{}' cursor is unavailable: {err}", + ObservationProxyError::RuntimeUnavailable(format!( + "embedded Worker '{}' observation cursor is unavailable: {err}", source.worker_id )) })? @@ -606,9 +495,6 @@ impl EmbeddedObservationClient { &mut self, ) -> Result { if let Some(event) = self.queued.pop_front() { - if let Some(cursor) = WorkerObservationCursor::decode(&event.runtime_cursor) { - self.cursor = cursor; - } return Ok(event); } loop { @@ -619,7 +505,7 @@ impl EmbeddedObservationClient { { self.cursor = WorkerObservationCursor::decode(&event.cursor).ok_or_else(|| { - ObservationProxyError::CursorMalformed( + ObservationProxyError::RuntimeUnavailable( "embedded runtime emitted a malformed cursor".into(), ) })?; @@ -627,7 +513,7 @@ impl EmbeddedObservationClient { } Ok(_) => continue, Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - return Err(ObservationProxyError::CursorUnknownOrExpired( + return Err(ObservationProxyError::RuntimeUnavailable( "embedded runtime observation backlog was exceeded".into(), )); } @@ -648,7 +534,7 @@ impl EmbeddedObservationClient { RuntimeObservationUpstreamEvent { runtime_id: runtime_id.to_string(), worker_id: worker_id.to_string(), - runtime_cursor: event.cursor, + runtime_event_id: event.cursor.clone(), payload: event.payload, } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 0d08e29e..732300c9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -34,8 +34,8 @@ use crate::hosts::{ }; use crate::identity::WorkspaceIdentity; use crate::observation::{ - BackendObservationProxy, ClientWorkerEventWsFrame, ClientWorkerEventsWsQuery, - ObservationProxyError, RuntimeObservationClient, RuntimeObservationSourceConfig, + BackendObservationProxy, ClientWorkerEventWsFrame, ObservationProxyError, + RuntimeObservationClient, RuntimeObservationSourceConfig, }; use crate::profile_settings::{ CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest, @@ -2119,19 +2119,13 @@ async fn scoped_worker_observation_ws( ws: WebSocketUpgrade, State(api): State, AxumPath(path): AxumPath, - Query(query): Query, ) -> Response { if let Err(err) = validate_workspace_scope(&api, &path.workspace_id) { return err.into_response(); } - worker_observation_ws( - State(api), - AxumPath((path.runtime_id, path.worker_id)), - Query(query), - ws, - ) - .await - .into_response() + worker_observation_ws(State(api), AxumPath((path.runtime_id, path.worker_id)), ws) + .await + .into_response() } async fn scoped_list_host_workers( @@ -2946,17 +2940,16 @@ async fn cancel_runtime_worker( async fn worker_observation_ws( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, - Query(query): Query, ws: WebSocketUpgrade, ) -> impl IntoResponse { match api.observation_proxy.source(&runtime_id, &worker_id) { Ok(source) => ws.on_upgrade(move |socket| { - worker_observation_ws_session(api.observation_proxy, source, query, socket) + worker_observation_ws_session(api.observation_proxy, source, socket) }), Err(ObservationProxyError::WorkerNotFound(_)) => { match api.runtime.observation_source(&runtime_id, &worker_id) { Ok(source) => ws.on_upgrade(move |socket| { - worker_observation_ws_session(api.observation_proxy, source, query, socket) + worker_observation_ws_session(api.observation_proxy, source, socket) }), Err(error) => ApiError::from(error.into_error()).into_response(), } @@ -2978,20 +2971,9 @@ async fn worker_observation_ws( async fn worker_observation_ws_session( proxy: BackendObservationProxy, source: crate::observation::RuntimeObservationSource, - query: ClientWorkerEventsWsQuery, mut socket: WebSocket, ) { - if let Err(error) = proxy.open( - source.runtime_id(), - source.worker_id(), - query.cursor.as_deref(), - ) { - let _ = - send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::diagnostic(error)).await; - return; - } - - let mut upstream = match RuntimeObservationClient::connect(&source, None).await { + let mut upstream = match RuntimeObservationClient::connect(&source).await { Ok(client) => client, Err(error) => { let _ = send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::diagnostic(error)) @@ -3033,14 +3015,9 @@ async fn worker_observation_ws_session( } upstream_event = upstream.next_event() => { match upstream_event { - Ok(event) => match proxy.store(event) { - Ok(envelope) => { - if !send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::event(envelope)).await { - return; - } - } - Err(error) => { - let _ = send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::diagnostic(error)).await; + Ok(event) => { + let envelope = proxy.map_event(event); + if !send_client_ws_frame(&mut socket, ClientWorkerEventWsFrame::event(envelope)).await { return; } }, @@ -7266,38 +7243,13 @@ mod tests { ClientWorkerEventWsFrame::Event { envelope } if matches!(envelope.payload, protocol::Event::TextDone { .. }) )); - let (mut resumed, _) = connect_async(format!("{url}?cursor={}", live.cursor)) - .await - .unwrap(); - let _snapshot = next_client_frame(&mut resumed).await; - runtime - .observe_worker_event( - &worker_ref, - protocol::Event::TextDone { - text: "done".into(), - }, - ) - .unwrap(); - let resumed_event = next_client_frame(&mut resumed).await; - let ClientWorkerEventWsFrame::Event { - envelope: resumed_event, - } = resumed_event - else { - panic!("expected resumed live event"); - }; - assert_ne!(resumed_event.cursor, live.cursor); + let (mut query_stream, _) = connect_async(format!("{url}?cursor=bad")).await.unwrap(); + let query_snapshot = next_client_frame(&mut query_stream).await; assert!(matches!( - resumed_event.payload, - protocol::Event::TextDone { .. } + query_snapshot, + ClientWorkerEventWsFrame::Event { envelope } if matches!(envelope.payload, protocol::Event::Snapshot { .. }) )); - let (mut malformed, _) = connect_async(format!("{url}?cursor=bad")).await.unwrap(); - let diagnostic = next_client_frame(&mut malformed).await; - let ClientWorkerEventWsFrame::Diagnostic { diagnostic } = diagnostic else { - panic!("expected malformed cursor diagnostic"); - }; - assert_eq!(diagnostic.code, "backend.cursor_malformed"); - stream.send(Message::Text("{}".into())).await.unwrap(); let mut saw_observation_only = false; for _ in 0..3 { @@ -7312,41 +7264,6 @@ mod tests { assert!(saw_observation_only, "expected observation-only diagnostic"); } - #[tokio::test] - async fn proxy_does_not_validate_backend_cursor_against_replay_history() { - let source = RuntimeObservationSourceConfig { - runtime_id: "runtime-a".into(), - worker_id: "worker-a".into(), - endpoint: "ws://127.0.0.1:9/not-used".into(), - bearer_token: None, - }; - let (url, _dir) = spawn_workspace_proxy(source).await; - let (mut stream, _) = connect_async(format!("{url}?cursor=bo_ffffffffffffffff")) - .await - .unwrap(); - let diagnostic = next_client_diagnostic(&mut stream).await; - assert_eq!(diagnostic.code, "backend.runtime_unavailable"); - } - - #[tokio::test] - async fn proxy_maps_runtime_cursor_diagnostic_to_typed_backend_diagnostic() { - let (_runtime, _worker_ref, endpoint) = spawn_runtime_worker().await; - let source = RuntimeObservationSourceConfig { - runtime_id: "runtime-a".into(), - worker_id: "worker-a".into(), - endpoint: format!("{endpoint}?cursor=wo_ffffffffffffffff"), - bearer_token: None, - }; - let (url, _dir) = spawn_workspace_proxy(source).await; - let (mut stream, _) = connect_async(&url).await.unwrap(); - assert!(matches!( - next_client_frame(&mut stream).await, - ClientWorkerEventWsFrame::Event { envelope } if matches!(envelope.payload, protocol::Event::Snapshot { .. }) - )); - let diagnostic = next_client_diagnostic(&mut stream).await; - assert_eq!(diagnostic.code, "backend.cursor_unknown_or_expired"); - } - #[tokio::test] async fn proxy_maps_runtime_worker_not_found_http_404_to_typed_backend_diagnostic() { let (_runtime, _worker_ref, endpoint) = spawn_runtime_worker().await; diff --git a/web/workspace/src/lib/workspace-console/model.test.ts b/web/workspace/src/lib/workspace-console/model.test.ts index 9c89a805..b6f081ae 100644 --- a/web/workspace/src/lib/workspace-console/model.test.ts +++ b/web/workspace/src/lib/workspace-console/model.test.ts @@ -56,28 +56,28 @@ Deno.test("segmentsToText preserves protocol segment semantics", () => { Deno.test("projectConsole projects visible protocol rows", () => { const projection = projectConsole([ { - cursor: "10", + eventId: "10", event: { event: "user_message", data: { segments: [{ kind: "text", content: "input" }] }, } satisfies Event, }, { - cursor: "11", + eventId: "11", event: { event: "text_delta", data: { text: "stream" }, } satisfies Event, }, { - cursor: "12", + eventId: "12", event: { event: "thinking_done", data: { text: "reasoning" }, } satisfies Event, }, { - cursor: "13", + eventId: "13", event: { event: "tool_result", data: { @@ -89,14 +89,14 @@ Deno.test("projectConsole projects visible protocol rows", () => { } satisfies Event, }, { - cursor: "14", + eventId: "14", event: { event: "usage", data: { input_tokens: 12, output_tokens: 5 }, } satisfies Event, }, { - cursor: "15", + eventId: "15", event: { event: "error", data: { code: "invalid_request", message: "bad frame" }, @@ -141,35 +141,35 @@ Deno.test("projectConsole projects visible protocol rows", () => { Deno.test("projectConsole groups tool call lifecycle into one Call block", () => { const projection = projectConsole([ { - cursor: "40", + eventId: "40", event: { event: "tool_call_start", data: { id: "call-1", name: "Bash" }, } satisfies Event, }, { - cursor: "41", + eventId: "41", event: { event: "tool_call_args_delta", data: { id: "call-1", json: '{"command":"pw' }, } satisfies Event, }, { - cursor: "42", + eventId: "42", event: { event: "tool_call_args_delta", data: { id: "call-1", json: 'd"}' }, } satisfies Event, }, { - cursor: "43", + eventId: "43", event: { event: "tool_call_done", data: { id: "call-1", name: "Bash", arguments: '{"command":"pwd"}' }, } satisfies Event, }, { - cursor: "44", + eventId: "44", event: { event: "tool_result", data: { @@ -206,14 +206,14 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () => Deno.test("projectConsole keeps streaming tool call updates in the same Call block", () => { const projection = projectConsole([ { - cursor: "45", + eventId: "45", event: { event: "tool_call_start", data: { id: "call-2", name: "Read" }, } satisfies Event, }, { - cursor: "46", + eventId: "46", event: { event: "tool_call_args_delta", data: { id: "call-2", json: '{"file_path":"/tmp/a.md"}' }, @@ -235,7 +235,7 @@ Deno.test("projectConsole keeps streaming tool call updates in the same Call blo Deno.test("projectConsole aggregates Read calls without showing file content", () => { const projection = projectConsole([ { - cursor: "50", + eventId: "50", event: { event: "tool_call_done", data: { @@ -246,7 +246,7 @@ Deno.test("projectConsole aggregates Read calls without showing file content", ( } satisfies Event, }, { - cursor: "51", + eventId: "51", event: { event: "tool_result", data: { @@ -258,7 +258,7 @@ Deno.test("projectConsole aggregates Read calls without showing file content", ( } satisfies Event, }, { - cursor: "52", + eventId: "52", event: { event: "tool_call_done", data: { @@ -269,7 +269,7 @@ Deno.test("projectConsole aggregates Read calls without showing file content", ( } satisfies Event, }, { - cursor: "53", + eventId: "53", event: { event: "tool_result", data: { @@ -307,7 +307,7 @@ Deno.test("projectConsole aggregates Read calls without showing file content", ( Deno.test("projectConsole renders Edit calls with structured diff lines", () => { const projection = projectConsole([ { - cursor: "60", + eventId: "60", event: { event: "tool_call_done", data: { @@ -322,7 +322,7 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () => } satisfies Event, }, { - cursor: "61", + eventId: "61", event: { event: "tool_result", data: { @@ -350,7 +350,7 @@ Deno.test("projectConsole renders Edit calls with structured diff lines", () => Deno.test("projectConsole preserves in-progress assistant protocol stream", () => { const projection = projectConsole([ { - cursor: "13", + eventId: "13", event: { event: "text_delta", data: { text: "new" } } satisfies Event, }, ]); @@ -367,26 +367,26 @@ Deno.test("projectConsole preserves in-progress assistant protocol stream", () = Deno.test("projectConsole keeps protocol lifecycle events out of the console surface", () => { const projection = projectConsole([ { - cursor: "30", + eventId: "30", event: { event: "status", data: { status: "running" } } satisfies Event, }, { - cursor: "31", + eventId: "31", event: { event: "llm_call_end", data: { llm_call: 0 } } satisfies Event, }, { - cursor: "32", + eventId: "32", event: { event: "turn_end", data: { turn: 0, result: "finished" }, } satisfies Event, }, { - cursor: "33", + eventId: "33", event: { event: "run_end", data: { result: "finished" } } satisfies Event, }, { - cursor: "34", + eventId: "34", event: { event: "system_item", data: { item: { kind: "note", content: "internal" } }, @@ -401,7 +401,7 @@ Deno.test("projectConsole keeps protocol lifecycle events out of the console sur Deno.test("projectConsole uses snapshot for state without rendering it as console output", () => { const projection = projectConsole([ { - cursor: "20", + eventId: "20", event: { event: "snapshot", data: { diff --git a/web/workspace/src/lib/workspace-console/model.ts b/web/workspace/src/lib/workspace-console/model.ts index b4331445..207748c4 100644 --- a/web/workspace/src/lib/workspace-console/model.ts +++ b/web/workspace/src/lib/workspace-console/model.ts @@ -49,7 +49,7 @@ export type ConsoleLine = { body: string; detail?: string; diff?: ConsoleDiffLine[]; - cursor?: string | null; + eventId?: string | null; source: "event"; streaming?: boolean; error?: boolean; @@ -60,7 +60,7 @@ export type ConsoleProjection = { lines: ConsoleLine[]; status: string | null; usage: string | null; - lastCursor: string | null; + lastEventId: string | null; }; export type WorkerTarget = { @@ -93,7 +93,7 @@ export function workerConsolePath( ); } -export type ConsoleEventInput = { cursor: string; event: ProtocolEvent }; +export type ConsoleEventInput = { eventId: string; event: ProtocolEvent }; export function projectConsole( events: ConsoleEventInput[] = [], @@ -102,7 +102,7 @@ export function projectConsole( lines: [], status: null, usage: null, - lastCursor: null, + lastEventId: null, }); return { ...projection, @@ -112,13 +112,13 @@ export function projectConsole( export function applyProtocolEvent( projection: ConsoleProjection, - envelope: { cursor: string; event: ProtocolEvent }, + envelope: { eventId: string; event: ProtocolEvent }, ): ConsoleProjection { const next: ConsoleProjection = { lines: [...projection.lines], status: projection.status, usage: projection.usage, - lastCursor: envelope.cursor, + lastEventId: envelope.eventId, }; const event = envelope.event; @@ -126,7 +126,7 @@ export function applyProtocolEvent( case "user_message": next.lines.push( line( - envelope.cursor, + envelope.eventId, "user", "User", segmentsToText(event.data.segments), @@ -139,7 +139,7 @@ export function applyProtocolEvent( case "text_delta": appendStreaming( next, - envelope.cursor, + envelope.eventId, "assistant", "assistant streaming", event.data.text, @@ -149,20 +149,20 @@ export function applyProtocolEvent( finalizeStreaming( next, "assistant", - envelope.cursor, + envelope.eventId, "assistant", event.data.text, ); break; case "thinking_start": next.lines.push( - line(envelope.cursor, "thinking", "Thinking...", "", undefined, true), + line(envelope.eventId, "thinking", "Thinking...", "", undefined, true), ); break; case "thinking_delta": appendStreaming( next, - envelope.cursor, + envelope.eventId, "thinking", "Thinking...", event.data.text, @@ -172,22 +172,22 @@ export function applyProtocolEvent( finalizeStreaming( next, "thinking", - envelope.cursor, + envelope.eventId, "Thought", event.data.text, ); break; case "tool_call_start": - upsertToolCall(next, envelope.cursor, event.data.id, { + upsertToolCall(next, envelope.eventId, event.data.id, { name: event.data.name, state: "pending", }); break; case "tool_call_args_delta": - appendToolArgs(next, envelope.cursor, event.data.id, event.data.json); + appendToolArgs(next, envelope.eventId, event.data.id, event.data.json); break; case "tool_call_done": - upsertToolCall(next, envelope.cursor, event.data.id, { + upsertToolCall(next, envelope.eventId, event.data.id, { name: event.data.name, arguments: event.data.arguments, argsStream: event.data.arguments, @@ -195,7 +195,7 @@ export function applyProtocolEvent( }); break; case "tool_result": - attachToolResult(next, envelope.cursor, event.data.id, { + attachToolResult(next, envelope.eventId, event.data.id, { summary: event.data.summary, output: event.data.output, isError: event.data.is_error, @@ -207,7 +207,7 @@ export function applyProtocolEvent( case "error": next.lines.push( line( - envelope.cursor, + envelope.eventId, "error", `error · ${event.data.code}`, event.data.message, @@ -220,7 +220,7 @@ export function applyProtocolEvent( case "snapshot": next.status = event.data.status; for (const block of event.data.in_flight?.blocks ?? []) { - next.lines.push(inFlightLine(envelope.cursor, block)); + next.lines.push(inFlightLine(envelope.eventId, block)); } break; case "status": @@ -251,7 +251,7 @@ export function applyProtocolEvent( case "compact_failed": next.lines.push( line( - envelope.cursor, + envelope.eventId, "error", "compact failed", event.data.error, @@ -292,7 +292,7 @@ export function segmentsToText(segments: Segment[]): string { } function line( - cursor: string, + eventId: string, kind: ConsoleLineKind, title: string, body: string, @@ -301,12 +301,12 @@ function line( error = false, ): ConsoleLine { return { - id: `event-${cursor}-${kind}-${slugify(title)}-${body.length}`, + id: `event-${eventId}-${kind}-${slugify(title)}-${body.length}`, kind, title, body, detail, - cursor, + eventId, source: "event", streaming, error, @@ -315,7 +315,7 @@ function line( function appendStreaming( projection: ConsoleProjection, - cursor: string, + eventId: string, kind: "assistant" | "thinking", title: string, delta: string, @@ -325,16 +325,16 @@ function appendStreaming( ); if (existing) { existing.body += delta; - existing.cursor = cursor; + existing.eventId = eventId; return; } - projection.lines.push(line(cursor, kind, title, delta, undefined, true)); + projection.lines.push(line(eventId, kind, title, delta, undefined, true)); } function finalizeStreaming( projection: ConsoleProjection, kind: "assistant" | "thinking", - cursor: string, + eventId: string, title: string, body: string, ): void { @@ -345,21 +345,21 @@ function finalizeStreaming( existing.body = body || existing.body; existing.streaming = false; existing.title = title; - existing.cursor = cursor; + existing.eventId = eventId; return; } - projection.lines.push(line(cursor, kind, title, body)); + projection.lines.push(line(eventId, kind, title, body)); } function upsertToolCall( projection: ConsoleProjection, - cursor: string, + eventId: string, id: string, update: Partial>, ): ConsoleLine { let existing = findToolCallLine(projection, id); if (!existing) { - existing = toolLine(cursor, { + existing = toolLine(eventId, { id, name: update.name ?? "Tool", argsStream: update.argsStream ?? "", @@ -371,7 +371,7 @@ function upsertToolCall( }); projection.lines.push(existing); } else { - existing.cursor = cursor; + existing.eventId = eventId; existing.toolCall = { ...existing.toolCall!, ...update, @@ -385,11 +385,11 @@ function upsertToolCall( function appendToolArgs( projection: ConsoleProjection, - cursor: string, + eventId: string, id: string, delta: string, ): void { - const existing = upsertToolCall(projection, cursor, id, { + const existing = upsertToolCall(projection, eventId, id, { state: "streaming_args", }); existing.toolCall!.argsStream += delta; @@ -398,13 +398,13 @@ function appendToolArgs( function attachToolResult( projection: ConsoleProjection, - cursor: string, + eventId: string, id: string, result: Pick, ): void { const existing = findToolCallLine(projection, id); if (!existing) { - const fallback = toolLine(cursor, { + const fallback = toolLine(eventId, { id, name: "Tool", argsStream: "", @@ -418,7 +418,7 @@ function attachToolResult( projection.lines.push(fallback); return; } - existing.cursor = cursor; + existing.eventId = eventId; existing.toolCall = { ...existing.toolCall!, ...result, @@ -436,14 +436,14 @@ function findToolCallLine( ); } -function toolLine(cursor: string, toolCall: ToolCallView): ConsoleLine { +function toolLine(eventId: string, toolCall: ToolCallView): ConsoleLine { const item: ConsoleLine = { id: `tool-call-${toolCall.id}`, kind: "tool", title: `Call · ${toolCall.name}`, body: "", detail: undefined, - cursor, + eventId, source: "event", streaming: true, error: false, @@ -531,7 +531,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine { title: "Call · Read", body, detail: calls.map(readDetail).join("\n\n"), - cursor: group.at(-1)?.cursor, + eventId: group.at(-1)?.eventId, source: "event", streaming: inProgress, error: hasError, @@ -760,11 +760,11 @@ function usageText( } · cache ${data.cache_read_input_tokens ?? "unknown"}`; } -function inFlightLine(cursor: string, block: InFlightBlock): ConsoleLine { +function inFlightLine(eventId: string, block: InFlightBlock): ConsoleLine { switch (block.kind) { case "text": return line( - cursor, + eventId, "in_flight", "in-flight assistant text", block.text, @@ -773,7 +773,7 @@ function inFlightLine(cursor: string, block: InFlightBlock): ConsoleLine { ); case "thinking": return line( - cursor, + eventId, "in_flight", "in-flight thinking", block.text, @@ -781,7 +781,7 @@ function inFlightLine(cursor: string, block: InFlightBlock): ConsoleLine { !block.finished, ); case "tool_call": - return toolLine(cursor, { + return toolLine(eventId, { id: block.id, name: block.name, argsStream: block.args, diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index 30cd2b8e..dc9db023 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -253,7 +253,6 @@ export type WorkerInputResult = { }; export type ClientWorkerEventWsEnvelope = { - cursor: string; event_id: string; runtime_id: string; worker_id: string; diff --git a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte index 6ace996c..3e340160 100644 --- a/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/runtimes/[runtimeId]/workers/[workerId]/console/+page.svelte @@ -42,7 +42,7 @@ let consoleBodyElement: HTMLElement | null = null; let autoFollowConsole = $state(true); const CONSOLE_BOTTOM_THRESHOLD_PX = 48; - let observedEvents = $state>([]); + let observedEvents = $state>([]); let seenObservationEventIds = new Set(); let nextReloadToken = 0; let reloadToken = $state(0); @@ -55,7 +55,7 @@ const consoleTarget = $derived({ runtimeId, workerId }); const projection = $derived( - projectConsole(observedEvents.map((item) => ({ cursor: item.cursor, event: item.event.envelope.payload }))) + projectConsole(observedEvents.map((item) => ({ eventId: item.eventId, event: item.event.envelope.payload }))) ); const lines = $derived(projection.lines); const diagnostics = $derived(mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics)); @@ -185,7 +185,7 @@ observedEvents = [ ...observedEvents, { - cursor: frame.envelope.cursor, + eventId: frame.envelope.event_id, event: frame } ].slice(-500);