1use crate::{
2 generated::{
3 operations::DocApi,
4 types::{DocCloseBoundParams, DocCloseResult, DocOpenParams, DocOpenResult},
5 },
6 invoke::Invoker,
7 runtime::{
8 contract, policy,
9 process::{CallState, HostMode, HostOptions, Transport},
10 routing,
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;
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}
73
74#[derive(Clone)]
75pub struct Client(Arc<ClientInner>);
76
77struct OpenGuard {
78 client: Client,
79 id: String,
80}
81impl Drop for OpenGuard {
82 fn drop(&mut self) {
83 self.client
84 .0
85 .opening
86 .lock()
87 .expect("SDK opening lock")
88 .remove(&self.id);
89 }
90}
91
92impl Client {
93 pub async fn connect(host: HostOptions) -> Result<Self> {
94 Self::connect_with_options(host, ClientOptions::default()).await
95 }
96
97 pub async fn connect_with_options(host: HostOptions, options: ClientOptions) -> Result<Self> {
98 if options
99 .default_change_mode
100 .as_deref()
101 .is_some_and(|mode| !["direct", "tracked"].contains(&mode))
102 {
103 return Err(Error::new(
104 "INVALID_ARGUMENT",
105 "default change mode must be direct or tracked",
106 ));
107 }
108 if options
109 .user_name
110 .as_deref()
111 .is_some_and(|name| name.trim().is_empty())
112 || (options.user_email.is_some() && options.user_name.is_none())
113 {
114 return Err(Error::new(
115 "INVALID_ARGUMENT",
116 "user identity requires a non-empty name",
117 ));
118 }
119 Ok(Self(Arc::new(ClientInner {
120 transport: Transport::connect(host).await?,
121 options,
122 opening: Mutex::new(BTreeSet::new()),
123 })))
124 }
125
126 pub fn describe() -> Value {
128 contract()["introspection"]["overview"].clone()
129 }
130
131 pub fn describe_command(lookup: &str) -> Result<Value> {
132 let index = contract()["introspection"]["lookup"][lookup.trim().to_lowercase()]
133 .as_u64()
134 .ok_or_else(|| {
135 Error::new(
136 "OPERATION_NOT_FOUND",
137 "no contract operation matches the requested lookup",
138 )
139 })?;
140 Ok(contract()["introspection"]["overview"]["operations"][index as usize].clone())
141 }
142
143 pub fn subscribe(&self) -> Notifications {
144 Notifications(self.0.transport.subscribe())
145 }
146 pub fn state_directory(&self) -> &Path {
147 self.0.transport.state_directory()
148 }
149
150 pub async fn open(&self, params: &DocOpenParams) -> Result<Document> {
151 self.open_with_options(params, OpenOptions::default()).await
152 }
153
154 pub async fn open_with_options(
155 &self,
156 params: &DocOpenParams,
157 options: OpenOptions,
158 ) -> Result<Document> {
159 let mut params = serde_json::to_value(params)
160 .map_err(|_| Error::new("INVALID_ARGUMENT", "open parameters could not be encoded"))?;
161 let session_id = match params.get("sessionId") {
162 Some(value) => value
163 .as_str()
164 .filter(|id| !id.is_empty())
165 .ok_or_else(|| Error::new("INVALID_ARGUMENT", "session ID must be non-empty"))?
166 .to_owned(),
167 None => fresh_session_id(),
168 };
169 {
170 let mut opening = self.0.opening.lock().expect("SDK opening lock");
171 if self.0.transport.has_session(&session_id) || !opening.insert(session_id.clone()) {
172 return Err(Error::new(
173 "SESSION_ALREADY_OPEN",
174 "session ID is already open or opening",
175 ));
176 }
177 }
178 let _guard = OpenGuard {
179 client: self.clone(),
180 id: session_id.clone(),
181 };
182 params["sessionId"] = json!(session_id);
183 let features = self.0.transport.features();
184 let structured = options.collaboration_auth.is_none()
185 && routing::can_open_document(¶ms, &options.invoke)
186 && (self.0.transport.options().mode == HostMode::Document
187 || self.0.options.document_rpc)
188 && policy()["documentRpcFeatures"]
189 .as_array()
190 .is_some_and(|required| {
191 required
192 .iter()
193 .all(|feature| features.contains(feature.as_str().unwrap_or("")))
194 });
195 if self.0.transport.options().mode == HostMode::Document && !structured {
196 return Err(Error::new(
197 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
198 "standalone document host cannot support these open parameters",
199 ));
200 }
201 let result = if structured {
202 if self.0.options.default_change_mode.is_some() {
203 self.require_feature("defaultChangeMode")?;
204 }
205 if self.0.options.user_name.is_some() {
206 self.require_feature("documentAuthor")?;
207 }
208 let mut request = json!({"sessionId":session_id,"path":params["doc"]});
209 if let Some(mode) = &self.0.options.default_change_mode {
210 request["defaultChangeMode"] = json!(mode);
211 }
212 if let Some(name) = &self.0.options.user_name {
213 let mut author = json!({"name":name});
214 if let Some(email) = &self.0.options.user_email {
215 author["email"] = json!(email);
216 }
217 request["author"] = author;
218 }
219 self.rpc("document.open", request, &options.invoke, true, true, true)
220 .await
221 .and_then(|response| {
222 routing::map_lifecycle(
223 "doc.open",
224 response,
225 &session_id,
226 params["doc"].as_str(),
227 )
228 })
229 } else {
230 if let Some(name) = &self.0.options.user_name {
231 if params.get("userName").is_none() {
232 params["userName"] = json!(name);
233 }
234 }
235 if let Some(email) = &self.0.options.user_email {
236 if params.get("userEmail").is_none() {
237 params["userEmail"] = json!(email);
238 }
239 }
240 self.invoke_cli(
241 "doc.open",
242 params,
243 &options.invoke,
244 options.collaboration_auth.as_ref(),
245 )
246 .await
247 };
248 let result = match result {
249 Ok(value) => serde_json::from_value::<DocOpenResult>(value).map_err(Error::decode),
250 Err(mut error) => {
251 error.operation_id = Some("doc.open".to_owned());
252 if ["TIMEOUT", "HOST_DISCONNECTED", "HOST_PROTOCOL_ERROR"]
253 .contains(&error.code.as_str())
254 {
255 let _ = self.shutdown().await;
256 error.outcome_unknown = true;
257 }
258 return Err(error);
259 }
260 };
261 let result = match result {
262 Ok(result) if result.context_id == session_id && result.active => result,
263 invalid => {
264 let _ = self.shutdown().await;
267 let mut error = match invalid {
268 Err(error) => error,
269 Ok(_) => Error::new(
270 "HOST_PROTOCOL_ERROR",
271 "host returned an invalid open result",
272 ),
273 };
274 error.operation_id = Some("doc.open".into());
275 error.outcome_unknown = true;
276 return Err(error);
277 }
278 };
279 let state = self.0.transport.session_state(&session_id);
280 if state.load(Ordering::Acquire) != 0 {
281 return Err(Error::new(
282 "DOCUMENT_CLOSED",
283 "session closed while it was opening",
284 ));
285 }
286 Ok(Document(Arc::new(DocumentInner {
287 client: self.clone(),
288 session_id,
289 state,
290 structured,
291 open_result: result,
292 })))
293 }
294
295 pub async fn shutdown(&self) -> Result<()> {
296 self.0.transport.shutdown().await
297 }
298
299 fn require_feature(&self, key: &str) -> Result<()> {
300 if !self
301 .0
302 .transport
303 .features()
304 .contains(policy()["features"][key].as_str().unwrap_or(""))
305 {
306 return Err(Error::new(
307 "CAPABILITY_UNSUPPORTED",
308 "connected host lacks the requested optional capability",
309 ));
310 }
311 Ok(())
312 }
313
314 async fn rpc(
315 &self,
316 method: &str,
317 params: Value,
318 options: &InvokeOptions,
319 mutates: bool,
320 open: bool,
321 supports_deadline: bool,
322 ) -> Result<Value> {
323 self.rpc_tracked(
324 method,
325 params,
326 options,
327 mutates,
328 open,
329 supports_deadline,
330 None,
331 )
332 .await
333 }
334
335 #[allow(clippy::too_many_arguments)]
336 async fn rpc_tracked(
337 &self,
338 method: &str,
339 params: Value,
340 options: &InvokeOptions,
341 mutates: bool,
342 open: bool,
343 supports_deadline: bool,
344 state: Option<Arc<CallState>>,
345 ) -> Result<Value> {
346 let effective = options
347 .timeout
348 .unwrap_or(self.0.transport.options().request_timeout);
349 let headroom =
350 Duration::from_millis(policy()["watchdogHeadroomMs"].as_u64().unwrap_or(5000));
351 if options.timeout.is_some() {
352 self.require_feature("requestTimeout")?;
353 if !supports_deadline {
354 return Err(Error::new(
355 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
356 "this native operation does not support an explicit request deadline",
357 ));
358 }
359 }
360 let deadline = (supports_deadline
361 && self.0.transport.features().contains(
362 policy()["features"]["requestTimeout"]
363 .as_str()
364 .unwrap_or(""),
365 ))
366 .then_some(effective);
367 self.0
368 .transport
369 .request_tracked(
370 method,
371 params,
372 deadline,
373 effective.saturating_add(headroom),
374 mutates,
375 open,
376 state.unwrap_or_default(),
377 )
378 .await
379 }
380
381 async fn invoke_cli(
382 &self,
383 id: &str,
384 params: Value,
385 options: &InvokeOptions,
386 auth: Option<&CollaborationAuth>,
387 ) -> Result<Value> {
388 self.invoke_cli_tracked(id, params, options, auth, None)
389 .await
390 }
391
392 async fn invoke_cli_tracked(
393 &self,
394 id: &str,
395 params: Value,
396 options: &InvokeOptions,
397 auth: Option<&CollaborationAuth>,
398 state: Option<Arc<CallState>>,
399 ) -> Result<Value> {
400 if self.0.transport.options().mode != HostMode::Cli {
401 return Err(Error::new(
402 "DOCUMENT_RPC_INPUT_UNSUPPORTED",
403 "operation requires the CLI host",
404 ));
405 }
406 let operation = operation(id)?;
407 if auth.is_some() {
408 if id != "doc.open" {
409 return Err(Error::new(
410 "INVALID_ARGUMENT",
411 "authentication is scoped to document open",
412 ));
413 }
414 self.require_feature("collaborationAuthPerOpen")?;
415 }
416 if options
417 .stdin_bytes
418 .as_ref()
419 .is_some_and(|bytes| bytes.len() > 32 * 1024 * 1024)
420 {
421 return Err(Error::new(
422 "PAYLOAD_TOO_LARGE",
423 "stdin exceeds the host's 32 MiB limit",
424 ));
425 }
426 validate_json(¶ms)?;
427 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()});
428 if let Some(auth) = auth {
429 request["collaborationAuth"] = auth.json();
430 }
431 let response = self
432 .rpc_tracked(
433 "cli.invoke",
434 request,
435 options,
436 operation["mutates"] == true,
437 id == "doc.open",
438 true,
439 state,
440 )
441 .await?;
442 routing::map_cli(operation, response).map_err(|mut error| {
443 error.outcome_unknown = operation["mutates"] == true;
444 error
445 })
446 }
447
448 pub async fn get_tools(&self, provider: &str) -> Result<Value> {
449 self.invoke_cli(
450 "doc.preset.getTools",
451 json!({"provider":provider,"preset":"core"}),
452 &InvokeOptions::default(),
453 None,
454 )
455 .await
456 }
457
458 pub async fn get_system_prompt(&self) -> Result<Value> {
459 self.invoke_cli(
460 "doc.preset.getSystemPrompt",
461 json!({"preset":"core"}),
462 &InvokeOptions::default(),
463 None,
464 )
465 .await
466 }
467}
468
469fn operation(id: &str) -> Result<&'static Value> {
470 contract()["operations"].get(id).ok_or_else(|| {
471 Error::new(
472 "OPERATION_NOT_FOUND",
473 "operation is not in the generated contract",
474 )
475 })
476}
477
478fn fresh_session_id() -> String {
479 static NEXT: AtomicU64 = AtomicU64::new(1);
480 format!(
481 "rust-{}-{}-{}",
482 std::process::id(),
483 SystemTime::now()
484 .duration_since(UNIX_EPOCH)
485 .unwrap_or_default()
486 .as_nanos(),
487 NEXT.fetch_add(1, Ordering::Relaxed)
488 )
489}
490
491fn validate_json(value: &Value) -> Result<()> {
492 let mut stack = vec![(value, 0)];
493 while let Some((value, depth)) = stack.pop() {
494 if depth > 128 {
495 return Err(Error::new(
496 "INVALID_ARGUMENT",
497 "request JSON exceeds the supported nesting depth",
498 ));
499 }
500 match value {
501 Value::Number(number)
502 if number.is_i64()
503 && number.as_i64().is_some_and(|n| {
504 !(-crate::wire::Integer::MAX..=crate::wire::Integer::MAX).contains(&n)
505 }) =>
506 {
507 return Err(Error::new(
508 "INVALID_ARGUMENT",
509 "JSON integer cannot be represented exactly by the host",
510 ))
511 }
512 Value::Number(number)
513 if number.is_u64()
514 && number
515 .as_u64()
516 .is_some_and(|n| n > crate::wire::Integer::MAX as u64) =>
517 {
518 return Err(Error::new(
519 "INVALID_ARGUMENT",
520 "JSON integer cannot be represented exactly by the host",
521 ))
522 }
523 Value::Array(values) => stack.extend(values.iter().map(|value| (value, depth + 1))),
524 Value::Object(values) => stack.extend(values.values().map(|value| (value, depth + 1))),
525 _ => {}
526 }
527 }
528 Ok(())
529}
530
531struct DocumentInner {
532 client: Client,
533 session_id: String,
534 state: Arc<AtomicU8>,
536 structured: bool,
537 open_result: DocOpenResult,
538}
539
540impl Drop for DocumentInner {
541 fn drop(&mut self) {
542 if self.state.load(Ordering::Acquire) >= 2 {
543 self.client
544 .0
545 .transport
546 .remove_session(&self.session_id, &self.state);
547 }
548 }
549}
550
551#[derive(Clone)]
552pub struct Document(Arc<DocumentInner>);
553
554struct CloseGuard {
555 state: Arc<AtomicU8>,
556 transmission: Arc<CallState>,
557}
558impl Drop for CloseGuard {
559 fn drop(&mut self) {
560 let _ = self.state.compare_exchange(
561 1,
562 if self.transmission.cancel_before_send() {
563 0
564 } else {
565 3
566 },
567 Ordering::AcqRel,
568 Ordering::Acquire,
569 );
570 }
571}
572
573impl Document {
574 pub fn session_id(&self) -> &str {
575 &self.0.session_id
576 }
577 pub fn open_result(&self) -> &DocOpenResult {
578 &self.0.open_result
579 }
580 pub fn api(&self) -> DocApi<'_, Self> {
581 DocApi::new(self)
582 }
583
584 pub async fn close(&self, params: &DocCloseBoundParams) -> Result<DocCloseResult> {
585 self.api().close(params).await
586 }
587
588 pub async fn replace_file(
589 &self,
590 path: impl AsRef<Path>,
591 options: InvokeOptions,
592 ) -> Result<Value> {
593 self.replace_path(path.as_ref(), options, None).await
594 }
595
596 pub async fn replace_bytes(&self, bytes: &[u8], options: InvokeOptions) -> Result<Value> {
599 use std::io::Write;
600 if bytes.len() > 32 * 1024 * 1024 {
601 return Err(Error::new(
602 "PAYLOAD_TOO_LARGE",
603 "replacement exceeds 32 MiB",
604 ));
605 }
606 self.active()?;
607 let mut file = tempfile::NamedTempFile::new_in(self.0.client.state_directory())
608 .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
609 file.write_all(bytes)
610 .map_err(|_| Error::new("IO_ERROR", "could not stage replacement bytes"))?;
611 let asset = file.into_temp_path();
612 let path = asset.to_path_buf();
613 self.replace_path(&path, options, Some(CallState::with_asset(asset)))
614 .await
615 }
616
617 async fn replace_path(
618 &self,
619 path: &Path,
620 options: InvokeOptions,
621 state: Option<Arc<CallState>>,
622 ) -> Result<Value> {
623 self.active()?;
624 self.0.client.require_feature("replaceFile")?;
625 let path = path
626 .to_str()
627 .ok_or_else(|| Error::new("INVALID_ARGUMENT", "replacement path must be UTF-8"))?;
628 let value = self
629 .0
630 .client
631 .rpc_tracked(
632 "document.replaceFile",
633 json!({"sessionId":self.0.session_id,"path":path}),
634 &options,
635 true,
636 false,
637 false,
638 state,
639 )
640 .await?;
641 if value["sessionId"] != self.0.session_id
642 || value["replaced"] != true
643 || !value["byteLength"].is_number()
644 || !value["revision"].is_string()
645 {
646 let mut error = Error::new(
647 "HOST_PROTOCOL_ERROR",
648 "host returned an invalid replacement result",
649 );
650 error.outcome_unknown = true;
651 return Err(error);
652 }
653 Ok(
654 json!({"contextId":self.0.session_id,"runtime":"v2","replaced":true,"document":{"byteLength":value["byteLength"],"revision":value["revision"]}}),
655 )
656 }
657
658 pub async fn dispatch_tool(
659 &self,
660 name: &str,
661 args: Value,
662 options: InvokeOptions,
663 ) -> Result<Value> {
664 self.active()?;
665 self.0
666 .client
667 .invoke_cli(
668 "doc.preset.dispatch",
669 json!({"sessionId":self.0.session_id,"toolName":name,"args":args,"preset":"core"}),
670 &options,
671 None,
672 )
673 .await
674 }
675
676 fn active(&self) -> Result<()> {
677 match self.0.state.load(Ordering::Acquire) {
678 0 if !self.0.client.0.transport.is_closed() => Ok(()),
679 3 => Err(Error::new(
680 "DOCUMENT_STATE_UNKNOWN",
681 "document state is uncertain; use a new client to reopen verified saved content",
682 )),
683 _ => Err(Error::new(
684 "DOCUMENT_CLOSED",
685 "document handle is closed or closing",
686 )),
687 }
688 }
689}
690
691impl Invoker for Document {
692 async fn invoke(
693 &self,
694 id: &str,
695 mut params: Value,
696 mut options: InvokeOptions,
697 ) -> Result<Value> {
698 self.active()?;
699 let operation = operation(id)?;
700 if operation["sdkSurface"] != "document" {
701 return Err(Error::new(
702 "INVALID_ARGUMENT",
703 "operation is not on the public document surface",
704 ));
705 }
706 let binds_session = operation["params"]
707 .as_array()
708 .is_some_and(|params| params.iter().any(|param| param["name"] == "sessionId"));
709 if binds_session {
710 params["sessionId"] = json!(self.0.session_id);
711 }
712 let closing = id == "doc.close";
713 let transmission = closing.then(|| Arc::new(CallState::default()));
714 let _close = if closing {
715 self.0
716 .state
717 .compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
718 .map_err(|_| Error::new("DOCUMENT_CLOSED", "document is already closing"))?;
719 Some(CloseGuard {
720 state: self.0.state.clone(),
721 transmission: transmission.clone().expect("close transmission state"),
722 })
723 } else {
724 None
725 };
726 if options.change_mode.is_none()
727 && operation["params"]
728 .as_array()
729 .is_some_and(|params| params.iter().any(|param| param["name"] == "changeMode"))
730 {
731 options.change_mode = self.0.client.0.options.default_change_mode.clone();
732 }
733 let result: Result<Value> = async {
734 if self.0.structured && binds_session {
735 let (method, request) = routing::document_request(
736 &self.0.session_id,
737 operation,
738 ¶ms,
739 &options,
740 self.0.client.0.transport.features(),
741 )?;
742 let safe_deadline = method != "document.save"
743 && (operation["mutates"] == false
744 || operation["idempotency"] == "idempotent"
745 || request["options"]["dryRun"] == true
746 || method != "document.invoke");
747 validate_json(&request)?;
748 let response = self
749 .0
750 .client
751 .rpc_tracked(
752 &method,
753 request,
754 &options,
755 operation["mutates"] == true,
756 false,
757 safe_deadline,
758 transmission.clone(),
759 )
760 .await;
761 response.and_then(|response| {
762 if id == "doc.save" || closing {
763 routing::map_lifecycle(id, response, &self.0.session_id, None).map_err(
764 |mut error| {
765 error.outcome_unknown = operation["mutates"] == true;
766 error
767 },
768 )
769 } else {
770 Ok(response)
771 }
772 })
773 } else {
774 self.0
775 .client
776 .invoke_cli_tracked(id, params, &options, None, transmission)
777 .await
778 }
779 }
780 .await;
781 let result = result.and_then(|value| routing::map_domain_result(operation, value));
782 match result {
783 Ok(value) => {
784 if closing {
785 if value["closed"] != true || value["contextId"] != self.0.session_id {
786 let mut error = Error::new(
787 "HOST_PROTOCOL_ERROR",
788 "close was not confirmed by the host",
789 );
790 error.outcome_unknown = true;
791 return Err(error);
792 }
793 self.0.state.store(2, Ordering::Release);
794 self.0
795 .client
796 .0
797 .transport
798 .remove_session(&self.0.session_id, &self.0.state);
799 }
800 Ok(value)
801 }
802 Err(mut error) => {
803 error.operation_id = Some(id.to_owned());
804 if closing && !error.outcome_unknown && !self.0.client.0.transport.is_closed() {
805 let _ =
806 self.0
807 .state
808 .compare_exchange(1, 0, Ordering::AcqRel, Ordering::Acquire);
809 }
810 Err(error)
811 }
812 }
813 }
814}
815
816pub struct Notifications(broadcast::Receiver<Value>);
820impl Notifications {
821 pub async fn recv(&mut self) -> Result<Value> {
822 self.0.recv().await.map_err(|error| match error {
823 broadcast::error::RecvError::Lagged(_) => Error::new(
824 "NOTIFICATIONS_LAGGED",
825 "resynchronize document state before using notification-derived caches",
826 ),
827 broadcast::error::RecvError::Closed => {
828 Error::new("HOST_DISCONNECTED", "notification stream closed")
829 }
830 })
831 }
832}