fix: preserve Console Notify ACK identity and request ID limits
This commit is contained in:
@@ -103,6 +103,33 @@ impl AuthenticatedInputSource {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Server-owned receipt partition, independent of client-visible ACK identity.
|
||||||
|
/// Console Notify keeps its original bounded ID without sharing HTTP/chat receipts.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum NotificationReceiptScope {
|
||||||
|
#[default]
|
||||||
|
Source,
|
||||||
|
BrowserConsole,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationReceiptScope {
|
||||||
|
pub fn namespace(self, source_namespace: String) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Source => source_namespace,
|
||||||
|
Self::BrowserConsole => format!("browser-console:{source_namespace}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like provenance, a receipt partition is rebound by the authenticated transport.
|
||||||
|
fn ignore_wire_notification_scope<'de, D: serde::Deserializer<'de>>(
|
||||||
|
deserializer: D,
|
||||||
|
) -> Result<NotificationReceiptScope, D::Error> {
|
||||||
|
let _ = serde::de::IgnoredAny::deserialize(deserializer)?;
|
||||||
|
Ok(NotificationReceiptScope::Source)
|
||||||
|
}
|
||||||
|
|
||||||
/// Caller-owned identity for one state-changing Worker command.
|
/// Caller-owned identity for one state-changing Worker command.
|
||||||
///
|
///
|
||||||
/// Commands operate on the controller state at admission time, not a caller's
|
/// Commands operate on the controller state at admission time, not a caller's
|
||||||
@@ -311,6 +338,8 @@ pub enum Method {
|
|||||||
NotifyTracked {
|
NotifyTracked {
|
||||||
notification_request_id: String,
|
notification_request_id: String,
|
||||||
message: String,
|
message: String,
|
||||||
|
#[serde(default, deserialize_with = "ignore_wire_notification_scope")]
|
||||||
|
receipt_scope: NotificationReceiptScope,
|
||||||
#[serde(default, deserialize_with = "ignore_wire_input_source")]
|
#[serde(default, deserialize_with = "ignore_wire_input_source")]
|
||||||
source: AuthenticatedInputSource,
|
source: AuthenticatedInputSource,
|
||||||
},
|
},
|
||||||
@@ -2242,6 +2271,29 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn notification_receipt_scope_is_not_selected_by_wire_input() {
|
||||||
|
for scope in [
|
||||||
|
serde_json::json!("browser_console"),
|
||||||
|
serde_json::json!({"forged":true}),
|
||||||
|
serde_json::Value::Null,
|
||||||
|
] {
|
||||||
|
let method: Method = serde_json::from_value(serde_json::json!({
|
||||||
|
"method":"notify_tracked", "params":{
|
||||||
|
"notification_request_id":"original", "message":"advisory",
|
||||||
|
"receipt_scope":scope,
|
||||||
|
"source":{"kind":"account","account_id":"forged"}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
assert!(matches!(method, Method::NotifyTracked {
|
||||||
|
receipt_scope: NotificationReceiptScope::Source,
|
||||||
|
source: AuthenticatedInputSource::UntrustedWire,
|
||||||
|
notification_request_id, ..
|
||||||
|
} if notification_request_id == "original"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
|
fn notify_rejects_typed_intent_instead_of_silently_discarding_it() {
|
||||||
for method in ["notify", "notify_tracked"] {
|
for method in ["notify", "notify_tracked"] {
|
||||||
|
|||||||
@@ -706,6 +706,7 @@ fn authorize_local_input(method: protocol::Method) -> protocol::Method {
|
|||||||
message,
|
message,
|
||||||
..
|
..
|
||||||
} => Method::NotifyTracked {
|
} => Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
source: AuthenticatedInputSource::LocalOperator,
|
source: AuthenticatedInputSource::LocalOperator,
|
||||||
@@ -952,6 +953,7 @@ mod local_input_tests {
|
|||||||
message: "hello".into(),
|
message: "hello".into(),
|
||||||
},
|
},
|
||||||
Method::NotifyTracked {
|
Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: "tracked-notify".into(),
|
notification_request_id: "tracked-notify".into(),
|
||||||
message: "hello".into(),
|
message: "hello".into(),
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -1415,6 +1415,14 @@ fn authorize_runtime_protocol_method(
|
|||||||
message,
|
message,
|
||||||
..
|
..
|
||||||
} => protocol::Method::NotifyTracked {
|
} => protocol::Method::NotifyTracked {
|
||||||
|
receipt_scope: if matches!(
|
||||||
|
transport_source,
|
||||||
|
Some(protocol::AuthenticatedInputSource::Account { .. })
|
||||||
|
) {
|
||||||
|
protocol::NotificationReceiptScope::BrowserConsole
|
||||||
|
} else {
|
||||||
|
protocol::NotificationReceiptScope::Source
|
||||||
|
},
|
||||||
source: transport_source.cloned().unwrap_or_else(|| {
|
source: transport_source.cloned().unwrap_or_else(|| {
|
||||||
protocol::AuthenticatedInputSource::Backend {
|
protocol::AuthenticatedInputSource::Backend {
|
||||||
operation_id: notification_request_id.clone(),
|
operation_id: notification_request_id.clone(),
|
||||||
@@ -2985,6 +2993,7 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.expect("account source header must resolve");
|
.expect("account source header must resolve");
|
||||||
let wire = serde_json::to_string(&protocol::Method::NotifyTracked {
|
let wire = serde_json::to_string(&protocol::Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: "notification-1".into(),
|
notification_request_id: "notification-1".into(),
|
||||||
message: "hello".into(),
|
message: "hello".into(),
|
||||||
source: protocol::AuthenticatedInputSource::Account {
|
source: protocol::AuthenticatedInputSource::Account {
|
||||||
@@ -2997,9 +3006,11 @@ mod tests {
|
|||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
authorize_runtime_protocol_method(decoded, Some(&source)),
|
authorize_runtime_protocol_method(decoded, Some(&source)),
|
||||||
protocol::Method::NotifyTracked {
|
protocol::Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::BrowserConsole,
|
||||||
|
notification_request_id,
|
||||||
source: protocol::AuthenticatedInputSource::Account { account_id },
|
source: protocol::AuthenticatedInputSource::Account { account_id },
|
||||||
..
|
..
|
||||||
} if account_id == "account-1"
|
} if account_id == "account-1" && notification_request_id == "notification-1"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5310,6 +5321,7 @@ mod ws_tests {
|
|||||||
let (mut stream, _) = connect_async(request).await.unwrap();
|
let (mut stream, _) = connect_async(request).await.unwrap();
|
||||||
let _ = next_frame(&mut stream).await;
|
let _ = next_frame(&mut stream).await;
|
||||||
let notification = protocol::Method::NotifyTracked {
|
let notification = protocol::Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: "notification-1".into(),
|
notification_request_id: "notification-1".into(),
|
||||||
message: "hello".into(),
|
message: "hello".into(),
|
||||||
source: protocol::AuthenticatedInputSource::Account {
|
source: protocol::AuthenticatedInputSource::Account {
|
||||||
|
|||||||
@@ -6540,6 +6540,7 @@ where
|
|||||||
WorkerExecutionOperation::Input,
|
WorkerExecutionOperation::Input,
|
||||||
worker,
|
worker,
|
||||||
Method::NotifyTracked {
|
Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: notification_request_id.clone(),
|
notification_request_id: notification_request_id.clone(),
|
||||||
message: input.content,
|
message: input.content,
|
||||||
source: input.source.unwrap_or_else(|| {
|
source: input.source.unwrap_or_else(|| {
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ mod notify_transport {
|
|||||||
let mut events = lost_handle.subscribe();
|
let mut events = lost_handle.subscribe();
|
||||||
lost_handle
|
lost_handle
|
||||||
.send(Method::NotifyTracked {
|
.send(Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: "same-client-id".into(),
|
notification_request_id: "same-client-id".into(),
|
||||||
message: "human-notice-body".into(),
|
message: "human-notice-body".into(),
|
||||||
source: account,
|
source: account,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ mod restore_candidate {
|
|||||||
let mut events = handle.subscribe();
|
let mut events = handle.subscribe();
|
||||||
let method = match source {
|
let method = match source {
|
||||||
Some(source) => Method::NotifyTracked {
|
Some(source) => Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: NOTICE.into(),
|
notification_request_id: NOTICE.into(),
|
||||||
message: NOTICE.into(),
|
message: NOTICE.into(),
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -546,6 +546,7 @@ fn durable_parent_notification_target<St: Store + Clone + Send + Sync + 'static>
|
|||||||
) -> crate::spawn::tool::ParentNotificationTarget {
|
) -> crate::spawn::tool::ParentNotificationTarget {
|
||||||
crate::spawn::tool::ParentNotificationTarget::Durable(Arc::new(move |method| {
|
crate::spawn::tool::ParentNotificationTarget::Durable(Arc::new(move |method| {
|
||||||
let Method::NotifyTracked {
|
let Method::NotifyTracked {
|
||||||
|
receipt_scope,
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
source,
|
source,
|
||||||
@@ -554,6 +555,7 @@ fn durable_parent_notification_target<St: Store + Clone + Send + Sync + 'static>
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let (source_namespace, provenance) = resolved_input_source(&pending_submissions, &source);
|
let (source_namespace, provenance) = resolved_input_source(&pending_submissions, &source);
|
||||||
|
let source_namespace = receipt_scope.namespace(source_namespace);
|
||||||
match pending_submissions.accept_notification_from_source(
|
match pending_submissions.accept_notification_from_source(
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
@@ -2268,12 +2270,14 @@ where
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
Method::NotifyTracked {
|
Method::NotifyTracked {
|
||||||
|
receipt_scope,
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
source,
|
source,
|
||||||
} => {
|
} => {
|
||||||
let (source_namespace, provenance) =
|
let (source_namespace, provenance) =
|
||||||
resolved_input_source(pending_submissions, &source);
|
resolved_input_source(pending_submissions, &source);
|
||||||
|
let source_namespace = receipt_scope.namespace(source_namespace);
|
||||||
durably_accept_notification(
|
durably_accept_notification(
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
@@ -2749,12 +2753,14 @@ async fn controller_loop<C, St>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Method::NotifyTracked {
|
Method::NotifyTracked {
|
||||||
|
receipt_scope,
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
source,
|
source,
|
||||||
} => {
|
} => {
|
||||||
let (source_namespace, provenance) =
|
let (source_namespace, provenance) =
|
||||||
resolved_input_source(&pending_submissions, &source);
|
resolved_input_source(&pending_submissions, &source);
|
||||||
|
let source_namespace = receipt_scope.namespace(source_namespace);
|
||||||
durably_accept_notification(
|
durably_accept_notification(
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
@@ -3671,12 +3677,14 @@ where
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(Method::NotifyTracked {
|
Some(Method::NotifyTracked {
|
||||||
|
receipt_scope,
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
source,
|
source,
|
||||||
}) => {
|
}) => {
|
||||||
let (source_namespace, provenance) =
|
let (source_namespace, provenance) =
|
||||||
resolved_input_source(pending_submissions, &source);
|
resolved_input_source(pending_submissions, &source);
|
||||||
|
let source_namespace = receipt_scope.namespace(source_namespace);
|
||||||
if durably_accept_notification(
|
if durably_accept_notification(
|
||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
@@ -4824,6 +4832,7 @@ mod tests {
|
|||||||
let pending = PendingSubmissionHandle::for_test(dir.path());
|
let pending = PendingSubmissionHandle::for_test(dir.path());
|
||||||
let (event_tx, mut event_rx) = broadcast::channel(4);
|
let (event_tx, mut event_rx) = broadcast::channel(4);
|
||||||
let method = Method::NotifyTracked {
|
let method = Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: "completion-1".into(),
|
notification_request_id: "completion-1".into(),
|
||||||
message: "subworker completed".into(),
|
message: "subworker completed".into(),
|
||||||
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ impl ParentNotificationTarget {
|
|||||||
|
|
||||||
pub(crate) fn notify(&self, child_session_id: String, message: String) {
|
pub(crate) fn notify(&self, child_session_id: String, message: String) {
|
||||||
let method = Method::NotifyTracked {
|
let method = Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: protocol::new_submission_request_id(),
|
notification_request_id: protocol::new_submission_request_id(),
|
||||||
message,
|
message,
|
||||||
source: protocol::AuthenticatedInputSource::SubWorker {
|
source: protocol::AuthenticatedInputSource::SubWorker {
|
||||||
|
|||||||
@@ -1935,6 +1935,7 @@ async fn manual_compact_cancel_clears_progress_before_returning_idle() {
|
|||||||
let completion_request_id = "subworker-completion-during-compact";
|
let completion_request_id = "subworker-completion-during-compact";
|
||||||
handle
|
handle
|
||||||
.send(Method::NotifyTracked {
|
.send(Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: completion_request_id.into(),
|
notification_request_id: completion_request_id.into(),
|
||||||
message: "subworker completed".into(),
|
message: "subworker completed".into(),
|
||||||
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
||||||
|
|||||||
@@ -601,6 +601,7 @@ fn notify_rejects_typed_side_effect_message_payloads() {
|
|||||||
message: "/prepare(first)".into(),
|
message: "/prepare(first)".into(),
|
||||||
},
|
},
|
||||||
protocol::Method::NotifyTracked {
|
protocol::Method::NotifyTracked {
|
||||||
|
receipt_scope: protocol::NotificationReceiptScope::Source,
|
||||||
notification_request_id: "n-1".into(),
|
notification_request_id: "n-1".into(),
|
||||||
message: "/prepare(first)".into(),
|
message: "/prepare(first)".into(),
|
||||||
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
source: protocol::AuthenticatedInputSource::UntrustedWire,
|
||||||
|
|||||||
@@ -34788,7 +34788,8 @@ pub(crate) fn authorize_browser_worker_method(
|
|||||||
notification_request_id,
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
} => Ok(protocol::Method::NotifyTracked {
|
} => Ok(protocol::Method::NotifyTracked {
|
||||||
notification_request_id: format!("browser-ws-notify:{notification_request_id}"),
|
receipt_scope: protocol::NotificationReceiptScope::BrowserConsole,
|
||||||
|
notification_request_id,
|
||||||
message,
|
message,
|
||||||
source: source.clone(),
|
source: source.clone(),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -777,7 +777,10 @@ fn publish_chat_fixture_profile(api: &WorkspaceApi) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ConnectedChat {
|
impl ConnectedChat {
|
||||||
async fn new(model: ChatModel, app_layer: impl FnOnce(Router) -> Router) -> Self {
|
async fn new(
|
||||||
|
model: impl agen::llm_client::LlmClient + Clone + 'static,
|
||||||
|
app_layer: impl FnOnce(Router) -> Router,
|
||||||
|
) -> Self {
|
||||||
const RUNTIME: &str = "connected-chat";
|
const RUNTIME: &str = "connected-chat";
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
let backend_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let backend_listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
@@ -1270,3 +1273,239 @@ async fn chat_public_receive_busy_messages_commit_before_next_model_input_and_id
|
|||||||
);
|
);
|
||||||
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
|
assert!(!public.to_string().contains("INTERNAL_ASSISTANT"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct NotifyObservationModel(tokio::sync::mpsc::Sender<String>);
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl agen::llm_client::LlmClient for NotifyObservationModel {
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
request: agen::llm_client::Request,
|
||||||
|
) -> std::result::Result<agen::llm_client::ResponseStream, agen::llm_client::ClientError> {
|
||||||
|
use agen::llm_client::event::{Event, StopReason};
|
||||||
|
self.0.send(format!("{:?}", request.items)).await.unwrap();
|
||||||
|
Ok(Box::pin(futures::stream::iter([
|
||||||
|
Ok(Event::text_block_start(0)),
|
||||||
|
Ok(Event::text_delta(0, "internal only")),
|
||||||
|
Ok(Event::text_block_stop(0, Some(StopReason::EndTurn))),
|
||||||
|
])))
|
||||||
|
}
|
||||||
|
fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> {
|
||||||
|
Box::new(self.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Real authenticated Backend WS -> signed Runtime WS -> controller -> SQLite,
|
||||||
|
/// followed by the production Console admission tracker (Deno, the normal test
|
||||||
|
/// runner prerequisite). Only the model is scripted; no browser rendering claim.
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 6)]
|
||||||
|
async fn browser_notify_preserves_ack_id_limits_retry_and_console_next_draft() {
|
||||||
|
let (tx, mut inputs) = tokio::sync::mpsc::channel(32);
|
||||||
|
let f = ConnectedChat::new(NotifyObservationModel(tx), |router| router).await;
|
||||||
|
let subject = open_subjektiv_store(&f.api)
|
||||||
|
.unwrap()
|
||||||
|
.create_subject(crate::subjektiv::SubjectRole::new("notify-console").unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.id;
|
||||||
|
let operations = [
|
||||||
|
"session.record",
|
||||||
|
"memory.resident.read",
|
||||||
|
"memory.resident.supply",
|
||||||
|
"journal.startup.read",
|
||||||
|
"journal.startup.supply",
|
||||||
|
"chat.read",
|
||||||
|
"chat.send",
|
||||||
|
];
|
||||||
|
f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{subject}/permissions"),json!({"operation_id":"notify-console-policy","expected_change_id":null,"execution":operations.into_iter().map(|op|subject_permission(&subject,op)).collect::<Vec<_>>(),"delegation":[]})).await;
|
||||||
|
let conversation = f
|
||||||
|
.post(
|
||||||
|
&conversations_path(),
|
||||||
|
json!({"operation_id":"notify-console-conversation","subject_id":subject}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let c = conversation["id"].as_str().unwrap();
|
||||||
|
let created = f.post(&format!("/api/w/{TEST_WORKSPACE_ID}/workers"),json!({"runtime_id":"connected-chat","display_name":"Notify Console","profile":"project:connected-chat","initial_submit":[],"workdir_attachments":[],"feature_connections":{"subjektiv":{"subject_id":subject}}})).await;
|
||||||
|
let worker = created["worker_id"].as_str().unwrap();
|
||||||
|
let chat = f
|
||||||
|
.post(
|
||||||
|
&messages_path(c),
|
||||||
|
json!({"operation_id":"notify-console-chat","text":"HTTP_CHAT_RECEIPT"}),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(chat["delivery"]["state"], "accepted");
|
||||||
|
// Deliberately reuse the exact Runtime ID of the accepted HTTP chat input.
|
||||||
|
// Its receipt must not reject/consume a different Console message.
|
||||||
|
let canonical = serde_json::to_vec(&(
|
||||||
|
format!(
|
||||||
|
"account:{}",
|
||||||
|
conversation["participant_account_id"].as_str().unwrap()
|
||||||
|
),
|
||||||
|
format!("chat-message:{}", chat["message"]["id"].as_str().unwrap()),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let digest = sha2::Sha256::digest(canonical)
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{b:02x}"))
|
||||||
|
.collect::<String>();
|
||||||
|
let collision_id = format!("runtime-input:notify:{digest}");
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/w/{TEST_WORKSPACE_ID}/runtimes/connected-chat/workers/{worker}/protocol/ws",
|
||||||
|
f.url.replacen("http://", "ws://", 1)
|
||||||
|
);
|
||||||
|
let mut request = url.into_client_request().unwrap();
|
||||||
|
request.headers_mut().insert(
|
||||||
|
"authorization",
|
||||||
|
format!("Bearer {}", f.owner).parse().unwrap(),
|
||||||
|
);
|
||||||
|
let (mut ws, _) = connect_async(request).await.unwrap();
|
||||||
|
let cases = [
|
||||||
|
(collision_id, "WS_ISOLATED_FROM_HTTP", true),
|
||||||
|
("a".repeat(128), "WS_ASCII_LIMIT", true),
|
||||||
|
("é".repeat(64), "WS_UTF8_LIMIT", true),
|
||||||
|
("b".repeat(129), "WS_ASCII_OVER_LIMIT", false),
|
||||||
|
(format!("{}x", "é".repeat(64)), "WS_UTF8_OVER_LIMIT", false),
|
||||||
|
("console-next-id".into(), "WS_NEXT_DRAFT", true),
|
||||||
|
];
|
||||||
|
let mut exchanges = Vec::new();
|
||||||
|
for (id, text, accepted) in &cases {
|
||||||
|
let method = protocol::Method::Notify {
|
||||||
|
notification_request_id: id.clone(),
|
||||||
|
message: (*text).into(),
|
||||||
|
};
|
||||||
|
let ack = console_notify_ack(&mut ws, &method).await;
|
||||||
|
assert_eq!(
|
||||||
|
matches!(ack, protocol::Event::NotificationAccepted { .. }),
|
||||||
|
*accepted,
|
||||||
|
"{ack:?}"
|
||||||
|
);
|
||||||
|
let mut delivered_ack = ack;
|
||||||
|
if *accepted {
|
||||||
|
// Lose the first ACK at the Console boundary, resend the exact wire
|
||||||
|
// input, and deliver the real retry ACK to the actual tracker below.
|
||||||
|
delivered_ack = console_notify_ack(&mut ws, &method).await;
|
||||||
|
assert!(matches!(
|
||||||
|
delivered_ack,
|
||||||
|
protocol::Event::NotificationAccepted { .. }
|
||||||
|
));
|
||||||
|
}
|
||||||
|
exchanges.push(
|
||||||
|
json!({"method":method,"event":delivered_ack,"accepted":accepted,"retry":accepted}),
|
||||||
|
);
|
||||||
|
if *accepted {
|
||||||
|
let changed = protocol::Method::Notify {
|
||||||
|
notification_request_id: id.clone(),
|
||||||
|
message: format!("CHANGED_{text}"),
|
||||||
|
};
|
||||||
|
let rejected = console_notify_ack(&mut ws, &changed).await;
|
||||||
|
assert!(
|
||||||
|
matches!(rejected, protocol::Event::NotificationRejected { .. }),
|
||||||
|
"{rejected:?}"
|
||||||
|
);
|
||||||
|
exchanges
|
||||||
|
.push(json!({"method":changed,"event":rejected,"accepted":false,"retry":false}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut console = tokio::process::Command::new("deno");
|
||||||
|
console
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.args(["run", "--quiet"])
|
||||||
|
.arg(
|
||||||
|
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||||
|
.join("../../tests/support/console_notify_admissions.ts"),
|
||||||
|
)
|
||||||
|
.arg(serde_json::to_string(&exchanges).unwrap());
|
||||||
|
let output = tokio::time::timeout(std::time::Duration::from_secs(30), console.output())
|
||||||
|
.await
|
||||||
|
.expect("Console admission tracker deadline")
|
||||||
|
.expect("Deno (normal test runner prerequisite)");
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"production Console admission tracker failed: {}{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||||
|
while !inputs
|
||||||
|
.recv()
|
||||||
|
.await
|
||||||
|
.expect("real model input")
|
||||||
|
.contains("WS_NEXT_DRAFT")
|
||||||
|
{}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("accepted Console input must reach next model request");
|
||||||
|
let history = session_store::read_retained_session_snapshot(
|
||||||
|
&f._dir.path().join("runtime/workers").join(worker),
|
||||||
|
&format!("worker-runtime-{worker}"),
|
||||||
|
2 * 1024 * 1024,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let notifications = history
|
||||||
|
.snapshot
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| match &entry.data {
|
||||||
|
protocol::SessionSnapshotEntryData::SystemItem {
|
||||||
|
item_kind, content, ..
|
||||||
|
} if item_kind == "notification" => Some(content.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n");
|
||||||
|
for (_, text, accepted) in cases {
|
||||||
|
assert_eq!(
|
||||||
|
notifications.matches(text).count(),
|
||||||
|
usize::from(accepted),
|
||||||
|
"one history input per accepted ID, none for rejection: {notifications}"
|
||||||
|
);
|
||||||
|
assert!(!notifications.contains(&format!("CHANGED_{text}")));
|
||||||
|
}
|
||||||
|
assert_eq!(notifications.matches("HTTP_CHAT_RECEIPT").count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn console_notify_ack(
|
||||||
|
ws: &mut tokio_tungstenite::WebSocketStream<
|
||||||
|
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||||
|
>,
|
||||||
|
method: &protocol::Method,
|
||||||
|
) -> protocol::Event {
|
||||||
|
let protocol::Method::Notify {
|
||||||
|
notification_request_id: expected,
|
||||||
|
..
|
||||||
|
} = method
|
||||||
|
else {
|
||||||
|
panic!("Console Notify required")
|
||||||
|
};
|
||||||
|
ws.send(TungsteniteMessage::Text(
|
||||||
|
protocol::stream::encode_method(method).unwrap().into(),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
tokio::time::timeout(std::time::Duration::from_secs(30), async {
|
||||||
|
loop {
|
||||||
|
let frame = ws.next().await.expect("real protocol WS frame").unwrap();
|
||||||
|
if let TungsteniteMessage::Text(text) = frame {
|
||||||
|
let event = protocol::stream::decode_event(&text).unwrap();
|
||||||
|
match &event {
|
||||||
|
protocol::Event::NotificationAccepted {
|
||||||
|
notification_request_id,
|
||||||
|
}
|
||||||
|
| protocol::Event::NotificationRejected {
|
||||||
|
notification_request_id,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(
|
||||||
|
notification_request_id, expected,
|
||||||
|
"client-visible ACK must preserve original request identity"
|
||||||
|
);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
protocol::Event::Error { .. } => panic!("unexpected protocol error: {event:?}"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("Console Notify ACK deadline")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { ComposerAdmissions } from "../../web/workspace/src/lib/workspace/console/composer-delivery.ts";
|
||||||
|
import type {
|
||||||
|
Event,
|
||||||
|
Method,
|
||||||
|
} from "../../web/workspace/src/lib/generated/protocol.ts";
|
||||||
|
|
||||||
|
// Rustの実WS結合試験で得たACKを既存Consoleの受付管理へ渡す。
|
||||||
|
// モデル・ブラウザ描画は対象外。Denoは通常テストrunnerと同じ前提。
|
||||||
|
type Exchange = {
|
||||||
|
method: Extract<Method, { method: "notify" }>;
|
||||||
|
event: Event;
|
||||||
|
accepted: boolean;
|
||||||
|
retry: boolean;
|
||||||
|
};
|
||||||
|
const exchanges: Exchange[] = JSON.parse(Deno.args[0]);
|
||||||
|
const admissions = new ComposerAdmissions();
|
||||||
|
for (const exchange of exchanges) {
|
||||||
|
const { method, event, accepted, retry } = exchange;
|
||||||
|
const snapshot = {
|
||||||
|
document: method.params.message,
|
||||||
|
content: method.params.message,
|
||||||
|
segments: [{ kind: "text" as const, content: method.params.message }],
|
||||||
|
pastes: [],
|
||||||
|
textPastes: [],
|
||||||
|
};
|
||||||
|
const record = admissions.begin("real-worker", method, snapshot, "notify");
|
||||||
|
if (JSON.stringify(record.method) !== JSON.stringify(method)) {
|
||||||
|
throw new Error("次のdraftが前の受付に置き換わった");
|
||||||
|
}
|
||||||
|
if (retry) {
|
||||||
|
admissions.disconnected("real-worker");
|
||||||
|
if (admissions.get("real-worker")?.status !== "unknown") {
|
||||||
|
throw new Error("ACK消失が拒否または成功になった");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
JSON.stringify(admissions.retry("real-worker")?.method) !==
|
||||||
|
JSON.stringify(method)
|
||||||
|
) {
|
||||||
|
throw new Error("再送で入力またはIDが変わった");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const result = admissions.acknowledge("real-worker", event);
|
||||||
|
if (!result || result.accepted !== accepted) {
|
||||||
|
throw new Error(
|
||||||
|
`実WSのACKが元のIDへ相関しない: ${JSON.stringify(exchange)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
accepted
|
||||||
|
? admissions.get("real-worker") !== null
|
||||||
|
: admissions.get("real-worker")?.status !== "rejected"
|
||||||
|
) {
|
||||||
|
throw new Error("ACK後の受付状態が不正");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`Console accepted/rejected/retry/next-draft: ${exchanges.length} real WS exchanges`,
|
||||||
|
);
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user