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, HostMode, 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::{BTreeMap, 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 pub document_rpc: bool,
66}
67
68struct ClientInner {
69 transport: Transport,
70 options: ClientOptions,
71 opening: Mutex<BTreeSet<String>>,
72 lifecycle: Mutex<ClientLifecycle>,
73 idle: Notify,
74 documents: Mutex<BTreeMap<String, Arc<DocumentPersistence>>>,
75}
76
77#[derive(Default)]
78struct ClientLifecycle {
79 active: usize,
80 stopping: bool,
81 managed_documents: bool,
82 opened_documents: bool,
83 shutdown: Option<watch::Receiver<Option<Result<()>>>>,
84}
85
86struct ActiveCall(Arc<ClientInner>);
87
88impl Drop for ActiveCall {
89 fn drop(&mut self) {
90 let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
91 lifecycle.active -= 1;
92 if lifecycle.active == 0 {
93 self.0.idle.notify_one();
94 }
95 }
96}
97
98#[derive(Default)]
99struct DirtyDocument {
100 confirmed: bool,
101 pending: usize,
102 generation: u64,
103}
104
105struct DocumentPersistence {
106 state: Arc<AtomicU8>,
107 dirty: Mutex<DirtyDocument>,
108}
109
110impl DocumentPersistence {
111 fn save_generation(&self) -> Option<u64> {
112 let dirty = self.dirty.lock().expect("SDK dirty document lock");
113 (dirty.pending == 0).then_some(dirty.generation)
114 }
115
116 fn saved(&self, generation: u64) {
117 let mut dirty = self.dirty.lock().expect("SDK dirty document lock");
118 if dirty.generation == generation {
119 dirty.confirmed = false;
120 }
121 }
122}
123
124struct MutationGuard {
125 document: Arc<DocumentPersistence>,
126 transmission: Arc<CallState>,
127 settled: bool,
128}
129
130impl MutationGuard {
131 fn new(
132 document: Arc<DocumentPersistence>,
133 transmission: Arc<CallState>,
134 transport: &Transport,
135 ) -> Self {
136 transport.retain_state();
137 {
138 let mut dirty = document.dirty.lock().expect("SDK dirty document lock");
139 dirty.pending += 1;
140 dirty.generation += 1;
141 }
142 Self {
143 document,
144 transmission,
145 settled: false,
146 }
147 }
148
149 fn settle(&mut self, applied: bool) {
150 let mut dirty = self.document.dirty.lock().expect("SDK dirty document lock");
151 dirty.pending -= 1;
152 dirty.confirmed |= applied;
153 self.settled = true;
154 }
155}
156
157impl Drop for MutationGuard {
158 fn drop(&mut self) {
159 if !self.settled {
160 self.settle(!self.transmission.cancel_before_send());
161 }
162 }
163}
164
165#[derive(Clone)]
166pub struct Client(Arc<ClientInner>);
167
168struct OpenGuard {
169 client: Client,
170 id: String,
171}
172impl Drop for OpenGuard {
173 fn drop(&mut self) {
174 self.client
175 .0
176 .opening
177 .lock()
178 .expect("SDK opening lock")
179 .remove(&self.id);
180 }
181}
182
183impl Client {
184 pub async fn connect(host: HostOptions) -> Result<Self> {
185 Self::connect_with_options(host, ClientOptions::default()).await
186 }
187
188 pub async fn connect_with_options(host: HostOptions, options: ClientOptions) -> Result<Self> {
189 if options
190 .default_change_mode
191 .as_deref()
192 .is_some_and(|mode| !["direct", "tracked"].contains(&mode))
193 {
194 return Err(Error::new(
195 "INVALID_ARGUMENT",
196 "default change mode must be direct or tracked",
197 ));
198 }
199 if options
200 .user_name
201 .as_deref()
202 .is_some_and(|name| trim_js_whitespace(name).is_empty())
203 || (options.user_email.is_some() && options.user_name.is_none())
204 {
205 return Err(Error::new(
206 "INVALID_ARGUMENT",
207 "user identity requires a non-empty name",
208 ));
209 }
210 Ok(Self(Arc::new(ClientInner {
211 transport: Transport::connect(host).await?,
212 options,
213 opening: Mutex::new(BTreeSet::new()),
214 lifecycle: Mutex::new(ClientLifecycle::default()),
215 idle: Notify::new(),
216 documents: Mutex::new(BTreeMap::new()),
217 })))
218 }
219
220 pub fn describe() -> Value {
222 contract()["introspection"]["overview"].clone()
223 }
224
225 pub fn describe_command(lookup: &str) -> Result<Value> {
226 let index = contract()["introspection"]["lookup"]
227 [trim_js_whitespace(lookup).to_lowercase()]
228 .as_u64()
229 .ok_or_else(|| {
230 Error::new(
231 "OPERATION_NOT_FOUND",
232 "no contract operation matches the requested lookup",
233 )
234 })?;
235 Ok(contract()["introspection"]["overview"]["operations"][index as usize].clone())
236 }
237
238 pub fn subscribe(&self) -> Notifications {
239 Notifications(self.0.transport.subscribe())
240 }
241 pub fn state_directory(&self) -> &Path {
242 self.0.transport.state_directory()
243 }
244
245 pub async fn open(&self, params: &DocOpenParams) -> Result<Document> {
246 self.open_with_options(params, OpenOptions::default()).await
247 }
248
249 pub async fn open_with_options(
250 &self,
251 params: &DocOpenParams,
252 options: OpenOptions,
253 ) -> Result<Document> {
254 let _active = self.begin_call()?;
255 let mut params = serde_json::to_value(params)
256 .map_err(|_| Error::new("INVALID_ARGUMENT", "open parameters could not be encoded"))?;
257 let session_id = match params.get("sessionId") {
258 Some(value) => value
259 .as_str()
260 .filter(|id| !id.is_empty())
261 .ok_or_else(|| Error::new("INVALID_ARGUMENT", "session ID must be non-empty"))?
262 .to_owned(),
263 None => fresh_session_id(),
264 };
265 {
266 let mut opening = self.0.opening.lock().expect("SDK opening lock");
267 if self.0.transport.has_session(&session_id) || !opening.insert(session_id.clone()) {
268 let mut error = Error::new(
269 "SESSION_ALREADY_OPEN",
270 "session ID is already open or opening",
271 );
272 error.details = Some(Box::new(json!({"sessionId":session_id})));
273 return Err(error);
274 }
275 }
276 let _guard = OpenGuard {
277 client: self.clone(),
278 id: session_id.clone(),
279 };
280 params["sessionId"] = json!(session_id);
281 if options.collaboration_auth.is_some()
282 && self.0.transport.options().mode == HostMode::Document
283 {
284 return Err(Self::unsupported_collaboration_auth(HostMode::Document));
285 }
286 let features = self.0.transport.features();
287 let structured = options.collaboration_auth.is_none()
288 && routing::can_open_document(¶ms, &options.invoke)
289 && (self.0.transport.options().mode == HostMode::Document
290 || self.0.options.document_rpc)
291 && policy()["documentRpcFeatures"]
292 .as_array()
293 .is_some_and(|required| {
294 required
295 .iter()
296 .all(|feature| features.contains(feature.as_str().unwrap_or("")))
297 });
298 if self.0.transport.options().mode == HostMode::Document && !structured {
299 return Err(Error::new(
300 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
301 "standalone document host cannot support these open parameters",
302 ));
303 }
304 let result = if structured {
305 if self.0.options.default_change_mode.is_some() {
306 self.require_feature("defaultChangeMode")?;
307 }
308 if self.0.options.user_name.is_some() {
309 self.require_feature("documentAuthor")?;
310 }
311 let mut request = json!({"sessionId":session_id,"path":params["doc"]});
312 if let Some(mode) = &self.0.options.default_change_mode {
313 request["defaultChangeMode"] = json!(mode);
314 }
315 if let Some(name) = &self.0.options.user_name {
316 let mut author = json!({"name":name});
317 if let Some(email) = &self.0.options.user_email {
318 author["email"] = json!(email);
319 }
320 request["author"] = author;
321 }
322 self.rpc("document.open", request, &options.invoke, true, true, true)
323 .await
324 .and_then(|response| {
325 routing::map_lifecycle(
326 "doc.open",
327 response,
328 &session_id,
329 params["doc"].as_str(),
330 )
331 })
332 } else {
333 if let Some(name) = &self.0.options.user_name {
334 if params.get("userName").is_none() {
335 params["userName"] = json!(name);
336 }
337 }
338 if let Some(email) = &self.0.options.user_email {
339 if params.get("userEmail").is_none() {
340 params["userEmail"] = json!(email);
341 }
342 }
343 self.invoke_cli(
344 "doc.open",
345 params,
346 &options.invoke,
347 options.collaboration_auth.as_ref(),
348 )
349 .await
350 };
351 let result = match result {
352 Ok(value) => serde_json::from_value::<DocOpenResult>(value).map_err(Error::decode),
353 Err(mut error) => {
354 error.operation_id = Some("doc.open".into());
355 if ["TIMEOUT", "HOST_DISCONNECTED", "HOST_PROTOCOL_ERROR"]
356 .contains(&error.code.as_str())
357 {
358 let _ = self.0.transport.shutdown().await;
359 error.outcome_unknown = true;
360 }
361 return Err(error);
362 }
363 };
364 let result = match result {
365 Ok(result) if result.context_id == session_id && result.active => result,
366 invalid => {
367 let _ = self.0.transport.shutdown().await;
370 let mut error = match invalid {
371 Err(error) => error,
372 Ok(_) => Error::new(
373 "HOST_PROTOCOL_ERROR",
374 "host returned an invalid open result",
375 ),
376 };
377 error.operation_id = Some("doc.open".into());
378 error.outcome_unknown = true;
379 return Err(error);
380 }
381 };
382 let state = self.0.transport.session_state(&session_id);
383 if state.load(Ordering::Acquire) != 0 {
384 return Err(Error::new(
385 "DOCUMENT_CLOSED",
386 "session closed while it was opening",
387 ));
388 }
389 self.0
390 .lifecycle
391 .lock()
392 .expect("SDK lifecycle lock")
393 .opened_documents = true;
394 let persistence = structured.then(|| {
395 let persistence = Arc::new(DocumentPersistence {
396 state: state.clone(),
397 dirty: Mutex::new(DirtyDocument::default()),
398 });
399 self.0
400 .documents
401 .lock()
402 .expect("SDK document lock")
403 .insert(session_id.clone(), persistence.clone());
404 self.0
405 .lifecycle
406 .lock()
407 .expect("SDK lifecycle lock")
408 .managed_documents = true;
409 persistence
410 });
411 Ok(Document(Arc::new(DocumentInner {
412 client: self.clone(),
413 session_id,
414 state,
415 structured,
416 open_result: result,
417 persistence,
418 })))
419 }
420
421 pub async fn shutdown(&self) -> Result<()> {
422 let mut completion = {
423 let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
424 if let Some(completion) = &lifecycle.shutdown {
425 completion.clone()
426 } else {
427 let (result, completion) = watch::channel(None);
428 lifecycle.stopping = true;
429 lifecycle.shutdown = Some(completion.clone());
430 let client = self.clone();
431 tokio::spawn(async move {
432 let outcome = client.shutdown_owned().await;
433 if outcome.as_ref().is_err_and(|error| !error.outcome_unknown)
434 && !client.0.transport.is_closed()
435 {
436 let mut lifecycle = client.0.lifecycle.lock().expect("SDK lifecycle lock");
437 lifecycle.stopping = false;
438 lifecycle.shutdown = None;
439 }
440 result.send_replace(Some(outcome));
441 });
442 completion
443 }
444 };
445 loop {
446 if let Some(result) = completion.borrow().clone() {
447 return result;
448 }
449 completion.changed().await.map_err(|_| {
450 Error::new(
451 "HOST_SHUTDOWN_FAILED",
452 "managed shutdown owner stopped before confirming completion",
453 )
454 })?;
455 }
456 }
457
458 fn begin_call(&self) -> Result<ActiveCall> {
459 let mut lifecycle = self.0.lifecycle.lock().expect("SDK lifecycle lock");
460 if lifecycle.stopping {
461 return Err(Error::new("HOST_DISCONNECTED", "host is disposing"));
462 }
463 lifecycle.active += 1;
464 Ok(ActiveCall(self.0.clone()))
465 }
466
467 async fn shutdown_owned(&self) -> Result<()> {
468 loop {
469 let idle = self.0.idle.notified();
470 if self.0.lifecycle.lock().expect("SDK lifecycle lock").active == 0 {
471 break;
472 }
473 idle.await;
474 }
475 self.0.transport.drain().await?;
476 if self.0.transport.is_closed() {
477 self.0.transport.shutdown().await?;
478 let unsaved = self
479 .0
480 .documents
481 .lock()
482 .expect("SDK document lock")
483 .values()
484 .any(|document| {
485 let dirty = document.dirty.lock().expect("SDK dirty document lock");
486 dirty.confirmed || dirty.pending != 0
487 });
488 if unsaved {
489 let mut error = Error::new(
490 "HOST_DISCONNECTED",
491 "host disconnected before dirty documents could be saved",
492 );
493 error.details = Some(Box::new(json!({
494 "path": self.state_directory(),
495 "sourceSaveConfirmed": false
496 })));
497 return Err(error);
498 }
499 return Ok(());
500 }
501 if !self
502 .0
503 .lifecycle
504 .lock()
505 .expect("SDK lifecycle lock")
506 .managed_documents
507 {
508 if !self
509 .0
510 .lifecycle
511 .lock()
512 .expect("SDK lifecycle lock")
513 .opened_documents
514 {
515 return self.0.transport.shutdown().await;
516 }
517 return self.0.transport.shutdown_checked().await.map_err(|error| {
518 let mut failure = Error::new(
519 "HOST_SHUTDOWN_FAILED",
520 "host shutdown failed; its state directory has been retained",
521 );
522 failure.outcome_unknown = error.outcome_unknown;
523 failure.exit_code = error.exit_code;
524 failure.details = Some(Box::new(json!({
525 "path": self.state_directory(),
526 "exitCode": failure.exit_code.as_deref(),
527 "cause": {"code": error.code, "details": error.details}
528 })));
529 failure
530 });
531 }
532 let documents: Vec<_> = self
533 .0
534 .documents
535 .lock()
536 .expect("SDK document lock")
537 .iter()
538 .map(|(id, state)| (id.clone(), state.clone()))
539 .collect();
540 for (id, document) in &documents {
541 if document.state.load(Ordering::Acquire) != 0 {
542 continue;
543 }
544 let dirty = {
545 let dirty = document.dirty.lock().expect("SDK dirty document lock");
546 dirty.confirmed || dirty.pending != 0
547 };
548 if !dirty {
549 continue;
550 }
551 self.0.transport.retain_state();
552 if !self
553 .0
554 .transport
555 .features()
556 .contains(policy()["features"]["sourceSave"].as_str().unwrap_or(""))
557 {
558 let mut error = Error::new(
559 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
560 "host cannot persist dirty source documents",
561 );
562 error.details = Some(Box::new(
563 json!({"feature":policy()["features"]["sourceSave"]}),
564 ));
565 return Err(error);
566 }
567 let generation = document.save_generation();
568 let response = self
569 .0
570 .transport
571 .request(
572 "document.save",
573 json!({"sessionId":id}),
574 None,
575 self.0.transport.options().request_timeout.saturating_add(
576 Duration::from_millis(
577 policy()["watchdogHeadroomMs"].as_u64().unwrap_or(5000),
578 ),
579 ),
580 true,
581 false,
582 )
583 .await;
584 let result = match response {
585 Err(error) => Err(error),
586 Ok(response) if response["inPlace"] != true => {
587 let mut error =
588 Error::new("HOST_PROTOCOL_ERROR", "host did not confirm a source save");
589 error.outcome_unknown = true;
590 Err(error)
591 }
592 Ok(response) => {
593 routing::map_lifecycle("doc.save", response, id, None).map_err(|mut error| {
594 error.outcome_unknown = true;
595 error
596 })
597 }
598 };
599 if let Err(error) = result {
600 if error.outcome_unknown {
601 let _ =
602 document
603 .state
604 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| {
605 (state < 2).then_some(3)
606 });
607 let _ = self.0.transport.retire_preserving_state().await;
608 }
609 return Err(error);
610 }
611 if let Some(generation) = generation {
612 document.saved(generation);
613 }
614 }
615 let active_documents: Vec<_> = documents
616 .iter()
617 .filter(|(_, document)| document.state.load(Ordering::Acquire) == 0)
618 .map(|(_, document)| document.state.clone())
619 .collect();
620 self.0.transport.shutdown_checked().await?;
621 for state in active_documents {
622 let _ = state.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
623 matches!(value, 0 | 3).then_some(2)
624 });
625 }
626 Ok(())
627 }
628
629 fn remove_document(&self, id: &str, state: &Arc<AtomicU8>) {
630 let mut documents = self.0.documents.lock().expect("SDK document lock");
631 if documents
632 .get(id)
633 .is_some_and(|entry| Arc::ptr_eq(&entry.state, state))
634 {
635 documents.remove(id);
636 }
637 }
638
639 pub fn presets(&self) -> DocApiPreset<'_, Self> {
640 DocApiPreset::new(self)
641 }
642
643 fn require_feature(&self, key: &str) -> Result<()> {
644 if !self
645 .0
646 .transport
647 .features()
648 .contains(policy()["features"][key].as_str().unwrap_or(""))
649 {
650 return Err(Error::new(
651 "CAPABILITY_UNSUPPORTED",
652 "connected host lacks the requested optional capability",
653 ));
654 }
655 Ok(())
656 }
657
658 fn unsupported_collaboration_auth(mode: HostMode) -> Error {
659 let mut error = Error::new(
660 "CAPABILITY_UNSUPPORTED",
661 if mode == HostMode::Document {
662 "Per-open collaboration authentication is not supported by the standalone document host."
663 } else {
664 "The connected CLI host does not support per-open collaboration authentication."
665 },
666 );
667 error.details = Some(Box::new(json!({
668 "feature": policy()["features"]["collaborationAuthPerOpen"],
669 })));
670 error.operation_id = Some("doc.open".into());
671 error
672 }
673
674 async fn rpc(
675 &self,
676 method: &str,
677 params: Value,
678 options: &InvokeOptions,
679 mutates: bool,
680 open: bool,
681 supports_deadline: bool,
682 ) -> Result<Value> {
683 self.rpc_tracked(
684 method,
685 params,
686 options,
687 mutates,
688 open,
689 supports_deadline,
690 None,
691 )
692 .await
693 }
694
695 #[allow(clippy::too_many_arguments)]
696 async fn rpc_tracked(
697 &self,
698 method: &str,
699 params: Value,
700 options: &InvokeOptions,
701 mutates: bool,
702 open: bool,
703 supports_deadline: bool,
704 state: Option<Arc<CallState>>,
705 ) -> Result<Value> {
706 let effective = options
707 .timeout
708 .unwrap_or(self.0.transport.options().request_timeout);
709 let headroom =
710 Duration::from_millis(policy()["watchdogHeadroomMs"].as_u64().unwrap_or(5000));
711 if options.timeout.is_some() {
712 self.require_feature("requestTimeout")?;
713 if !supports_deadline {
714 return Err(Error::new(
715 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
716 "this native operation does not support an explicit request deadline",
717 ));
718 }
719 }
720 let deadline = (supports_deadline
721 && self.0.transport.features().contains(
722 policy()["features"]["requestTimeout"]
723 .as_str()
724 .unwrap_or(""),
725 ))
726 .then_some(effective);
727 self.0
728 .transport
729 .request_tracked(
730 method,
731 params,
732 deadline,
733 effective.saturating_add(headroom),
734 mutates,
735 open,
736 state.unwrap_or_default(),
737 )
738 .await
739 }
740
741 fn reject_unsupported_dispatch_preview(
742 &self,
743 id: &str,
744 options: &InvokeOptions,
745 full: bool,
746 ) -> Result<()> {
747 if self.0.transport.options().mode == HostMode::Cli
748 && id == "doc.preset.dispatch"
749 && options.dry_run == Some(true)
750 && !full
751 {
752 let mut error = Error::new(
753 "CAPABILITY_UNSUPPORTED",
754 "The core preset dispatcher does not support dryRun previews.",
755 );
756 error.operation_id = Some(id.into());
757 return Err(error);
758 }
759 Ok(())
760 }
761
762 async fn invoke_cli(
763 &self,
764 id: &str,
765 params: Value,
766 options: &InvokeOptions,
767 auth: Option<&CollaborationAuth>,
768 ) -> Result<Value> {
769 self.invoke_cli_tracked(id, params, options, auth, None)
770 .await
771 }
772
773 async fn invoke_cli_tracked(
774 &self,
775 id: &str,
776 params: Value,
777 options: &InvokeOptions,
778 auth: Option<&CollaborationAuth>,
779 state: Option<Arc<CallState>>,
780 ) -> Result<Value> {
781 if self.0.transport.options().mode != HostMode::Cli {
782 if [
783 "doc.preset.list",
784 "doc.preset.getCatalog",
785 "doc.preset.getTools",
786 "doc.preset.getSystemPrompt",
787 "doc.preset.getMcpPrompt",
788 ]
789 .contains(&id)
790 {
791 if !self
792 .0
793 .transport
794 .features()
795 .contains("document.preset.full.v1")
796 {
797 let mut error = Error::new(
798 "CAPABILITY_UNSUPPORTED",
799 "host cannot provide preset metadata",
800 );
801 error.details = Some(Box::new(json!({"feature":"document.preset.full.v1"})));
802 return Err(error);
803 }
804 if auth.is_some() {
805 return Err(Error::new(
806 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
807 "preset metadata does not accept authentication",
808 ));
809 }
810 if options
811 .stdin_bytes
812 .as_ref()
813 .is_some_and(|bytes| bytes.len() > 32 * 1024 * 1024)
814 {
815 return Err(Error::new(
816 "PAYLOAD_TOO_LARGE",
817 "stdin exceeds the host's 32 MiB limit",
818 ));
819 }
820 validate_json(¶ms)?;
821 let operation = operation(id)?;
822 let params = Value::Object(routing::normalize_aliases(operation, ¶ms)?);
823 routing::argv(
824 operation,
825 ¶ms,
826 options,
827 self.0.options.default_change_mode.as_deref(),
828 )?;
829 return self
830 .rpc_tracked(
831 "preset.metadata",
832 json!({"operationId":id,"input":params}),
833 options,
834 false,
835 false,
836 true,
837 state,
838 )
839 .await;
840 }
841 return Err(Error::new(
842 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
843 "operation requires the CLI host",
844 ));
845 }
846 let operation = operation(id)?;
847 if auth.is_some() {
848 if id != "doc.open" {
849 return Err(Error::new(
850 "INVALID_ARGUMENT",
851 "authentication is scoped to document open",
852 ));
853 }
854 self.require_feature("collaborationAuthPerOpen")
855 .map_err(|_| Self::unsupported_collaboration_auth(HostMode::Cli))?;
856 }
857 if options
858 .stdin_bytes
859 .as_ref()
860 .is_some_and(|bytes| bytes.len() > 32 * 1024 * 1024)
861 {
862 return Err(Error::new(
863 "PAYLOAD_TOO_LARGE",
864 "stdin exceeds the host's 32 MiB limit",
865 ));
866 }
867 validate_json(¶ms)?;
868 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()});
869 if let Some(auth) = auth {
870 request["collaborationAuth"] = auth.json();
871 }
872 let response = self
873 .rpc_tracked(
874 "cli.invoke",
875 request,
876 options,
877 operation["mutates"] == true,
878 id == "doc.open",
879 true,
880 state,
881 )
882 .await?;
883 routing::map_cli(operation, response).map_err(|mut error| {
884 error.outcome_unknown = operation["mutates"] == true;
885 error
886 })
887 }
888
889 pub async fn get_tools(&self, provider: &str) -> Result<Value> {
890 let _active = self.begin_call()?;
891 self.invoke_cli(
892 "doc.preset.getTools",
893 json!({"provider":provider,"preset":"core"}),
894 &InvokeOptions::default(),
895 None,
896 )
897 .await
898 }
899
900 pub async fn get_system_prompt(&self) -> Result<Value> {
901 let _active = self.begin_call()?;
902 self.invoke_cli(
903 "doc.preset.getSystemPrompt",
904 json!({"preset":"core"}),
905 &InvokeOptions::default(),
906 None,
907 )
908 .await
909 }
910}
911
912impl Invoker for Client {
913 async fn invoke(&self, id: &str, params: Value, options: InvokeOptions) -> Result<Value> {
914 let _active = self.begin_call()?;
915 if ![
916 "doc.preset.list",
917 "doc.preset.getCatalog",
918 "doc.preset.getTools",
919 "doc.preset.getSystemPrompt",
920 "doc.preset.getMcpPrompt",
921 ]
922 .contains(&id)
923 || operation(id)?["requiresDocumentContext"] != false
924 || operation(id)?["mutates"] != false
925 {
926 return Err(Error::new(
927 "INVALID_ARGUMENT",
928 "client only invokes sessionless preset getters",
929 ));
930 }
931 self.invoke_cli(id, params, &options, None).await
932 }
933}
934
935fn operation(id: &str) -> Result<&'static Value> {
936 contract()["operations"].get(id).ok_or_else(|| {
937 Error::new(
938 "OPERATION_NOT_FOUND",
939 "operation is not in the generated contract",
940 )
941 })
942}
943
944fn fresh_session_id() -> String {
945 static NEXT: AtomicU64 = AtomicU64::new(1);
946 format!(
947 "rust-{}-{}-{}",
948 std::process::id(),
949 SystemTime::now()
950 .duration_since(UNIX_EPOCH)
951 .unwrap_or_default()
952 .as_nanos(),
953 NEXT.fetch_add(1, Ordering::Relaxed)
954 )
955}
956
957fn validate_json(value: &Value) -> Result<()> {
958 let mut stack = vec![(value, 0)];
959 while let Some((value, depth)) = stack.pop() {
960 if depth > 128 {
961 return Err(Error::new(
962 "INVALID_ARGUMENT",
963 "request JSON exceeds the supported nesting depth",
964 ));
965 }
966 match value {
967 Value::Number(number)
968 if number.is_i64()
969 && number.as_i64().is_some_and(|n| {
970 !(-crate::wire::Integer::MAX..=crate::wire::Integer::MAX).contains(&n)
971 }) =>
972 {
973 return Err(Error::new(
974 "INVALID_ARGUMENT",
975 "JSON integer cannot be represented exactly by the host",
976 ))
977 }
978 Value::Number(number)
979 if number.is_u64()
980 && number
981 .as_u64()
982 .is_some_and(|n| n > crate::wire::Integer::MAX as u64) =>
983 {
984 return Err(Error::new(
985 "INVALID_ARGUMENT",
986 "JSON integer cannot be represented exactly by the host",
987 ))
988 }
989 Value::Array(values) => stack.extend(values.iter().map(|value| (value, depth + 1))),
990 Value::Object(values) => stack.extend(values.values().map(|value| (value, depth + 1))),
991 _ => {}
992 }
993 }
994 Ok(())
995}
996
997struct DocumentInner {
998 client: Client,
999 session_id: String,
1000 state: Arc<AtomicU8>,
1002 structured: bool,
1003 open_result: DocOpenResult,
1004 persistence: Option<Arc<DocumentPersistence>>,
1005}
1006
1007impl Drop for DocumentInner {
1008 fn drop(&mut self) {
1009 if self.state.load(Ordering::Acquire) >= 2 {
1010 self.client.remove_document(&self.session_id, &self.state);
1011 self.client
1012 .0
1013 .transport
1014 .remove_session(&self.session_id, &self.state);
1015 }
1016 }
1017}
1018
1019#[derive(Clone)]
1020pub struct Document(Arc<DocumentInner>);
1021
1022struct CloseGuard {
1023 state: Arc<AtomicU8>,
1024 transmission: Arc<CallState>,
1025}
1026impl Drop for CloseGuard {
1027 fn drop(&mut self) {
1028 let _ = self.state.compare_exchange(
1029 1,
1030 if self.transmission.cancel_before_send() {
1031 0
1032 } else {
1033 3
1034 },
1035 Ordering::AcqRel,
1036 Ordering::Acquire,
1037 );
1038 }
1039}
1040
1041impl Document {
1042 pub fn session_id(&self) -> &str {
1043 &self.0.session_id
1044 }
1045 pub fn open_result(&self) -> &DocOpenResult {
1046 &self.0.open_result
1047 }
1048 pub fn api(&self) -> DocApi<'_, Self> {
1049 DocApi::new(self)
1050 }
1051
1052 pub async fn close(&self, params: &DocCloseBoundParams) -> Result<DocCloseResult> {
1053 self.api().close(params).await
1054 }
1055
1056 pub async fn replace_file(
1057 &self,
1058 path: impl AsRef<Path>,
1059 options: InvokeOptions,
1060 ) -> Result<Value> {
1061 self.active()?;
1062 let _active = self.0.client.begin_call()?;
1063 self.replace_path(path.as_ref(), options, None).await
1064 }
1065
1066 pub async fn replace_bytes(&self, bytes: &[u8], options: InvokeOptions) -> Result<Value> {
1069 use std::io::Write;
1070 if bytes.len() > 32 * 1024 * 1024 {
1071 return Err(Error::new(
1072 "PAYLOAD_TOO_LARGE",
1073 "replacement exceeds 32 MiB",
1074 ));
1075 }
1076 self.active()?;
1077 let _active = self.0.client.begin_call()?;
1078 let mut file = tempfile::NamedTempFile::new_in(self.0.client.state_directory())
1079 .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
1080 file.write_all(bytes)
1081 .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
1082 let asset = file.into_temp_path();
1083 let path = asset.to_path_buf();
1084 self.replace_path(&path, options, Some(CallState::with_asset(asset)))
1085 .await
1086 }
1087
1088 async fn replace_path(
1089 &self,
1090 path: &Path,
1091 options: InvokeOptions,
1092 state: Option<Arc<CallState>>,
1093 ) -> Result<Value> {
1094 self.active()?;
1095 self.0.client.require_feature("replaceFile")?;
1096 let path = path
1097 .to_str()
1098 .ok_or_else(|| Error::new("INVALID_ARGUMENT", "replacement path must be UTF-8"))?;
1099 let transmission = state.unwrap_or_default();
1100 let mut mutation = self.0.persistence.as_ref().map(|document| {
1101 MutationGuard::new(
1102 document.clone(),
1103 transmission.clone(),
1104 &self.0.client.0.transport,
1105 )
1106 });
1107 let response = self
1108 .0
1109 .client
1110 .rpc_tracked(
1111 "document.replaceFile",
1112 json!({"sessionId":self.0.session_id,"path":path}),
1113 &options,
1114 true,
1115 false,
1116 false,
1117 Some(transmission),
1118 )
1119 .await;
1120 if let Some(mutation) = &mut mutation {
1121 mutation.settle(
1122 response
1123 .as_ref()
1124 .map_or_else(|error| error.outcome_unknown, |_| true),
1125 );
1126 }
1127 let value = response?;
1128 if value["sessionId"] != self.0.session_id
1129 || value["replaced"] != true
1130 || !value["byteLength"].is_number()
1131 || !value["revision"].is_string()
1132 {
1133 let mut error = Error::new(
1134 "HOST_PROTOCOL_ERROR",
1135 "host returned an invalid replacement result",
1136 );
1137 error.outcome_unknown = true;
1138 return Err(error);
1139 }
1140 Ok(
1141 json!({"contextId":self.0.session_id,"runtime":"v2","replaced":true,"document":{"byteLength":value["byteLength"],"revision":value["revision"]}}),
1142 )
1143 }
1144
1145 pub(crate) async fn check_core_revision(
1146 &self,
1147 tool_name: &str,
1148 args: &Value,
1149 options: &InvokeOptions,
1150 configured: Option<&crate::generated::types::DocPresetGetToolsParams>,
1151 ) -> Result<Option<Value>> {
1152 let Some(expected) = options.expected_revision.as_deref() else {
1153 return Ok(None);
1154 };
1155 let params = serde_json::from_value(
1156 serde_json::json!({"offset":crate::wire::Integer::MAX,"limit":1}),
1157 )
1158 .map_err(Error::decode)?;
1159 let page = self.api().blocks().list(¶ms).await?;
1160 if page.revision.is_empty() || page.revision == "unknown" {
1161 let cli_reader = !self.0.structured
1162 && self.0.open_result.session_type == "local"
1163 && self
1164 .0
1165 .client
1166 .0
1167 .transport
1168 .features()
1169 .contains("cli.invoke.localExecutionFacts");
1170 return Err(if cli_reader {
1171 Error::new(
1172 "HOST_PROTOCOL_ERROR",
1173 "CLI returned an invalid execution revision.",
1174 )
1175 } else {
1176 Error::new(
1177 "EVIDENCE_UNAVAILABLE",
1178 "Required document revision is unavailable.",
1179 )
1180 });
1181 }
1182 if expected == page.revision {
1183 return Ok(None);
1184 }
1185 if tool_name == "superdoc_perform_action" {
1186 return crate::agent::stale_action_revision_receipt(args, &page.revision, configured)
1187 .map(Some);
1188 }
1189 Err(Error::new(
1190 "REVISION_CONFLICT",
1191 crate::agent::stale_revision_message(tool_name),
1192 ))
1193 }
1194
1195 pub async fn dispatch_tool(
1196 &self,
1197 name: &str,
1198 mut args: Value,
1199 mut options: InvokeOptions,
1200 ) -> Result<Value> {
1201 self.active()?;
1202 crate::agent::prepare_core_dispatch(name, &mut args, &mut options, None)?;
1203 self.0.client.reject_unsupported_dispatch_preview(
1204 "doc.preset.dispatch",
1205 &options,
1206 false,
1207 )?;
1208 if let Some(receipt) = self
1209 .check_core_revision(name, &args, &options, None)
1210 .await?
1211 {
1212 return Ok(json!({ "result": receipt }));
1213 }
1214 let _active = self.0.client.begin_call()?;
1215 let transmission = Arc::new(CallState::default());
1216 let mut mutation = self.0.persistence.as_ref().map(|document| {
1217 MutationGuard::new(
1218 document.clone(),
1219 transmission.clone(),
1220 &self.0.client.0.transport,
1221 )
1222 });
1223 let result = self
1224 .0
1225 .client
1226 .invoke_cli_tracked(
1227 "doc.preset.dispatch",
1228 json!({"sessionId":self.0.session_id,"toolName":name,"args":args,"preset":"core"}),
1229 &options,
1230 None,
1231 Some(transmission),
1232 )
1233 .await;
1234 if let Some(mutation) = &mut mutation {
1235 mutation.settle(result.as_ref().map_or_else(
1236 |error| error.outcome_unknown,
1237 |value| value["success"] != false,
1238 ));
1239 }
1240 result
1241 }
1242
1243 fn active(&self) -> Result<()> {
1244 match self.0.state.load(Ordering::Acquire) {
1245 0 if !self.0.client.0.transport.is_closed() => Ok(()),
1246 3 => Err(Error::new(
1247 "DOCUMENT_STATE_UNKNOWN",
1248 "document state is uncertain; use a new client to reopen verified saved content",
1249 )),
1250 _ => Err(Error::new(
1251 "DOCUMENT_CLOSED",
1252 "document handle is closed or closing",
1253 )),
1254 }
1255 }
1256}
1257
1258impl Invoker for Document {
1259 fn handle_decode_error(&self, error: Error) -> Error {
1260 if error.outcome_unknown {
1261 let _ = self
1262 .0
1263 .state
1264 .fetch_update(Ordering::AcqRel, Ordering::Acquire, |state| {
1265 (state < 2).then_some(3)
1266 });
1267 }
1268 error
1269 }
1270
1271 async fn invoke(
1272 &self,
1273 id: &str,
1274 mut params: Value,
1275 mut options: InvokeOptions,
1276 ) -> Result<Value> {
1277 self.active().map_err(|mut error| {
1278 if error.code == "DOCUMENT_CLOSED" && operation(id).is_ok() {
1279 error.message =
1280 format!("Document handle is closed or closing; cannot invoke {id}.");
1281 error.operation_id = Some(id.into());
1282 error.details = Some(Box::new(json!({
1283 "sessionId": self.0.session_id,
1284 "operationId": id
1285 })));
1286 }
1287 error
1288 })?;
1289 let _active = self.0.client.begin_call()?;
1290 let operation = operation(id)?;
1291 if operation["sdkSurface"] != "document" {
1292 return Err(Error::new(
1293 "INVALID_ARGUMENT",
1294 "operation is not on the public document surface",
1295 ));
1296 }
1297 self.0.client.reject_unsupported_dispatch_preview(
1298 id,
1299 &options,
1300 params.get("preset") == Some(&json!("full")),
1301 )?;
1302 let binds_session = operation["params"]
1303 .as_array()
1304 .is_some_and(|params| params.iter().any(|param| param["name"] == "sessionId"));
1305 if binds_session {
1306 params["sessionId"] = json!(self.0.session_id);
1307 }
1308 let closing = id == "doc.close";
1309 let transmission = closing.then(|| Arc::new(CallState::default()));
1310 let _close = if closing {
1311 self.0
1312 .state
1313 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
1314 .map_err(|_| Error::new("DOCUMENT_CLOSED", "document is already closing"))?;
1315 Some(CloseGuard {
1316 state: self.0.state.clone(),
1317 transmission: transmission.clone().expect("close transmission state"),
1318 })
1319 } else {
1320 None
1321 };
1322 if options.change_mode.is_none()
1323 && operation["params"]
1324 .as_array()
1325 .is_some_and(|params| params.iter().any(|param| param["name"] == "changeMode"))
1326 {
1327 options.change_mode = self.0.client.0.options.default_change_mode.clone();
1328 }
1329 let preserves_review =
1330 params.get("mode").is_none() || params["mode"] == "review-preserving";
1331 let save_generation = self
1332 .0
1333 .persistence
1334 .as_ref()
1335 .filter(|_| id == "doc.save")
1336 .and_then(|document| document.save_generation());
1337 let mut mutation = None;
1338 let mut observed_full_change = None;
1339 let result: Result<Value> = async {
1340 if self.0.structured && binds_session {
1341 let (method, request) = routing::document_request(
1342 &self.0.session_id,
1343 operation,
1344 ¶ms,
1345 &options,
1346 self.0.client.0.transport.features(),
1347 )?;
1348 let mutates = (method == "document.invoke" || method == "document.preset.dispatch")
1349 && operation["mutates"] == true
1350 && request["options"]["dryRun"] != true;
1351 let transmission = transmission.clone().unwrap_or_default();
1352 if mutates {
1353 mutation = self.0.persistence.as_ref().map(|document| {
1354 MutationGuard::new(
1355 document.clone(),
1356 transmission.clone(),
1357 &self.0.client.0.transport,
1358 )
1359 });
1360 }
1361 let safe_deadline = method != "document.save"
1362 && (operation["mutates"] == false
1363 || operation["idempotency"] == "idempotent"
1364 || request["options"]["dryRun"] == true
1365 || method != "document.invoke");
1366 validate_json(&request)?;
1367 let response = self
1368 .0
1369 .client
1370 .rpc_tracked(
1371 &method,
1372 request,
1373 &options,
1374 operation["mutates"] == true,
1375 false,
1376 safe_deadline,
1377 Some(transmission.clone()),
1378 )
1379 .await;
1380 let response = if method == "document.preset.dispatch" {
1381 observed_full_change = transmission.session_revision_changed();
1382 match response {
1383 Ok(mut response) => {
1384 let valid = response.as_object().is_some_and(|object| {
1385 let present = object.get("resultPresent").and_then(Value::as_bool);
1386 observed_full_change.is_some()
1387 && present.is_some()
1388 && object.contains_key("result") == present.unwrap_or(false)
1389 && object.len() == if present == Some(true) { 3 } else { 2 }
1390 });
1391 if !valid {
1392 let mut error = Error::new(
1393 "HOST_PROTOCOL_ERROR",
1394 "Full preset response has an invalid completion envelope",
1395 );
1396 error.outcome_unknown = true;
1397 Err(error)
1398 } else {
1399 if let Some(object) = response.as_object_mut() {
1400 object.remove("sessionRevisionChanged");
1401 object.remove("resultPresent");
1402 }
1403 Ok(response)
1404 }
1405 }
1406 Err(mut error) => {
1407 if observed_full_change.is_none() && transmission.transmission_started()
1408 {
1409 error.outcome_unknown = true;
1410 }
1411 Err(error)
1412 }
1413 }
1414 } else {
1415 response
1416 };
1417 response.and_then(|response| {
1418 if id == "doc.save" || closing {
1419 routing::map_lifecycle(id, response, &self.0.session_id, None).map_err(
1420 |mut error| {
1421 error.outcome_unknown = operation["mutates"] == true;
1422 error
1423 },
1424 )
1425 } else {
1426 Ok(response)
1427 }
1428 })
1429 } else {
1430 self.0
1431 .client
1432 .invoke_cli_tracked(id, params, &options, None, transmission)
1433 .await
1434 }
1435 }
1436 .await;
1437 let result = result.and_then(|value| routing::map_domain_result(operation, value));
1438 if let Some(mutation) = &mut mutation {
1439 mutation.settle(result.as_ref().map_or_else(
1440 |error| error.outcome_unknown || observed_full_change.unwrap_or(false),
1441 |value| observed_full_change.unwrap_or_else(|| value["success"] != false),
1442 ));
1443 }
1444 match result {
1445 Ok(value) => {
1446 if closing {
1447 if value["closed"] != true || value["contextId"] != self.0.session_id {
1448 let mut error = Error::new(
1449 "HOST_PROTOCOL_ERROR",
1450 "close was not confirmed by the host",
1451 );
1452 error.outcome_unknown = true;
1453 return Err(error);
1454 }
1455 self.0.state.store(2, Ordering::Release);
1456 self.0
1457 .client
1458 .remove_document(&self.0.session_id, &self.0.state);
1459 self.0
1460 .client
1461 .0
1462 .transport
1463 .remove_session(&self.0.session_id, &self.0.state);
1464 } else if id == "doc.save" && preserves_review {
1465 if let (Some(document), Some(generation)) =
1466 (&self.0.persistence, save_generation)
1467 {
1468 document.saved(generation);
1469 }
1470 }
1471 Ok(value)
1472 }
1473 Err(mut error) => {
1474 error.operation_id = Some(id.into());
1475 if closing && !error.outcome_unknown && !self.0.client.0.transport.is_closed() {
1476 let _ =
1477 self.0
1478 .state
1479 .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
1480 }
1481 Err(error)
1482 }
1483 }
1484 }
1485}
1486
1487pub struct Notifications(broadcast::Receiver<Value>);
1491impl Notifications {
1492 pub async fn recv(&mut self) -> Result<Value> {
1493 self.0.recv().await.map_err(|error| match error {
1494 broadcast::error::RecvError::Lagged(_) => Error::new(
1495 "NOTIFICATIONS_LAGGED",
1496 "resynchronize document state before using notification-derived caches",
1497 ),
1498 broadcast::error::RecvError::Closed => {
1499 Error::new("HOST_DISCONNECTED", "notification stream closed")
1500 }
1501 })
1502 }
1503}