1use crate::{
2 generated::{
3 operations::{DocApi, DocApiPreset},
4 types::{DocCloseBoundParams, DocCloseResult, DocOpenParams, DocOpenResult},
5 },
6 invoke::Invoker,
7 runtime::{
8 contract, policy,
9 process::{CallState, HostOptions, Transport},
10 routing, trim_js_whitespace,
11 },
12 Error, InvokeOptions, Result,
13};
14use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
15use serde_json::{json, Value};
16use std::{
17 collections::BTreeSet,
18 path::Path,
19 sync::{
20 atomic::{AtomicU64, AtomicU8, Ordering},
21 Arc, Mutex,
22 },
23 time::{Duration, SystemTime, UNIX_EPOCH},
24};
25use tokio::sync::{broadcast, watch, Notify};
26
27#[derive(Clone)]
28pub struct CollaborationAuth {
29 token: String,
30}
31impl CollaborationAuth {
32 pub fn token(token: impl Into<String>) -> Result<Self> {
33 let token = token.into();
34 if token.is_empty() {
35 return Err(Error::new(
36 "INVALID_ARGUMENT",
37 "collaboration token must not be empty",
38 ));
39 }
40 Ok(Self { token })
41 }
42 fn json(&self) -> Value {
43 json!({"type":"token","token":self.token})
44 }
45}
46impl std::fmt::Debug for CollaborationAuth {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 f.write_str("CollaborationAuth([redacted])")
49 }
50}
51
52#[derive(Clone, Debug, Default)]
53pub struct OpenOptions {
54 pub invoke: InvokeOptions,
55 pub collaboration_auth: Option<CollaborationAuth>,
56}
57
58#[derive(Clone, Default)]
59pub struct ClientOptions {
60 pub default_change_mode: Option<String>,
61 pub user_name: Option<String>,
62 pub user_email: Option<String>,
63}
64
65struct ClientInner {
66 transport: Transport,
67 options: ClientOptions,
68 opening: Mutex<BTreeSet<String>>,
69 lifecycle: Mutex<ClientLifecycle>,
70 idle: Notify,
71}
72
73#[derive(Default)]
74struct ClientLifecycle {
75 active: usize,
76 stopping: bool,
77 opened_documents: bool,
78 shutdown: Option<watch::Receiver<Option<Result<()>>>>,
79}
80
81struct ActiveCall(Arc<ClientInner>);
82
83impl Drop for ActiveCall {
84 fn drop(&mut self) {
85 let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
86 lifecycle.active -= 1;
87 if lifecycle.active == 0 {
88 self.0.idle.notify_one();
89 }
90 }
91}
92
93#[derive(Clone)]
94pub struct Client(Arc<ClientInner>);
95
96struct OpenGuard {
97 client: Client,
98 id: String,
99}
100impl Drop for OpenGuard {
101 fn drop(&mut self) {
102 self.client
103 .0
104 .opening
105 .lock()
106 .expect("SDK opening lock")
107 .remove(&self.id);
108 }
109}
110
111impl Client {
112 pub async fn connect(host: HostOptions) -> Result<Self> {
113 Self::connect_with_options(host, ClientOptions::default()).await
114 }
115
116 pub async fn connect_with_options(host: HostOptions, options: ClientOptions) -> Result<Self> {
117 if options
118 .default_change_mode
119 .as_deref()
120 .is_some_and(|mode| !["direct", "tracked"].contains(&mode))
121 {
122 return Err(Error::new(
123 "INVALID_ARGUMENT",
124 "default change mode must be direct or tracked",
125 ));
126 }
127 if options
128 .user_name
129 .as_deref()
130 .is_some_and(|name| trim_js_whitespace(name).is_empty())
131 || (options.user_email.is_some() && options.user_name.is_none())
132 {
133 return Err(Error::new(
134 "INVALID_ARGUMENT",
135 "user identity requires a non-empty name",
136 ));
137 }
138 Ok(Self(Arc::new(ClientInner {
139 transport: Transport::connect(host).await?,
140 options,
141 opening: Mutex::new(BTreeSet::new()),
142 lifecycle: Mutex::new(ClientLifecycle::default()),
143 idle: Notify::new(),
144 })))
145 }
146
147 pub fn describe() -> Value {
149 contract()["introspection"]["overview"].clone()
150 }
151
152 pub fn describe_command(lookup: &str) -> Result<Value> {
153 let index = contract()["introspection"]["lookup"]
154 [trim_js_whitespace(lookup).to_lowercase()]
155 .as_u64()
156 .ok_or_else(|| {
157 Error::new(
158 "OPERATION_NOT_FOUND",
159 "no contract operation matches the requested lookup",
160 )
161 })?;
162 Ok(contract()["introspection"]["overview"]["operations"][index as usize].clone())
163 }
164
165 pub fn subscribe(&self) -> Notifications {
166 Notifications(self.0.transport.subscribe())
167 }
168 pub fn state_directory(&self) -> &Path {
169 self.0.transport.state_directory()
170 }
171
172 pub async fn open(&self, params: &DocOpenParams) -> Result<Document> {
173 self.open_with_options(params, OpenOptions::default()).await
174 }
175
176 pub async fn open_with_options(
177 &self,
178 params: &DocOpenParams,
179 options: OpenOptions,
180 ) -> Result<Document> {
181 let _active = self.begin_call()?;
182 let mut params = serde_json::to_value(params)
183 .map_err(|_| Error::new("INVALID_ARGUMENT", "open parameters could not be encoded"))?;
184 let session_id = match params.get("sessionId") {
185 Some(value) => value
186 .as_str()
187 .filter(|id| !id.is_empty())
188 .ok_or_else(|| Error::new("INVALID_ARGUMENT", "session ID must be non-empty"))?
189 .to_owned(),
190 None => fresh_session_id(),
191 };
192 {
193 let mut opening = self.0.opening.lock().expect("SDK opening lock");
194 if self.0.transport.has_session(&session_id) || !opening.insert(session_id.clone()) {
195 let mut error = Error::new(
196 "SESSION_ALREADY_OPEN",
197 "session ID is already open or opening",
198 );
199 error.details = Some(Box::new(json!({"sessionId":session_id})));
200 return Err(error);
201 }
202 }
203 let _guard = OpenGuard {
204 client: self.clone(),
205 id: session_id.clone(),
206 };
207 params["sessionId"] = json!(session_id);
208
209 let result = {
210 if let Some(name) = &self.0.options.user_name {
211 if params.get("userName").is_none() {
212 params["userName"] = json!(name);
213 }
214 }
215 if let Some(email) = &self.0.options.user_email {
216 if params.get("userEmail").is_none() {
217 params["userEmail"] = json!(email);
218 }
219 }
220 self.invoke_cli(
221 "doc.open",
222 params,
223 &options.invoke,
224 options.collaboration_auth.as_ref(),
225 )
226 .await
227 };
228 let result = match result {
229 Ok(value) => serde_json::from_value::<DocOpenResult>(value).map_err(Error::decode),
230 Err(mut error) => {
231 error.operation_id = Some("doc.open".into());
232 if ["TIMEOUT", "HOST_DISCONNECTED", "HOST_PROTOCOL_ERROR"]
233 .contains(&error.code.as_str())
234 {
235 let _ = self.0.transport.shutdown().await;
236 error.outcome_unknown = true;
237 }
238 return Err(error);
239 }
240 };
241 let result = match result {
242 Ok(result) if result.context_id == session_id && result.active => result,
243 invalid => {
244 let _ = self.0.transport.shutdown().await;
247 let mut error = match invalid {
248 Err(error) => error,
249 Ok(_) => Error::new(
250 "HOST_PROTOCOL_ERROR",
251 "host returned an invalid open result",
252 ),
253 };
254 error.operation_id = Some("doc.open".into());
255 error.outcome_unknown = true;
256 return Err(error);
257 }
258 };
259 let state = self.0.transport.session_state(&session_id);
260 if state.load(Ordering::Acquire) != 0 {
261 return Err(Error::new(
262 "DOCUMENT_CLOSED",
263 "session closed while it was opening",
264 ));
265 }
266 self.0
267 .lifecycle
268 .lock()
269 .expect("SDK lifecycle lock")
270 .opened_documents = true;
271
272 Ok(Document(Arc::new(DocumentInner {
273 client: self.clone(),
274 session_id,
275 state,
276 open_result: result,
277 })))
278 }
279
280 pub async fn shutdown(&self) -> Result<()> {
281 let mut completion = {
282 let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
283 if let Some(completion) = &lifecycle.shutdown {
284 completion.clone()
285 } else {
286 let (result, completion) = watch::channel(None);
287 lifecycle.stopping = true;
288 lifecycle.shutdown = Some(completion.clone());
289 let client = self.clone();
290 tokio::spawn(async move {
291 let outcome = client.shutdown_owned().await;
292 if outcome.as_ref().is_err_and(|error| !error.outcome_unknown)
293 && !client.0.transport.is_closed()
294 {
295 let mut lifecycle = client.0.lifecycle.lock().expect("SDK lifecycle lock");
296 lifecycle.stopping = false;
297 lifecycle.shutdown = None;
298 }
299 result.send_replace(Some(outcome));
300 });
301 completion
302 }
303 };
304 loop {
305 if let Some(result) = completion.borrow().clone() {
306 return result;
307 }
308 completion.changed().await.map_err(|_| {
309 Error::new(
310 "HOST_SHUTDOWN_FAILED",
311 "managed shutdown owner stopped before confirming completion",
312 )
313 })?;
314 }
315 }
316
317 fn begin_call(&self) -> Result<ActiveCall> {
318 let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
319 if lifecycle.stopping {
320 return Err(Error::new("HOST_DISCONNECTED", "host is disposing"));
321 }
322 lifecycle.active += 1;
323 Ok(ActiveCall(self.0.clone()))
324 }
325
326 async fn shutdown_owned(&self) -> Result<()> {
327 loop {
328 let idle = self.0.idle.notified();
329 if self.0.lifecycle.lock().expect("SDK lifecycle lock").active == 0 {
330 break;
331 }
332 idle.await;
333 }
334 self.0.transport.drain().await?;
335 if self.0.transport.is_closed() {
336 return self.0.transport.shutdown().await;
337 }
338 if !self
339 .0
340 .lifecycle
341 .lock()
342 .expect("SDK lifecycle lock")
343 .opened_documents
344 {
345 return self.0.transport.shutdown().await;
346 }
347 self.0.transport.shutdown_checked().await.map_err(|error| {
348 let mut failure = Error::new(
349 "HOST_SHUTDOWN_FAILED",
350 "host shutdown failed; its state directory has been retained",
351 );
352 failure.outcome_unknown = error.outcome_unknown;
353 failure.exit_code = error.exit_code;
354 failure.details = Some(Box::new(json!({
355 "path": self.state_directory(),
356 "exitCode": failure.exit_code.as_deref(),
357 "cause": {"code": error.code, "details": error.details}
358 })));
359 failure
360 })
361 }
362
363 pub fn presets(&self) -> DocApiPreset<'_, Self> {
364 DocApiPreset::new(self)
365 }
366
367 fn require_feature(&self, key: &str) -> Result<()> {
368 if !self
369 .0
370 .transport
371 .features()
372 .contains(policy()["features"][key].as_str().unwrap_or(""))
373 {
374 return Err(Error::new(
375 "CAPABILITY_UNSUPPORTED",
376 "connected host lacks the requested optional capability",
377 ));
378 }
379 Ok(())
380 }
381
382 fn unsupported_collaboration_auth() -> Error {
383 let mut error = Error::new("CAPABILITY_UNSUPPORTED", {
384 "The connected CLI host does not support per-open collaboration authentication."
385 });
386 error.details = Some(Box::new(json!({
387 "feature": policy()["features"]["collaborationAuthPerOpen"],
388 })));
389 error.operation_id = Some("doc.open".into());
390 error
391 }
392
393 #[allow(clippy::too_many_arguments)]
394 async fn rpc_tracked(
395 &self,
396 method: &str,
397 params: Value,
398 options: &InvokeOptions,
399 mutates: bool,
400 open: bool,
401 supports_deadline: bool,
402 state: Option<Arc<CallState>>,
403 ) -> Result<Value> {
404 let effective = options
405 .timeout
406 .unwrap_or(self.0.transport.options().request_timeout);
407 let headroom =
408 Duration::from_millis(policy()["watchdogHeadroomMs"].as_u64().unwrap_or(5000));
409 if options.timeout.is_some() {
410 self.require_feature("requestTimeout")?;
411 if !supports_deadline {
412 return Err(Error::new(
413 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
414 "this native operation does not support an explicit request deadline",
415 ));
416 }
417 }
418 let deadline = (supports_deadline
419 && self.0.transport.features().contains(
420 policy()["features"]["requestTimeout"]
421 .as_str()
422 .unwrap_or(""),
423 ))
424 .then_some(effective);
425 self.0
426 .transport
427 .request_tracked(
428 method,
429 params,
430 deadline,
431 effective.saturating_add(headroom),
432 mutates,
433 open,
434 state.unwrap_or_default(),
435 )
436 .await
437 }
438
439 fn reject_unsupported_dispatch_preview(
440 &self,
441 id: &str,
442 options: &InvokeOptions,
443 full: bool,
444 ) -> Result<()> {
445 if id == "doc.preset.dispatch" && options.dry_run == Some(true) && !full {
446 let mut error = Error::new(
447 "CAPABILITY_UNSUPPORTED",
448 "The core preset dispatcher does not support dryRun previews.",
449 );
450 error.operation_id = Some(id.into());
451 return Err(error);
452 }
453 Ok(())
454 }
455
456 async fn invoke_cli(
457 &self,
458 id: &str,
459 params: Value,
460 options: &InvokeOptions,
461 auth: Option<&CollaborationAuth>,
462 ) -> Result<Value> {
463 self.invoke_cli_tracked(id, params, options, auth, None)
464 .await
465 }
466
467 async fn invoke_cli_tracked(
468 &self,
469 id: &str,
470 params: Value,
471 options: &InvokeOptions,
472 auth: Option<&CollaborationAuth>,
473 state: Option<Arc<CallState>>,
474 ) -> Result<Value> {
475 let operation = operation(id)?;
476 if auth.is_some() {
477 if id != "doc.open" {
478 return Err(Error::new(
479 "INVALID_ARGUMENT",
480 "authentication is scoped to document open",
481 ));
482 }
483 self.require_feature("collaborationAuthPerOpen")
484 .map_err(|_| Self::unsupported_collaboration_auth())?;
485 }
486 if options
487 .stdin_bytes
488 .as_ref()
489 .is_some_and(|bytes| bytes.len() > 32 * 1024 * 1024)
490 {
491 return Err(Error::new(
492 "PAYLOAD_TOO_LARGE",
493 "stdin exceeds the host's 32 MiB limit",
494 ));
495 }
496 validate_json(¶ms)?;
497 let mut request = json!({"argv":routing::argv(operation,¶ms,options,self.0.options.default_change_mode.as_deref())?,"stdinBase64":options.stdin_bytes.as_ref().map(|bytes|BASE64.encode(bytes)).unwrap_or_default()});
498 if let Some(auth) = auth {
499 request["collaborationAuth"] = auth.json();
500 }
501 let response = self
502 .rpc_tracked(
503 "cli.invoke",
504 request,
505 options,
506 operation["mutates"] == true,
507 id == "doc.open",
508 true,
509 state,
510 )
511 .await?;
512 routing::map_cli(operation, response).map_err(|mut error| {
513 error.outcome_unknown = operation["mutates"] == true;
514 error
515 })
516 }
517
518 pub async fn get_tools(&self, provider: &str) -> Result<Value> {
519 let _active = self.begin_call()?;
520 self.invoke_cli(
521 "doc.preset.getTools",
522 json!({"provider":provider,"preset":"core"}),
523 &InvokeOptions::default(),
524 None,
525 )
526 .await
527 }
528
529 pub async fn get_system_prompt(&self) -> Result<Value> {
530 let _active = self.begin_call()?;
531 self.invoke_cli(
532 "doc.preset.getSystemPrompt",
533 json!({"preset":"core"}),
534 &InvokeOptions::default(),
535 None,
536 )
537 .await
538 }
539}
540
541impl Invoker for Client {
542 async fn invoke(&self, id: &str, params: Value, options: InvokeOptions) -> Result<Value> {
543 let _active = self.begin_call()?;
544 if ![
545 "doc.preset.list",
546 "doc.preset.getCatalog",
547 "doc.preset.getTools",
548 "doc.preset.getSystemPrompt",
549 "doc.preset.getMcpPrompt",
550 ]
551 .contains(&id)
552 || operation(id)?["requiresDocumentContext"] != false
553 || operation(id)?["mutates"] != false
554 {
555 return Err(Error::new(
556 "INVALID_ARGUMENT",
557 "client only invokes sessionless preset getters",
558 ));
559 }
560 self.invoke_cli(id, params, &options, None).await
561 }
562}
563
564fn operation(id: &str) -> Result<&'static Value> {
565 contract()["operations"].get(id).ok_or_else(|| {
566 Error::new(
567 "OPERATION_NOT_FOUND",
568 "operation is not in the generated contract",
569 )
570 })
571}
572
573fn fresh_session_id() -> String {
574 static NEXT: AtomicU64 = AtomicU64::new(1);
575 format!(
576 "rust-{}-{}-{}",
577 std::process::id(),
578 SystemTime::now()
579 .duration_since(UNIX_EPOCH)
580 .unwrap_or_default()
581 .as_nanos(),
582 NEXT.fetch_add(1, Ordering::Relaxed)
583 )
584}
585
586fn validate_json(value: &Value) -> Result<()> {
587 let mut stack = vec![(value, 0)];
588 while let Some((value, depth)) = stack.pop() {
589 if depth > 128 {
590 return Err(Error::new(
591 "INVALID_ARGUMENT",
592 "request JSON exceeds the supported nesting depth",
593 ));
594 }
595 match value {
596 Value::Number(number)
597 if number.is_i64()
598 && number.as_i64().is_some_and(|n| {
599 !(-crate::wire::Integer::MAX..=crate::wire::Integer::MAX).contains(&n)
600 }) =>
601 {
602 return Err(Error::new(
603 "INVALID_ARGUMENT",
604 "JSON integer cannot be represented exactly by the host",
605 ))
606 }
607 Value::Number(number)
608 if number.is_u64()
609 && number
610 .as_u64()
611 .is_some_and(|n| n > crate::wire::Integer::MAX as u64) =>
612 {
613 return Err(Error::new(
614 "INVALID_ARGUMENT",
615 "JSON integer cannot be represented exactly by the host",
616 ))
617 }
618 Value::Array(values) => stack.extend(values.iter().map(|value| (value, depth + 1))),
619 Value::Object(values) => stack.extend(values.values().map(|value| (value, depth + 1))),
620 _ => {}
621 }
622 }
623 Ok(())
624}
625
626struct DocumentInner {
627 client: Client,
628 session_id: String,
629 state: Arc<AtomicU8>,
631 open_result: DocOpenResult,
632}
633
634impl Drop for DocumentInner {
635 fn drop(&mut self) {
636 if self.state.load(Ordering::Acquire) >= 2 {
637 self.client
638 .0
639 .transport
640 .remove_session(&self.session_id, &self.state);
641 }
642 }
643}
644
645#[derive(Clone)]
646pub struct Document(Arc<DocumentInner>);
647
648struct CloseGuard {
649 state: Arc<AtomicU8>,
650 transmission: Arc<CallState>,
651}
652impl Drop for CloseGuard {
653 fn drop(&mut self) {
654 let _ = self.state.compare_exchange(
655 1,
656 if self.transmission.cancel_before_send() {
657 0
658 } else {
659 3
660 },
661 Ordering::AcqRel,
662 Ordering::Acquire,
663 );
664 }
665}
666
667impl Document {
668 pub fn session_id(&self) -> &str {
669 &self.0.session_id
670 }
671 pub fn open_result(&self) -> &DocOpenResult {
672 &self.0.open_result
673 }
674 pub fn api(&self) -> DocApi<'_, Self> {
675 DocApi::new(self)
676 }
677
678 pub async fn close(&self, params: &DocCloseBoundParams) -> Result<DocCloseResult> {
679 self.api().close(params).await
680 }
681
682 pub async fn replace_file(
683 &self,
684 path: impl AsRef<Path>,
685 options: InvokeOptions,
686 ) -> Result<Value> {
687 self.active()?;
688 let _active = self.0.client.begin_call()?;
689 self.replace_path(path.as_ref(), options, None).await
690 }
691
692 pub async fn replace_bytes(&self, bytes: &[u8], options: InvokeOptions) -> Result<Value> {
695 use std::io::Write;
696 if bytes.len() > 32 * 1024 * 1024 {
697 return Err(Error::new(
698 "PAYLOAD_TOO_LARGE",
699 "replacement exceeds 32 MiB",
700 ));
701 }
702 self.active()?;
703 let _active = self.0.client.begin_call()?;
704 let mut file = tempfile::NamedTempFile::new_in(self.0.client.state_directory())
705 .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
706 file.write_all(bytes)
707 .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
708 let asset = file.into_temp_path();
709 let path = asset.to_path_buf();
710 self.replace_path(&path, options, Some(CallState::with_asset(asset)))
711 .await
712 }
713
714 async fn replace_path(
715 &self,
716 path: &Path,
717 options: InvokeOptions,
718 state: Option<Arc<CallState>>,
719 ) -> Result<Value> {
720 self.active()?;
721 self.0.client.require_feature("replaceFile")?;
722 let path = path
723 .to_str()
724 .ok_or_else(|| Error::new("INVALID_ARGUMENT", "replacement path must be UTF-8"))?;
725 let transmission = state.unwrap_or_default();
726
727 let response = self
728 .0
729 .client
730 .rpc_tracked(
731 "document.replaceFile",
732 json!({"sessionId":self.0.session_id,"path":path}),
733 &options,
734 true,
735 false,
736 false,
737 Some(transmission),
738 )
739 .await;
740
741 let value = response?;
742 if value["sessionId"] != self.0.session_id
743 || value["replaced"] != true
744 || !value["byteLength"].is_number()
745 || !value["revision"].is_string()
746 {
747 let mut error = Error::new(
748 "HOST_PROTOCOL_ERROR",
749 "host returned an invalid replacement result",
750 );
751 error.outcome_unknown = true;
752 return Err(error);
753 }
754 Ok(
755 json!({"contextId":self.0.session_id,"runtime":"v2","replaced":true,"document":{"byteLength":value["byteLength"],"revision":value["revision"]}}),
756 )
757 }
758
759 pub(crate) async fn check_core_revision(
760 &self,
761 tool_name: &str,
762 args: &Value,
763 options: &InvokeOptions,
764 configured: Option<&crate::generated::types::DocPresetGetToolsParams>,
765 ) -> Result<Option<Value>> {
766 let Some(expected) = options.expected_revision.as_deref() else {
767 return Ok(None);
768 };
769 let params = serde_json::from_value(
770 serde_json::json!({"offset":crate::wire::Integer::MAX,"limit":1}),
771 )
772 .map_err(Error::decode)?;
773 let page = self.api().blocks().list(¶ms).await?;
774 if page.revision.is_empty() || page.revision == "unknown" {
775 let cli_reader = self.0.open_result.session_type == "local"
776 && self
777 .0
778 .client
779 .0
780 .transport
781 .features()
782 .contains("cli.invoke.localExecutionFacts");
783 return Err(if cli_reader {
784 Error::new(
785 "HOST_PROTOCOL_ERROR",
786 "CLI returned an invalid execution revision.",
787 )
788 } else {
789 Error::new(
790 "EVIDENCE_UNAVAILABLE",
791 "Required document revision is unavailable.",
792 )
793 });
794 }
795 if expected == page.revision {
796 return Ok(None);
797 }
798 if tool_name == "superdoc_perform_action" {
799 return crate::agent::stale_action_revision_receipt(args, &page.revision, configured)
800 .map(Some);
801 }
802 Err(Error::new(
803 "REVISION_CONFLICT",
804 crate::agent::stale_revision_message(tool_name),
805 ))
806 }
807
808 pub async fn dispatch_tool(
809 &self,
810 name: &str,
811 mut args: Value,
812 mut options: InvokeOptions,
813 ) -> Result<Value> {
814 self.active()?;
815 crate::agent::prepare_core_dispatch(name, &mut args, &mut options, None)?;
816 self.0.client.reject_unsupported_dispatch_preview(
817 "doc.preset.dispatch",
818 &options,
819 false,
820 )?;
821 if let Some(receipt) = self
822 .check_core_revision(name, &args, &options, None)
823 .await?
824 {
825 return Ok(json!({ "result": receipt }));
826 }
827 let _active = self.0.client.begin_call()?;
828 let transmission = Arc::new(CallState::default());
829
830 let result = self
831 .0
832 .client
833 .invoke_cli_tracked(
834 "doc.preset.dispatch",
835 json!({"sessionId":self.0.session_id,"toolName":name,"args":args,"preset":"core"}),
836 &options,
837 None,
838 Some(transmission),
839 )
840 .await;
841
842 result
843 }
844
845 fn active(&self) -> Result<()> {
846 match self.0.state.load(Ordering::Acquire) {
847 0 if !self.0.client.0.transport.is_closed() => Ok(()),
848 3 => Err(Error::new(
849 "DOCUMENT_STATE_UNKNOWN",
850 "document state is uncertain; use a new client to reopen verified saved content",
851 )),
852 _ => Err(Error::new(
853 "DOCUMENT_CLOSED",
854 "document handle is closed or closing",
855 )),
856 }
857 }
858}
859
860impl Invoker for Document {
861 fn handle_decode_error(&self, error: Error) -> Error {
862 if error.outcome_unknown {
863 let _ = self
864 .0
865 .state
866 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| {
867 (state < 2).then_some(3)
868 });
869 }
870 error
871 }
872
873 async fn invoke(
874 &self,
875 id: &str,
876 mut params: Value,
877 mut options: InvokeOptions,
878 ) -> Result<Value> {
879 self.active().map_err(|mut error| {
880 if error.code == "DOCUMENT_CLOSED" && operation(id).is_ok() {
881 error.message =
882 format!("Document handle is closed or closing; cannot invoke {id}.");
883 error.operation_id = Some(id.into());
884 error.details = Some(Box::new(json!({
885 "sessionId": self.0.session_id,
886 "operationId": id
887 })));
888 }
889 error
890 })?;
891 let _active = self.0.client.begin_call()?;
892 let operation = operation(id)?;
893 if operation["sdkSurface"] != "document" {
894 return Err(Error::new(
895 "INVALID_ARGUMENT",
896 "operation is not on the public document surface",
897 ));
898 }
899 self.0.client.reject_unsupported_dispatch_preview(
900 id,
901 &options,
902 params.get("preset") == Some(&json!("full")),
903 )?;
904 let binds_session = operation["params"]
905 .as_array()
906 .is_some_and(|params| params.iter().any(|param| param["name"] == "sessionId"));
907 if binds_session {
908 params["sessionId"] = json!(self.0.session_id);
909 }
910 let closing = id == "doc.close";
911 let transmission = closing.then(|| Arc::new(CallState::default()));
912 let _close = if closing {
913 self.0
914 .state
915 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
916 .map_err(|_| Error::new("DOCUMENT_CLOSED", "document is already closing"))?;
917 Some(CloseGuard {
918 state: self.0.state.clone(),
919 transmission: transmission.clone().expect("close transmission state"),
920 })
921 } else {
922 None
923 };
924 if options.change_mode.is_none()
925 && operation["params"]
926 .as_array()
927 .is_some_and(|params| params.iter().any(|param| param["name"] == "changeMode"))
928 {
929 options.change_mode = self.0.client.0.options.default_change_mode.clone();
930 }
931 let result = self
932 .0
933 .client
934 .invoke_cli_tracked(id, params, &options, None, transmission)
935 .await
936 .and_then(|value| routing::map_domain_result(operation, value));
937 match result {
938 Ok(value) => {
939 if closing {
940 if value["closed"] != true || value["contextId"] != self.0.session_id {
941 let mut error = Error::new(
942 "HOST_PROTOCOL_ERROR",
943 "close was not confirmed by the host",
944 );
945 error.outcome_unknown = true;
946 return Err(error);
947 }
948 self.0.state.store(2, Ordering::Release);
949 self.0
950 .client
951 .0
952 .transport
953 .remove_session(&self.0.session_id, &self.0.state);
954 }
955 Ok(value)
956 }
957 Err(mut error) => {
958 error.operation_id = Some(id.into());
959 if closing && !error.outcome_unknown && !self.0.client.0.transport.is_closed() {
960 let _ =
961 self.0
962 .state
963 .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
964 }
965 Err(error)
966 }
967 }
968 }
969}
970
971pub struct Notifications(broadcast::Receiver<Value>);
975impl Notifications {
976 pub async fn recv(&mut self) -> Result<Value> {
977 self.0.recv().await.map_err(|error| match error {
978 broadcast::error::RecvError::Lagged(_) => Error::new(
979 "NOTIFICATIONS_LAGGED",
980 "resynchronize document state before using notification-derived caches",
981 ),
982 broadcast::error::RecvError::Closed => {
983 Error::new("HOST_DISCONNECTED", "notification stream closed")
984 }
985 })
986 }
987}