Skip to main content

superdoc_sdk/
client.rs

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    /// Opt in to negotiated document RPC on the CLI host. Collaboration and
64    /// other ineligible opens keep their CLI route before any request is sent.
65    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    /// Local contract introspection requires no running host.
221    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(&params, &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                // An invalid open result cannot leave an unreachable native
368                // session alive. Retiring also invalidates any sibling handles.
369                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(&params)?;
821                let operation = operation(id)?;
822                let params = Value::Object(routing::normalize_aliases(operation, &params)?);
823                routing::argv(
824                    operation,
825                    &params,
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(&params)?;
868        let mut request = json!({"argv":routing::argv(operation,&params,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    // Active, closing, closed, or outcome unknown. All clones share this state.
1001    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    /// The transport owns staged bytes until the native reply or confirmed exit,
1067    /// including when the caller drops this future after transmission.
1068    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(&params).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                    &params,
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
1487/// Lag is explicit; callers must resynchronize rather than assume every remote
1488/// change notification was observed. Session invalidation runs in the reader's
1489/// owner independently of this public notification stream.
1490pub 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}