1mod environment_manager;
4mod transport;
5
6use std::{
7 collections::HashMap,
8 sync::{
9 atomic::{AtomicBool, Ordering},
10 mpsc, Arc, Mutex,
11 },
12 thread,
13 time::Duration,
14};
15
16use self::environment_manager::EnvironmentAttachmentManager;
17use serde_json::{json, Value};
18use starweaver_rpc_core::{
19 attachment_result, environment_attachment_refs, environment_attachment_result,
20 handle_json_rpc_text_async, notification, output_item, replay_cursor_from_params,
21 replay_result, stream_payload_format, EnvironmentActiveListParams,
22 EnvironmentActiveMountParams, EnvironmentActiveUnmountParams, RpcError, StreamPayloadFormat,
23 INVALID_PARAMS, METHOD_NOT_FOUND, RUN_CONFLICT, SERVER_ERROR, UNSUPPORTED_FEATURE,
24};
25use starweaver_stream::ReplayScope;
26use tokio::runtime::Runtime;
27use uuid::Uuid;
28
29use crate::{
30 args::{HitlPolicy, OutputMode, RpcCommand, RpcTransport, RunCommand},
31 client_state,
32 config::{get_config_value, read_current_session, write_current_session, CliConfig},
33 environment::validate_envd_attachment_transport,
34 local_store::{LocalStore, LocalStreamArchive},
35 profiles::{list_config_model_profiles, list_profiles, show_profile},
36 runner::CliSteeringMessage,
37 runtime_coordinator::{CliRuntimeCoordinator, RunAttachment, RunStreamEvent, StartedRun},
38 CliError, CliResult, CliService,
39};
40
41const PROTOCOL_VERSION: &str = "2026-06-08";
42
43impl From<CliError> for RpcError {
44 fn from(error: CliError) -> Self {
45 Self::new(SERVER_ERROR, error.to_string())
46 }
47}
48
49pub fn run(config: &CliConfig, command: &RpcCommand) -> CliResult<()> {
51 match command.transport {
52 RpcTransport::Stdio => transport::run_stdio(config),
53 RpcTransport::Http => transport::run_http(config, &command.host, command.port),
54 }
55}
56
57#[derive(Clone, Copy, Debug, Eq, PartialEq)]
58enum RpcNotificationMode {
59 Live,
60 ReplayOnly,
61}
62
63impl RpcNotificationMode {
64 const fn supports_live_notifications(self) -> bool {
65 matches!(self, Self::Live)
66 }
67}
68
69struct RpcService {
70 config: CliConfig,
71 coordinator: CliRuntimeCoordinator,
72 output_sender: mpsc::Sender<Value>,
73 subscriptions: Arc<Mutex<HashMap<String, Arc<AtomicBool>>>>,
74 notifications: RpcNotificationMode,
75 runtime: Arc<Runtime>,
76 environment_manager: EnvironmentAttachmentManager,
77}
78
79impl RpcService {
80 fn new(config: CliConfig, output_sender: mpsc::Sender<Value>) -> Self {
81 Self::with_notification_mode(config, output_sender, RpcNotificationMode::Live)
82 }
83
84 fn replay_only(config: CliConfig, output_sender: mpsc::Sender<Value>) -> Self {
85 Self::with_notification_mode(config, output_sender, RpcNotificationMode::ReplayOnly)
86 }
87
88 fn with_notification_mode(
89 config: CliConfig,
90 output_sender: mpsc::Sender<Value>,
91 notifications: RpcNotificationMode,
92 ) -> Self {
93 let environment_manager = EnvironmentAttachmentManager::new();
94 Self {
95 coordinator: CliRuntimeCoordinator::new(config.clone()),
96 config,
97 output_sender,
98 subscriptions: Arc::new(Mutex::new(HashMap::new())),
99 notifications,
100 environment_manager,
101 runtime: build_rpc_runtime(),
102 }
103 }
104
105 fn handle_line(&self, line: &str) -> (Option<Value>, bool) {
106 self.handle_text(line)
107 }
108
109 fn handle_text(&self, text: &str) -> (Option<Value>, bool) {
110 let outcome = self.runtime.block_on(self.handle_text_async(text));
111 (outcome.response, outcome.shutdown)
112 }
113
114 async fn handle_text_async(&self, text: &str) -> starweaver_rpc_core::JsonRpcOutcome {
115 handle_json_rpc_text_async(text, |method, params| async move {
116 self.dispatch_async(&method, ¶ms).await
117 })
118 .await
119 }
120
121 #[allow(clippy::too_many_lines)]
122 async fn dispatch_async(&self, method: &str, params: &Value) -> Result<Value, RpcError> {
123 let config = &self.config;
124 match method {
125 "initialize" => Ok(initialize_result(config, self.notifications)),
126 "shutdown" => Ok(json!({"status": "shutdown"})),
127 "profile.list" => Ok(json!({
128 "profiles": list_profiles(config),
129 "current": selected_profile_result(config, params.get("client").and_then(Value::as_str))?,
130 })),
131 "model.list" => Ok(json!({
132 "profiles": list_config_model_profiles(config),
133 "current": selected_model_profile_result(config, params.get("client").and_then(Value::as_str))?,
134 })),
135 "profile.get" => {
136 let name = required_string(params, "name")?;
137 let yaml = show_profile(config, &name).map_err(RpcError::from)?;
138 Ok(json!({"name": name, "profile": yaml}))
139 }
140 "model.current" => {
141 selected_model_profile_result(config, params.get("client").and_then(Value::as_str))
142 }
143 "model.select" => {
144 let profile = required_string(params, "profile")?;
145 ensure_client_model_profile(config, &profile)?;
146 let client = params
147 .get("client")
148 .and_then(Value::as_str)
149 .unwrap_or("tui");
150 client_state::write_selected_profile(config, client, &profile)
151 .map_err(RpcError::from)?;
152 Ok(json!({
153 "client": client,
154 "selectedProfile": profile,
155 "modelId": model_id_for_profile(config, &profile),
156 }))
157 }
158 "config.get" => config_get(config, params),
159 "diagnostics.get" => Ok(json!({
160 "sdk": starweaver_core::sdk_name(),
161 "version": env!("CARGO_PKG_VERSION"),
162 "globalDir": config.global_dir,
163 "projectDir": config.project_dir,
164 "tuiStateDir": config.tui_state_dir,
165 "desktopStateDir": config.desktop_state_dir,
166 "databasePath": config.database_path,
167 "defaultProfile": config.default_profile,
168 "profiles": list_profiles(config).len(),
169 })),
170 "session.create" => {
171 let profile = params
172 .get("profile")
173 .and_then(Value::as_str)
174 .unwrap_or(&config.default_profile);
175 let title = params
176 .get("title")
177 .and_then(Value::as_str)
178 .map(ToString::to_string);
179 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
180 let session = store
181 .create_session(profile, title)
182 .map_err(RpcError::from)?;
183 Ok(json!({"session": session}))
184 }
185 "session.list" => {
186 let limit = params
187 .get("limit")
188 .and_then(Value::as_u64)
189 .and_then(|value| usize::try_from(value).ok())
190 .unwrap_or(50);
191 let store = LocalStore::open(config).map_err(RpcError::from)?;
192 let sessions = store.list_sessions(limit).map_err(RpcError::from)?;
193 Ok(json!({"sessions": sessions}))
194 }
195 "session.get" => {
196 let session_id = required_string(params, "sessionId")?;
197 let runs_limit = params
198 .get("runs")
199 .and_then(Value::as_u64)
200 .and_then(|value| usize::try_from(value).ok())
201 .unwrap_or(20);
202 let store = LocalStore::open(config).map_err(RpcError::from)?;
203 let session = store.load_session(&session_id).map_err(RpcError::from)?;
204 let runs = store
205 .list_runs(&session_id, runs_limit)
206 .map_err(RpcError::from)?;
207 Ok(json!({"session": session, "runs": runs}))
208 }
209 "session.current.get" => Ok(json!({
210 "sessionId": read_current_session(config).map_err(RpcError::from)?,
211 })),
212 "session.current.set" => {
213 let session_id = required_string(params, "sessionId")?;
214 write_current_session(config, &session_id).map_err(RpcError::from)?;
215 Ok(json!({"sessionId": session_id}))
216 }
217 "session.replay" | "stream.replay" => self.stream_replay(params),
218 "session.delete" => {
219 let session_id = required_string(params, "sessionId")?;
220 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
221 let deleted = store.delete_session(&session_id).map_err(RpcError::from)?;
222 Ok(json!({"sessionId": session_id, "deleted": deleted}))
223 }
224 "session.output" => self.session_output(params),
225 "stream.subscribe" => self.stream_subscribe(params),
226 "stream.unsubscribe" => self.stream_unsubscribe(params),
227 "run.prompt" => self.run_prompt(params).await,
228 "run.start" => self.run_start(params).await,
229 "run.attach" => self.run_attach(params),
230 "run.status" => self.run_status(params),
231 "run.await" => self.run_await(params),
232 "run.cancel" => self.run_cancel(params),
233 "run.steer" => self.run_steer(params),
234 "session.steer" => self.session_steer(params),
235 "approval.list" => {
236 let store = LocalStore::open(config).map_err(RpcError::from)?;
237 let records = store
238 .list_approvals(
239 params.get("sessionId").and_then(Value::as_str),
240 params.get("runId").and_then(Value::as_str),
241 )
242 .map_err(RpcError::from)?;
243 Ok(json!({"approvals": records}))
244 }
245 "approval.show" => {
246 let approval_id = required_string(params, "approvalId")?;
247 let store = LocalStore::open(config).map_err(RpcError::from)?;
248 let approval = store.load_approval(&approval_id).map_err(RpcError::from)?;
249 Ok(json!({"approval": approval}))
250 }
251 "approval.decide" => {
252 let approval_id = required_string(params, "approvalId")?;
253 let status = match required_string(params, "status")?.as_str() {
254 "approved" | "approve" => starweaver_session::ApprovalStatus::Approved,
255 "denied" | "rejected" | "reject" => starweaver_session::ApprovalStatus::Denied,
256 other => {
257 return Err(RpcError::new(
258 INVALID_PARAMS,
259 format!("unknown approval status: {other}"),
260 ))
261 }
262 };
263 let reason = params
264 .get("reason")
265 .and_then(Value::as_str)
266 .map(ToString::to_string);
267 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
268 let approval = store
269 .decide_approval(&approval_id, status, reason)
270 .map_err(RpcError::from)?;
271 Ok(json!({"approval": approval}))
272 }
273 "deferred.list" => {
274 let store = LocalStore::open(config).map_err(RpcError::from)?;
275 let records = store
276 .list_deferred_tools(
277 params.get("sessionId").and_then(Value::as_str),
278 params.get("runId").and_then(Value::as_str),
279 )
280 .map_err(RpcError::from)?;
281 Ok(json!({"deferred": records}))
282 }
283 "deferred.show" => {
284 let deferred_id = required_string(params, "deferredId")?;
285 let store = LocalStore::open(config).map_err(RpcError::from)?;
286 let deferred = store
287 .load_deferred_tool(&deferred_id)
288 .map_err(RpcError::from)?;
289 Ok(json!({"deferred": deferred}))
290 }
291 "deferred.complete" => {
292 let deferred_id = required_string(params, "deferredId")?;
293 let result = params.get("result").cloned().unwrap_or(Value::Null);
294 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
295 let deferred = store
296 .complete_deferred_tool(&deferred_id, result)
297 .map_err(RpcError::from)?;
298 Ok(json!({"deferred": deferred}))
299 }
300 "deferred.fail" => {
301 let deferred_id = required_string(params, "deferredId")?;
302 let error = required_string(params, "error")?;
303 let mut store = LocalStore::open(config).map_err(RpcError::from)?;
304 let deferred = store
305 .fail_deferred_tool(&deferred_id, &error)
306 .map_err(RpcError::from)?;
307 Ok(json!({"deferred": deferred}))
308 }
309 "environment.attach" => {
310 self.validate_environment_attach_scope(params)?;
311 self.environment_manager.attach(params).await
312 }
313 "environment.detach" => self.environment_manager.detach(params),
314 "environment.list" => self.environment_manager.list(params),
315 "environment.health" => self.environment_manager.health(params).await,
316 "environment.active_mount" => self.environment_active_mount(params).await,
317 "environment.active_unmount" => self.environment_active_unmount(params).await,
318 "environment.active_list" => self.environment_active_list(params),
319 other => Err(RpcError::new(
320 METHOD_NOT_FOUND,
321 format!("method not found: {other}"),
322 )),
323 }
324 }
325
326 async fn environment_active_mount(&self, params: &Value) -> Result<Value, RpcError> {
327 let params_digest = canonical_json(params);
328 let params = serde_json::from_value::<EnvironmentActiveMountParams>(params.clone())
329 .map_err(|error| {
330 RpcError::new(
331 INVALID_PARAMS,
332 format!("invalid active mount params: {error}"),
333 )
334 })?;
335 let session_id = self
336 .coordinator
337 .active_run_session_id(¶ms.run_id)
338 .map_err(RpcError::from)?;
339 let attachment = self
340 .environment_manager
341 .materialize_active_attachment(params.attachment.clone(), Some(&session_id))
342 .await?;
343 self.environment_manager
344 .mark_run_mounted(¶ms.run_id, &attachment)?;
345 match self
346 .coordinator
347 .active_environment_mount(¶ms, attachment.clone(), ¶ms_digest)
348 {
349 Ok(outcome) => {
350 if !outcome.applied {
351 self.environment_manager
352 .mark_run_unmounted(¶ms.run_id, &attachment)?;
353 }
354 Ok(outcome.result)
355 }
356 Err(error) => {
357 let _ = self
358 .environment_manager
359 .mark_run_unmounted(¶ms.run_id, &attachment);
360 Err(error)
361 }
362 }
363 }
364
365 async fn environment_active_unmount(&self, params: &Value) -> Result<Value, RpcError> {
366 let params_digest = canonical_json(params);
367 let params = serde_json::from_value::<EnvironmentActiveUnmountParams>(params.clone())
368 .map_err(|error| {
369 RpcError::new(
370 INVALID_PARAMS,
371 format!("invalid active unmount params: {error}"),
372 )
373 })?;
374 let outcome = self
375 .coordinator
376 .active_environment_unmount(¶ms, ¶ms_digest)
377 .await?;
378 if outcome.applied {
379 self.environment_manager
380 .mark_run_unmounted(¶ms.run_id, &outcome.removed)?;
381 }
382 Ok(outcome.result)
383 }
384
385 fn environment_active_list(&self, params: &Value) -> Result<Value, RpcError> {
386 let params = serde_json::from_value::<EnvironmentActiveListParams>(params.clone())
387 .map_err(|error| {
388 RpcError::new(
389 INVALID_PARAMS,
390 format!("invalid active list params: {error}"),
391 )
392 })?;
393 self.coordinator
394 .active_environment_list(¶ms.run_id)
395 .map_err(active_run_rpc_error)
396 }
397
398 async fn run_start(&self, params: &Value) -> Result<Value, RpcError> {
399 let format = stream_payload_format(params)?;
400 let mut command = run_command_from_params(&self.config, params, OutputMode::Json)?;
401 let run_session_id = command.session.clone();
402 command.environment_attachments = self
403 .environment_manager
404 .materialize_run_attachments(command.environment_attachments, run_session_id.as_deref())
405 .await?;
406 let environment_attachments = command.environment_attachments.clone();
407 let pending_environment_run_id = pending_active_environment_run_id();
408 self.environment_manager
409 .mark_run_started(&pending_environment_run_id, &environment_attachments)?;
410 let started = match self
411 .coordinator
412 .start_run(command, None)
413 .map_err(RpcError::from)
414 {
415 Ok(started) => started,
416 Err(error) => {
417 self.environment_manager
418 .mark_run_finished(&pending_environment_run_id)?;
419 return Err(error);
420 }
421 };
422 let session_id = started.session_id.clone();
423 let run_id = started.run_id.clone();
424 if let Err(error) = self
425 .environment_manager
426 .mark_run_started(&run_id, &environment_attachments)
427 {
428 let _ = self
429 .environment_manager
430 .mark_run_finished(&pending_environment_run_id);
431 return Err(error);
432 }
433 self.environment_manager
434 .mark_run_finished(&pending_environment_run_id)?;
435 self.spawn_run_notifications(started, format);
436 let mut result = json!({
437 "sessionId": session_id,
438 "runId": run_id,
439 "status": "running",
440 "payloadFormat": format.as_str(),
441 });
442 if !environment_attachments.is_empty() {
443 merge_object(
444 &mut result,
445 &environment_attachment_result(&environment_attachments),
446 );
447 }
448 Ok(result)
449 }
450
451 async fn run_prompt(&self, params: &Value) -> Result<Value, RpcError> {
452 let mut command = run_command_from_params(&self.config, params, OutputMode::Json)?;
453 let run_session_id = command.session.clone();
454 command.environment_attachments = self
455 .environment_manager
456 .materialize_run_attachments(command.environment_attachments, run_session_id.as_deref())
457 .await?;
458 let active_environment_run_id = prompt_active_environment_run_id();
459 self.environment_manager
460 .mark_run_started(&active_environment_run_id, &command.environment_attachments)?;
461 let config = self.config.clone();
462 let join_result =
463 tokio::task::spawn_blocking(move || CliService::open(config)?.run_prompt(&command))
464 .await;
465 let finish_result = self
466 .environment_manager
467 .mark_run_finished(&active_environment_run_id);
468 let output_result = match join_result {
469 Ok(result) => result.map_err(RpcError::from),
470 Err(error) => Err(RpcError::new(SERVER_ERROR, error.to_string())),
471 };
472 let output = output_result?;
473 finish_result?;
474 serde_json::from_str(output.trim())
475 .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))
476 }
477
478 fn run_attach(&self, params: &Value) -> Result<Value, RpcError> {
479 let session_id = required_string(params, "sessionId")?;
480 let run_id = required_string(params, "runId")?;
481 let format = stream_payload_format(params)?;
482 let cursor = replay_cursor_from_params(params, ReplayScope::run(&run_id))?;
483 let mut attachment = self
484 .coordinator
485 .attach_run(&session_id, &run_id, cursor.as_ref())
486 .map_err(RpcError::from)?;
487 let result = attachment_result(
488 &attachment.session_id,
489 attachment.run_id.as_deref(),
490 attachment.active,
491 &attachment.events,
492 format,
493 );
494 self.spawn_attachment_notifications(&mut attachment, format);
495 Ok(result)
496 }
497
498 fn session_output(&self, params: &Value) -> Result<Value, RpcError> {
499 let session_id = required_string(params, "sessionId")?;
500 let run_id = params.get("runId").and_then(Value::as_str);
501 let format = stream_payload_format(params)?;
502 let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
503 let cursor = replay_cursor_from_params(params, scope)?;
504 let mut attachment = self
505 .coordinator
506 .session_output(&session_id, run_id, cursor.as_ref())
507 .map_err(RpcError::from)?;
508 let result = attachment_result(
509 &attachment.session_id,
510 attachment.run_id.as_deref(),
511 attachment.active,
512 &attachment.events,
513 format,
514 );
515 self.spawn_attachment_notifications(&mut attachment, format);
516 Ok(result)
517 }
518
519 fn stream_replay(&self, params: &Value) -> Result<Value, RpcError> {
520 let session_id = required_string(params, "sessionId")?;
521 let run_id = params.get("runId").and_then(Value::as_str);
522 let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
523 let cursor = replay_cursor_from_params(params, scope)?;
524 let archive = LocalStreamArchive::new(self.config.clone());
525 let window = archive
526 .replay_display_window(&session_id, run_id, cursor.as_ref())
527 .map_err(RpcError::from)?;
528 Ok(replay_result(
529 &session_id,
530 run_id,
531 &window.scope,
532 &window.events,
533 cursor.as_ref(),
534 window.next_sequence,
535 ))
536 }
537
538 fn stream_subscribe(&self, params: &Value) -> Result<Value, RpcError> {
539 if !self.notifications.supports_live_notifications() {
540 return Err(RpcError::new(
541 UNSUPPORTED_FEATURE,
542 "stream.subscribe requires a live notification transport",
543 ));
544 }
545 let subscription_id = subscription_id(params);
546 let session_id = required_string(params, "sessionId")?;
547 let run_id = params.get("runId").and_then(Value::as_str);
548 let format = stream_payload_format(params)?;
549 let scope = run_id.map_or_else(|| ReplayScope::session(&session_id), ReplayScope::run);
550 let cursor = replay_cursor_from_params(params, scope)?;
551 let mut attachment = if let Some(run_id) = run_id {
552 self.coordinator
553 .attach_run(&session_id, run_id, cursor.as_ref())
554 .map_err(RpcError::from)?
555 } else {
556 self.coordinator
557 .session_output(&session_id, None, cursor.as_ref())
558 .map_err(RpcError::from)?
559 };
560 let mut result = attachment_result(
561 &attachment.session_id,
562 attachment.run_id.as_deref(),
563 attachment.active,
564 &attachment.events,
565 format,
566 );
567 insert_subscription_id(&mut result, &subscription_id);
568 if attachment.subscription.is_some() {
569 let cancel = Arc::new(AtomicBool::new(false));
570 let mut subscriptions = self.subscriptions.lock().map_err(|error| {
571 RpcError::new(
572 SERVER_ERROR,
573 format!("subscription registry poisoned: {error}"),
574 )
575 })?;
576 if subscriptions.contains_key(&subscription_id) {
577 return Err(RpcError::new(
578 SERVER_ERROR,
579 format!("subscription already exists: {subscription_id}"),
580 ));
581 }
582 subscriptions.insert(subscription_id.clone(), Arc::clone(&cancel));
583 drop(subscriptions);
584 self.spawn_stream_subscription_notifications(
585 &mut attachment,
586 format,
587 subscription_id,
588 cancel,
589 );
590 }
591 Ok(result)
592 }
593
594 fn stream_unsubscribe(&self, params: &Value) -> Result<Value, RpcError> {
595 let subscription_id = required_string(params, "subscriptionId")?;
596 let subscription = self
597 .subscriptions
598 .lock()
599 .map_err(|error| {
600 RpcError::new(
601 SERVER_ERROR,
602 format!("subscription registry poisoned: {error}"),
603 )
604 })?
605 .remove(&subscription_id);
606 let unsubscribed = subscription.is_some();
607 if let Some(cancel) = subscription {
608 cancel.store(true, Ordering::SeqCst);
609 }
610 Ok(json!({
611 "subscriptionId": subscription_id,
612 "unsubscribed": unsubscribed,
613 }))
614 }
615
616 fn run_status(&self, params: &Value) -> Result<Value, RpcError> {
617 let session_id = required_string(params, "sessionId")?;
618 let run_id = required_string(params, "runId")?;
619 let status = self
620 .coordinator
621 .run_status(&session_id, &run_id)
622 .map_err(RpcError::from)?;
623 Ok(json!({"status": status}))
624 }
625
626 fn run_cancel(&self, params: &Value) -> Result<Value, RpcError> {
627 let run_id = required_string(params, "runId")?;
628 self.coordinator
629 .cancel_run(&run_id)
630 .map_err(RpcError::from)?;
631 Ok(json!({"runId": run_id, "cancelled": true}))
632 }
633
634 fn run_steer(&self, params: &Value) -> Result<Value, RpcError> {
635 let run_id = required_string(params, "runId")?;
636 let text = required_string(params, "text")?;
637 let id = steering_id(params);
638 self.coordinator
639 .steer_run(
640 &run_id,
641 CliSteeringMessage {
642 id: id.clone(),
643 text,
644 },
645 )
646 .map_err(RpcError::from)?;
647 Ok(json!({"runId": run_id, "steeringId": id, "queued": true}))
648 }
649
650 fn session_steer(&self, params: &Value) -> Result<Value, RpcError> {
651 let session_id = required_string(params, "sessionId")?;
652 let text = required_string(params, "text")?;
653 let id = steering_id(params);
654 let run_id = self
655 .coordinator
656 .steer_session(
657 &session_id,
658 CliSteeringMessage {
659 id: id.clone(),
660 text,
661 },
662 )
663 .map_err(RpcError::from)?;
664 Ok(json!({
665 "sessionId": session_id,
666 "runId": run_id,
667 "steeringId": id,
668 "queued": true,
669 }))
670 }
671
672 fn run_await(&self, params: &Value) -> Result<Value, RpcError> {
673 let session_id = required_string(params, "sessionId")?;
674 let run_id = required_string(params, "runId")?;
675 let timeout = params
676 .get("timeoutMs")
677 .and_then(Value::as_u64)
678 .map(Duration::from_millis);
679 let attachment = self
680 .coordinator
681 .attach_run(&session_id, &run_id, None)
682 .map_err(RpcError::from)?;
683 let Some(receiver) = attachment.subscription else {
684 return self.run_status(params);
685 };
686 loop {
687 let event = match timeout {
688 Some(timeout) => receiver
689 .recv_timeout(timeout)
690 .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))?,
691 None => receiver
692 .recv()
693 .map_err(|error| RpcError::new(SERVER_ERROR, error.to_string()))?,
694 };
695 if let RunStreamEvent::Status(status) = event {
696 if status_is_terminal(&status.status) {
697 return Ok(json!({"status": status}));
698 }
699 }
700 }
701 }
702
703 fn spawn_run_notifications(&self, started: StartedRun, format: StreamPayloadFormat) {
704 let output_sender = self.output_sender.clone();
705 let environment_manager = self.environment_manager.clone();
706 let send_notifications = self.notifications.supports_live_notifications();
707 thread::spawn(move || {
708 let session_id = started.session_id.clone();
709 let run_id = started.run_id.clone();
710 if send_notifications {
711 let _ = output_sender.send(notification(
712 "run.started",
713 &json!({
714 "sessionId": session_id,
715 "runId": run_id,
716 "status": "running",
717 }),
718 ));
719 }
720 forward_started_run_events(
721 &output_sender,
722 started.events,
723 format,
724 send_notifications,
725 &environment_manager,
726 &run_id,
727 );
728 });
729 }
730
731 fn validate_environment_attach_scope(&self, params: &Value) -> Result<(), RpcError> {
732 if self.notifications.supports_live_notifications() {
733 return Ok(());
734 }
735 let scope_kind = params
736 .get("scope")
737 .and_then(|scope| scope.get("kind"))
738 .and_then(Value::as_str)
739 .unwrap_or("connection");
740 if scope_kind == "connection" {
741 return Err(RpcError::new(
742 UNSUPPORTED_FEATURE,
743 "connection-scoped environment attachments are not supported by replay-only transports; use session scope",
744 ));
745 }
746 Ok(())
747 }
748
749 fn spawn_attachment_notifications(
750 &self,
751 attachment: &mut RunAttachment,
752 format: StreamPayloadFormat,
753 ) {
754 if !self.notifications.supports_live_notifications() {
755 return;
756 }
757 let Some(subscription) = attachment.subscription.take() else {
758 return;
759 };
760 let output_sender = self.output_sender.clone();
761 thread::spawn(move || forward_run_events(&output_sender, subscription, format));
762 }
763
764 fn spawn_stream_subscription_notifications(
765 &self,
766 attachment: &mut RunAttachment,
767 format: StreamPayloadFormat,
768 subscription_id: String,
769 cancel: Arc<AtomicBool>,
770 ) {
771 if !self.notifications.supports_live_notifications() {
772 return;
773 }
774 let Some(subscription) = attachment.subscription.take() else {
775 return;
776 };
777 let output_sender = self.output_sender.clone();
778 let subscriptions = Arc::clone(&self.subscriptions);
779 thread::spawn(move || {
780 forward_subscription_events(
781 &output_sender,
782 &subscription,
783 format,
784 &subscription_id,
785 &cancel,
786 );
787 if let Ok(mut subscriptions) = subscriptions.lock() {
788 subscriptions.remove(&subscription_id);
789 }
790 });
791 }
792}
793
794fn build_rpc_runtime() -> Arc<Runtime> {
795 let runtime = tokio::runtime::Builder::new_multi_thread()
796 .enable_all()
797 .thread_name("starweaver-rpc")
798 .build();
799 match runtime {
800 Ok(runtime) => Arc::new(runtime),
801 Err(error) => panic!("failed to build RPC async runtime: {error}"),
802 }
803}
804
805fn run_command_from_params(
806 config: &CliConfig,
807 params: &Value,
808 output: OutputMode,
809) -> Result<RunCommand, RpcError> {
810 let prompt = required_string(params, "prompt")?;
811 let environment_attachments = environment_attachment_refs(params)?;
812 validate_environment_attachments_for_run(&environment_attachments)?;
813 validate_run_session_selectors(params)?;
814 let client = params.get("client").and_then(Value::as_str);
815 let client_profile = client
816 .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
817 .transpose()?
818 .flatten();
819 let profile = params
820 .get("profile")
821 .or_else(|| params.get("modelProfile"))
822 .and_then(Value::as_str)
823 .map(ToString::to_string)
824 .or(client_profile)
825 .unwrap_or_else(|| config.default_profile.clone());
826 ensure_profile(config, &profile)?;
827 Ok(RunCommand {
828 prompt: Some(prompt),
829 prompt_parts: Vec::new(),
830 session: params
831 .get("sessionId")
832 .and_then(Value::as_str)
833 .map(ToString::to_string),
834 continue_session: params
835 .get("continueLatest")
836 .and_then(Value::as_bool)
837 .unwrap_or(false),
838 new_session: params
839 .get("newSession")
840 .and_then(Value::as_bool)
841 .unwrap_or(false),
842 run: params
843 .get("restoreFromRunId")
844 .or_else(|| params.get("runId"))
845 .and_then(Value::as_str)
846 .map(ToString::to_string),
847 branch_from: params
848 .get("branchFromRunId")
849 .and_then(Value::as_str)
850 .map(ToString::to_string),
851 profile: Some(profile),
852 worker: None,
853 worker_label: None,
854 worktree: None,
855 worktree_name: None,
856 branch: None,
857 output: Some(output),
858 hitl: params
859 .get("hitl")
860 .and_then(Value::as_str)
861 .and_then(parse_hitl),
862 goal: None,
863 session_affinity_id: None,
864 environment_attachments,
865 })
866}
867
868fn validate_run_session_selectors(params: &Value) -> Result<(), RpcError> {
869 let session_id = params.get("sessionId").and_then(Value::as_str).is_some();
870 let continue_latest = params
871 .get("continueLatest")
872 .and_then(Value::as_bool)
873 .unwrap_or(false);
874 let new_session = params
875 .get("newSession")
876 .and_then(Value::as_bool)
877 .unwrap_or(false);
878 let selected =
879 usize::from(session_id) + usize::from(continue_latest) + usize::from(new_session);
880 if selected > 1 {
881 return Err(RpcError::new(
882 INVALID_PARAMS,
883 "run session selectors are mutually exclusive: use only one of sessionId, continueLatest, or newSession",
884 ));
885 }
886 Ok(())
887}
888
889fn validate_environment_attachments_for_run(
890 attachments: &[starweaver_rpc_core::EnvironmentAttachmentRef],
891) -> Result<(), RpcError> {
892 for attachment in attachments {
893 match attachment.kind.as_str() {
894 "local" => {}
895 "envd" => {
896 validate_envd_attachment_transport(attachment)
897 .map_err(|error| RpcError::new(INVALID_PARAMS, error))?;
898 }
899 other => {
900 return Err(RpcError::new(
901 UNSUPPORTED_FEATURE,
902 format!("unsupported environment attachment kind: {other}"),
903 ));
904 }
905 }
906 }
907 Ok(())
908}
909
910fn active_run_rpc_error(error: CliError) -> RpcError {
911 match error {
912 CliError::NotFound(value) => {
913 RpcError::new(RUN_CONFLICT, format!("active run not found: {value}"))
914 }
915 other => RpcError::from(other),
916 }
917}
918
919fn steering_id(params: &Value) -> String {
920 params
921 .get("steeringId")
922 .or_else(|| params.get("id"))
923 .and_then(Value::as_str)
924 .filter(|value| !value.trim().is_empty())
925 .map_or_else(
926 || format!("steer_{}", chrono::Utc::now().timestamp_micros()),
927 ToString::to_string,
928 )
929}
930
931fn subscription_id(params: &Value) -> String {
932 params
933 .get("subscriptionId")
934 .or_else(|| params.get("id"))
935 .and_then(Value::as_str)
936 .filter(|value| !value.trim().is_empty())
937 .map_or_else(
938 || format!("stream_{}", chrono::Utc::now().timestamp_micros()),
939 ToString::to_string,
940 )
941}
942
943fn prompt_active_environment_run_id() -> String {
944 format!("run_prompt_{}", Uuid::new_v4().simple())
945}
946
947fn pending_active_environment_run_id() -> String {
948 format!("run_start_pending_{}", Uuid::new_v4().simple())
949}
950
951fn forward_run_events(
952 output_sender: &mpsc::Sender<Value>,
953 receiver: mpsc::Receiver<RunStreamEvent>,
954 format: StreamPayloadFormat,
955) {
956 for event in receiver {
957 let Some(frame) = run_event_notification(event, format) else {
958 continue;
959 };
960 if output_sender.send(frame).is_err() {
961 break;
962 }
963 }
964}
965
966fn forward_started_run_events(
967 output_sender: &mpsc::Sender<Value>,
968 receiver: mpsc::Receiver<RunStreamEvent>,
969 format: StreamPayloadFormat,
970 send_notifications: bool,
971 environment_manager: &EnvironmentAttachmentManager,
972 run_id: &str,
973) {
974 for event in receiver {
975 let terminal = matches!(
976 &event,
977 RunStreamEvent::Status(status) if status_is_terminal(&status.status)
978 );
979 if send_notifications {
980 if let Some(frame) = run_event_notification(event, format) {
981 let _ = output_sender.send(frame);
982 }
983 }
984 if terminal {
985 let _ = environment_manager.mark_run_finished(run_id);
986 return;
987 }
988 }
989 let _ = environment_manager.mark_run_finished(run_id);
990}
991
992fn run_event_notification(event: RunStreamEvent, format: StreamPayloadFormat) -> Option<Value> {
993 match event {
994 RunStreamEvent::Output(output) => {
995 let output = output_item(&output, format)?;
996 Some(notification("run.output", &json!(output)))
997 }
998 RunStreamEvent::Status(status) => Some(notification("run.status", &json!(status))),
999 RunStreamEvent::Raw(_) => None,
1000 }
1001}
1002
1003fn forward_subscription_events(
1004 output_sender: &mpsc::Sender<Value>,
1005 receiver: &mpsc::Receiver<RunStreamEvent>,
1006 format: StreamPayloadFormat,
1007 subscription_id: &str,
1008 cancel: &AtomicBool,
1009) {
1010 while !cancel.load(Ordering::SeqCst) {
1011 let event = match receiver.recv_timeout(Duration::from_millis(100)) {
1012 Ok(event) => event,
1013 Err(mpsc::RecvTimeoutError::Timeout) => continue,
1014 Err(mpsc::RecvTimeoutError::Disconnected) => break,
1015 };
1016 let terminal = matches!(
1017 &event,
1018 RunStreamEvent::Status(status) if status_is_terminal(&status.status)
1019 );
1020 let frame = match event {
1021 RunStreamEvent::Output(output) => {
1022 let Some(output) = output_item(&output, format) else {
1023 continue;
1024 };
1025 let mut params = json!(output);
1026 insert_subscription_id(&mut params, subscription_id);
1027 notification("stream.output", ¶ms)
1028 }
1029 RunStreamEvent::Status(status) => {
1030 let mut params = json!(status);
1031 insert_subscription_id(&mut params, subscription_id);
1032 notification("stream.status", ¶ms)
1033 }
1034 RunStreamEvent::Raw(_) => continue,
1035 };
1036 if output_sender.send(frame).is_err() || terminal {
1037 break;
1038 }
1039 }
1040}
1041
1042fn insert_subscription_id(value: &mut Value, subscription_id: &str) {
1043 if let Some(object) = value.as_object_mut() {
1044 object.insert(
1045 "subscriptionId".to_string(),
1046 Value::String(subscription_id.to_string()),
1047 );
1048 }
1049}
1050
1051fn canonical_json(value: &Value) -> String {
1052 match value {
1053 Value::Object(object) => {
1054 let mut entries = object.iter().collect::<Vec<_>>();
1055 entries.sort_by_key(|(key, _)| *key);
1056 let body = entries
1057 .into_iter()
1058 .map(|(key, value)| {
1059 format!(
1060 "{}:{}",
1061 serde_json::to_string(key).unwrap_or_else(|_| "\"\"".to_string()),
1062 canonical_json(value)
1063 )
1064 })
1065 .collect::<Vec<_>>()
1066 .join(",");
1067 format!("{{{body}}}")
1068 }
1069 Value::Array(values) => {
1070 let body = values
1071 .iter()
1072 .map(canonical_json)
1073 .collect::<Vec<_>>()
1074 .join(",");
1075 format!("[{body}]")
1076 }
1077 _ => serde_json::to_string(value).unwrap_or_else(|_| "null".to_string()),
1078 }
1079}
1080
1081fn merge_object(target: &mut Value, source: &Value) {
1082 let Some(target) = target.as_object_mut() else {
1083 return;
1084 };
1085 let Some(source) = source.as_object() else {
1086 return;
1087 };
1088 for (key, value) in source {
1089 target.insert(key.clone(), value.clone());
1090 }
1091}
1092
1093fn status_is_terminal(status: &str) -> bool {
1094 matches!(status, "completed" | "failed" | "cancelled")
1095}
1096
1097fn initialize_result(config: &CliConfig, notifications: RpcNotificationMode) -> Value {
1098 let live_notifications = notifications.supports_live_notifications();
1099 json!({
1100 "protocolVersion": PROTOCOL_VERSION,
1101 "serverInfo": {"name": "starweaver-cli", "version": env!("CARGO_PKG_VERSION")},
1102 "capabilities": {
1103 "sessions": true,
1104 "runs": true,
1105 "management": true,
1106 "profiles": true,
1107 "clientModelSelection": true,
1108 "blockingRunStart": false,
1109 "blockingRunPrompt": true,
1110 "nonBlockingRunStart": true,
1111 "liveDisplay": live_notifications,
1112 "streamReplay": true,
1113 "streamSubscribe": live_notifications,
1114 "cancel": true,
1115 "steering": true,
1116 "attach": true,
1117 "environmentAttachments": true,
1118 "environmentActiveMounts": true,
1119 "defaultStreamPayload": "agui",
1120 "approvals": true,
1121 "deferred": true
1122 },
1123 "config": {
1124 "globalDir": config.global_dir,
1125 "projectDir": config.project_dir,
1126 "tuiStateDir": config.tui_state_dir,
1127 "desktopStateDir": config.desktop_state_dir,
1128 "defaultProfile": config.default_profile,
1129 }
1130 })
1131}
1132
1133fn config_get(config: &CliConfig, params: &Value) -> Result<Value, RpcError> {
1134 if let Some(key) = params.get("key").and_then(Value::as_str) {
1135 let value = get_config_value(config, key)
1136 .map_err(RpcError::from)?
1137 .trim_end_matches('\n')
1138 .to_string();
1139 return Ok(json!({"values": {key: value}}));
1140 }
1141 let Some(keys) = params.get("keys").and_then(Value::as_array) else {
1142 return Err(RpcError::new(
1143 INVALID_PARAMS,
1144 "config.get requires key or keys",
1145 ));
1146 };
1147 let mut values = serde_json::Map::new();
1148 for key in keys {
1149 let Some(key) = key.as_str() else {
1150 return Err(RpcError::new(INVALID_PARAMS, "keys must be strings"));
1151 };
1152 let value = get_config_value(config, key)
1153 .map_err(RpcError::from)?
1154 .trim_end_matches('\n')
1155 .to_string();
1156 values.insert(key.to_string(), Value::String(value));
1157 }
1158 Ok(json!({"values": values}))
1159}
1160
1161fn selected_profile_result(config: &CliConfig, client: Option<&str>) -> Result<Value, RpcError> {
1162 let selected = client
1163 .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
1164 .transpose()?
1165 .flatten()
1166 .unwrap_or_else(|| config.default_profile.clone());
1167 Ok(json!({
1168 "client": client,
1169 "selectedProfile": selected,
1170 "modelId": model_id_for_profile(config, &selected),
1171 }))
1172}
1173
1174fn selected_model_profile_result(
1175 config: &CliConfig,
1176 client: Option<&str>,
1177) -> Result<Value, RpcError> {
1178 let configured_profiles = list_config_model_profiles(config);
1179 let persisted = client
1180 .map(|client| client_state::read_selected_profile(config, client).map_err(RpcError::from))
1181 .transpose()?
1182 .flatten();
1183 let selected = persisted
1184 .filter(|profile| {
1185 configured_profiles
1186 .iter()
1187 .any(|summary| summary.name == *profile)
1188 })
1189 .or_else(|| {
1190 configured_profiles
1191 .iter()
1192 .find(|summary| summary.name == config.default_profile)
1193 .map(|summary| summary.name.clone())
1194 })
1195 .or_else(|| {
1196 configured_profiles
1197 .first()
1198 .map(|summary| summary.name.clone())
1199 });
1200 let model_id = selected
1201 .as_deref()
1202 .and_then(|selected| {
1203 configured_profiles
1204 .iter()
1205 .find(|summary| summary.name == selected)
1206 })
1207 .map(|summary| summary.model_id.clone());
1208 Ok(json!({
1209 "client": client,
1210 "selectedProfile": selected,
1211 "modelId": model_id,
1212 }))
1213}
1214
1215fn ensure_profile(config: &CliConfig, profile: &str) -> Result<(), RpcError> {
1216 if list_profiles(config)
1217 .iter()
1218 .any(|summary| summary.name == profile)
1219 {
1220 Ok(())
1221 } else {
1222 Err(RpcError::new(
1223 INVALID_PARAMS,
1224 format!("unknown profile: {profile}"),
1225 ))
1226 }
1227}
1228
1229fn ensure_client_model_profile(config: &CliConfig, profile: &str) -> Result<(), RpcError> {
1230 if list_config_model_profiles(config)
1231 .iter()
1232 .any(|summary| summary.name == profile)
1233 {
1234 Ok(())
1235 } else {
1236 Err(RpcError::new(
1237 INVALID_PARAMS,
1238 format!("unknown model profile: {profile}"),
1239 ))
1240 }
1241}
1242
1243fn model_id_for_profile(config: &CliConfig, profile: &str) -> Option<String> {
1244 list_profiles(config)
1245 .into_iter()
1246 .find(|summary| summary.name == profile)
1247 .map(|summary| summary.model_id)
1248}
1249
1250fn parse_hitl(value: &str) -> Option<HitlPolicy> {
1251 match value {
1252 "deny" => Some(HitlPolicy::Deny),
1253 "defer" => Some(HitlPolicy::Defer),
1254 "fail" => Some(HitlPolicy::Fail),
1255 "prompt" => Some(HitlPolicy::Prompt),
1256 _ => None,
1257 }
1258}
1259
1260fn required_string(params: &Value, key: &str) -> Result<String, RpcError> {
1261 params
1262 .get(key)
1263 .and_then(Value::as_str)
1264 .filter(|value| !value.trim().is_empty())
1265 .map(ToString::to_string)
1266 .ok_or_else(|| RpcError::new(INVALID_PARAMS, format!("missing string param: {key}")))
1267}
1268
1269#[cfg(test)]
1270mod tests {
1271 #![allow(clippy::unwrap_used)]
1272
1273 use std::{
1274 fs,
1275 io::{Read as _, Write as _},
1276 net::{TcpListener, TcpStream},
1277 sync::{
1278 atomic::{AtomicBool, Ordering},
1279 Arc,
1280 },
1281 };
1282
1283 use serde_json::json;
1284 use starweaver_stream::{EnvironmentLifecycleEvent, ReplayEvent, ReplayEventKind, ReplayScope};
1285
1286 use super::*;
1287 use crate::{args, ConfigResolver};
1288
1289 fn test_config(root: &std::path::Path) -> CliConfig {
1290 let cli = args::parse(["starweaver-cli".to_string(), "rpc".to_string()]).unwrap();
1291 ConfigResolver::for_tests(root).resolve(&cli).unwrap()
1292 }
1293
1294 #[cfg(unix)]
1295 fn stdio_envd_fixture_endpoint(root: &std::path::Path) -> String {
1296 use std::os::unix::fs::PermissionsExt as _;
1297
1298 let script = root.join("envd-stdio-fixture.sh");
1299 fs::write(
1300 &script,
1301 r#"#!/bin/sh
1302while IFS= read -r line; do
1303 if printf '%s' "$line" | grep -q '"method":"initialize"'; then
1304 printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocol":"starweaver.envd","protocol_version":"2026-06-01","service_name":"fixture","metadata":{}}}'
1305 elif printf '%s' "$line" | grep -q '"method":"environment.open"'; then
1306 printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"environment_id":"default","kind":"fixture","store":"ephemeral","status":"open","state_version":1,"policy_revision":1,"capabilities":{"features":["files","shell","processes"]},"metadata":{}}}'
1307 elif printf '%s' "$line" | grep -q '"method":"environment.state"'; then
1308 printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"descriptor":{"environment_id":"default","kind":"fixture","store":"ephemeral","status":"open","state_version":1,"policy_revision":1,"capabilities":{"features":["files","shell","processes"]},"metadata":{}},"mounts":[],"resources":[],"processes":[],"operations":[],"effects":[],"metadata":{}}}'
1309 else
1310 printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{}}'
1311 fi
1312done
1313"#,
1314 )
1315 .unwrap();
1316 let mut permissions = fs::metadata(&script).unwrap().permissions();
1317 permissions.set_mode(0o755);
1318 fs::set_permissions(&script, permissions).unwrap();
1319 format!("stdio://{}", script.display())
1320 }
1321
1322 #[allow(clippy::needless_pass_by_value)]
1323 fn request(config: &CliConfig, id: u64, method: &str, params: Value) -> Value {
1324 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1325 let server = RpcService::new(config.clone(), output_sender);
1326 request_with_server(&server, id, method, params)
1327 }
1328
1329 #[allow(clippy::needless_pass_by_value)]
1330 fn request_with_server(server: &RpcService, id: u64, method: &str, params: Value) -> Value {
1331 let line = json!({
1332 "jsonrpc": "2.0",
1333 "id": id,
1334 "method": method,
1335 "params": params,
1336 })
1337 .to_string();
1338 let (response, shutdown) = server.handle_line(&line);
1339 assert!(!shutdown || method == "shutdown");
1340 let response = response.unwrap();
1341 assert_eq!(response["jsonrpc"], "2.0");
1342 assert_eq!(response["id"], id);
1343 assert!(
1344 response.get("error").is_none(),
1345 "unexpected RPC error: {response}"
1346 );
1347 response["result"].clone()
1348 }
1349
1350 #[allow(clippy::needless_pass_by_value)]
1351 fn request_error_with_server(
1352 server: &RpcService,
1353 id: u64,
1354 method: &str,
1355 params: Value,
1356 ) -> Value {
1357 let line = json!({
1358 "jsonrpc": "2.0",
1359 "id": id,
1360 "method": method,
1361 "params": params,
1362 })
1363 .to_string();
1364 let (response, shutdown) = server.handle_line(&line);
1365 assert!(!shutdown);
1366 let response = response.unwrap();
1367 assert_eq!(response["jsonrpc"], "2.0");
1368 assert_eq!(response["id"], id);
1369 response["error"].clone()
1370 }
1371
1372 fn http_post(config: &CliConfig, body: &Value) -> (String, Arc<AtomicBool>) {
1373 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1374 let address = listener.local_addr().unwrap();
1375 let service = Arc::new(RpcService::replay_only(
1376 config.clone(),
1377 transport::closed_notification_sender(),
1378 ));
1379 let shutdown = Arc::new(AtomicBool::new(false));
1380 let server_shutdown = Arc::clone(&shutdown);
1381 let handle = thread::spawn(move || {
1382 let (stream, _address) = listener.accept().unwrap();
1383 transport::handle_http_connection(stream, &service, &server_shutdown).unwrap();
1384 });
1385 let body = body.to_string();
1386 let mut client = TcpStream::connect(address).unwrap();
1387 write!(
1388 client,
1389 "POST /rpc HTTP/1.1\r\nHost: {address}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
1390 body.len()
1391 )
1392 .unwrap();
1393 let mut response = String::new();
1394 client.read_to_string(&mut response).unwrap();
1395 handle.join().unwrap();
1396 (response, shutdown)
1397 }
1398
1399 fn http_body(response: &str) -> Value {
1400 let (headers, body) = response.split_once("\r\n\r\n").unwrap();
1401 assert!(headers.starts_with("HTTP/1.1 200 OK"), "{headers}");
1402 serde_json::from_str(body).unwrap()
1403 }
1404
1405 #[test]
1406 fn initialize_capabilities_follow_notification_transport() {
1407 let temp = tempfile::tempdir().unwrap();
1408 let config = test_config(temp.path());
1409 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1410 let live = RpcService::new(config.clone(), output_sender);
1411 let initialized = request_with_server(&live, 1, "initialize", json!({}));
1412 assert_eq!(initialized["capabilities"]["liveDisplay"], true);
1413 assert_eq!(initialized["capabilities"]["streamSubscribe"], true);
1414 assert_eq!(initialized["capabilities"]["environmentActiveMounts"], true);
1415
1416 let replay_only = RpcService::replay_only(config, transport::closed_notification_sender());
1417 let initialized = request_with_server(&replay_only, 2, "initialize", json!({}));
1418 assert_eq!(initialized["capabilities"]["liveDisplay"], false);
1419 assert_eq!(initialized["capabilities"]["streamSubscribe"], false);
1420 assert_eq!(initialized["capabilities"]["streamReplay"], true);
1421 assert_eq!(initialized["capabilities"]["environmentActiveMounts"], true);
1422 }
1423
1424 #[test]
1425 fn active_environment_methods_are_dispatched() {
1426 let temp = tempfile::tempdir().unwrap();
1427 let config = test_config(temp.path());
1428 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1429 let server = RpcService::new(config, output_sender);
1430
1431 let error = request_error_with_server(
1432 &server,
1433 1,
1434 "environment.active_list",
1435 json!({"runId": "run_missing"}),
1436 );
1437
1438 assert_eq!(error["code"], RUN_CONFLICT);
1439 assert!(error["message"].as_str().unwrap().contains("run_missing"));
1440 }
1441
1442 #[test]
1443 fn rpc_command_parses_http_transport() {
1444 let cli = args::parse([
1445 "starweaver-cli".to_string(),
1446 "rpc".to_string(),
1447 "http".to_string(),
1448 "--host".to_string(),
1449 "127.0.0.1".to_string(),
1450 "--port".to_string(),
1451 "0".to_string(),
1452 ])
1453 .unwrap();
1454 let Some(crate::CliCommand::Rpc(command)) = cli.command else {
1455 panic!("expected rpc command");
1456 };
1457 assert_eq!(command.transport, crate::args::RpcTransport::Http);
1458 assert_eq!(command.host, "127.0.0.1");
1459 assert_eq!(command.port, 0);
1460 }
1461
1462 #[test]
1463 fn initialize_and_model_selection_use_client_state_dirs() {
1464 let temp = tempfile::tempdir().unwrap();
1465 let global = temp.path().join("global");
1466 std::fs::create_dir_all(&global).unwrap();
1467 std::fs::write(
1468 global.join("config.toml"),
1469 r#"
1470[general]
1471model = "test:default"
1472
1473[model_profiles.coding]
1474label = "Coding"
1475model = "test:coding"
1476"#,
1477 )
1478 .unwrap();
1479 let config = test_config(temp.path());
1480
1481 let initialized = request(
1482 &config,
1483 1,
1484 "initialize",
1485 json!({"clientInfo":{"name":"tui"}}),
1486 );
1487 assert_eq!(initialized["protocolVersion"], PROTOCOL_VERSION);
1488 assert_eq!(initialized["capabilities"]["clientModelSelection"], true);
1489 assert_eq!(initialized["config"]["globalDir"], json!(config.global_dir));
1490 assert_eq!(
1491 initialized["config"]["tuiStateDir"],
1492 json!(config.tui_state_dir)
1493 );
1494 assert_eq!(
1495 initialized["config"]["desktopStateDir"],
1496 json!(config.desktop_state_dir)
1497 );
1498
1499 let listed = request(&config, 2, "model.list", json!({"client":"tui"}));
1500 let listed_profiles = listed["profiles"].as_array().unwrap();
1501 assert_eq!(listed_profiles.len(), 2);
1502 assert_eq!(listed_profiles[0]["name"], "default_model");
1503 assert_eq!(listed_profiles[0]["model_id"], "test:default");
1504 assert_eq!(listed_profiles[1]["name"], "coding");
1505 assert_eq!(listed_profiles[1]["model_id"], "test:coding");
1506 assert!(!listed_profiles
1507 .iter()
1508 .any(|profile| profile["source"] == "built-in" || profile["model_id"] == "local_echo"));
1509 assert_eq!(listed["current"]["selectedProfile"], config.default_profile);
1510
1511 let selected = request(
1512 &config,
1513 3,
1514 "model.select",
1515 json!({"client":"tui", "profile":"coding"}),
1516 );
1517 assert_eq!(selected["client"], "tui");
1518 assert_eq!(selected["selectedProfile"], "coding");
1519 assert_eq!(selected["modelId"], "test:coding");
1520 assert!(config.tui_state_dir.join("state.json").exists());
1521 assert!(!config.desktop_state_dir.join("state.json").exists());
1522
1523 let current = request(&config, 4, "model.current", json!({"client":"tui"}));
1524 assert_eq!(current["selectedProfile"], "coding");
1525 let desktop_current = request(&config, 5, "model.current", json!({"client":"desktop"}));
1526 assert_eq!(desktop_current["selectedProfile"], "default_model");
1527 assert_eq!(desktop_current["modelId"], "test:default");
1528 }
1529
1530 #[test]
1531 fn client_model_selection_is_empty_without_configured_profiles() {
1532 let temp = tempfile::tempdir().unwrap();
1533 let config = test_config(temp.path());
1534
1535 let listed = request(&config, 1, "model.list", json!({"client":"tui"}));
1536 assert!(listed["profiles"].as_array().unwrap().is_empty());
1537 assert!(listed["current"]["selectedProfile"].is_null());
1538 assert!(listed["current"]["modelId"].is_null());
1539
1540 let line = json!({
1541 "jsonrpc": "2.0",
1542 "id": 2,
1543 "method": "model.select",
1544 "params": {"client":"tui", "profile":"general"},
1545 })
1546 .to_string();
1547 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1548 let server = RpcService::new(config, output_sender);
1549 let (response, shutdown) = server.handle_line(&line);
1550 assert!(!shutdown);
1551 let response = response.unwrap();
1552 assert_eq!(response["id"], 2);
1553 assert_eq!(response["error"]["code"], -32_602);
1554 assert!(response["error"]["message"]
1555 .as_str()
1556 .unwrap()
1557 .contains("unknown model profile: general"));
1558 }
1559
1560 #[test]
1561 fn client_model_selection_only_uses_configured_profiles() {
1562 let temp = tempfile::tempdir().unwrap();
1563 let global = temp.path().join("global");
1564 std::fs::create_dir_all(&global).unwrap();
1565 std::fs::write(
1566 global.join("config.toml"),
1567 r#"
1568[model_profiles.coding]
1569model = "test:coding"
1570"#,
1571 )
1572 .unwrap();
1573 let config = test_config(temp.path());
1574
1575 let listed = request(&config, 1, "model.list", json!({"client":"tui"}));
1576 let listed_profiles = listed["profiles"].as_array().unwrap();
1577 assert_eq!(listed_profiles.len(), 1);
1578 assert_eq!(listed_profiles[0]["name"], "coding");
1579 assert_eq!(listed_profiles[0]["source"], "config");
1580 assert_eq!(listed_profiles[0]["model_id"], "test:coding");
1581 assert!(!listed_profiles
1582 .iter()
1583 .any(|profile| profile["model_id"] == "local_echo"));
1584
1585 let selected = request(
1586 &config,
1587 2,
1588 "model.select",
1589 json!({"client":"tui", "profile":"coding"}),
1590 );
1591 assert_eq!(selected["selectedProfile"], "coding");
1592 assert_eq!(selected["modelId"], "test:coding");
1593
1594 let current = request(&config, 3, "model.current", json!({"client":"tui"}));
1595 assert_eq!(current["selectedProfile"], "coding");
1596 assert_eq!(current["modelId"], "test:coding");
1597 }
1598
1599 #[test]
1600 fn config_get_and_run_prompt_smoke_through_rpc_dispatch() {
1601 let temp = tempfile::tempdir().unwrap();
1602 let config = test_config(temp.path());
1603
1604 let values = request(
1605 &config,
1606 1,
1607 "config.get",
1608 json!({"keys": ["general.default_profile", "storage.database_path"]}),
1609 );
1610 assert_eq!(
1611 values["values"]["general.default_profile"],
1612 config.default_profile
1613 );
1614 assert_eq!(
1615 values["values"]["storage.database_path"],
1616 config.database_path.display().to_string()
1617 );
1618
1619 let run = request(
1620 &config,
1621 2,
1622 "run.prompt",
1623 json!({"prompt":"hello from rpc", "newSession": true, "client":"tui"}),
1624 );
1625 assert!(run["sessionId"].as_str().unwrap().starts_with("session_"));
1626 assert!(run["runId"].as_str().unwrap().starts_with("run_"));
1627 assert_eq!(run["status"], "completed");
1628 assert!(run["latestCursor"]["sequence"].as_u64().is_some());
1629
1630 let replay = request(
1631 &config,
1632 3,
1633 "session.replay",
1634 json!({"sessionId": run["sessionId"].as_str().unwrap()}),
1635 );
1636 assert!(replay["messages"].as_array().unwrap().len() > 1);
1637 assert_eq!(
1638 replay["scope"],
1639 format!("session:{}", run["sessionId"].as_str().unwrap())
1640 );
1641 assert!(replay["events"].as_array().unwrap().len() > 1);
1642 assert!(replay["latestCursor"]["sequence"].as_u64().is_some());
1643
1644 let tail = request(
1645 &config,
1646 4,
1647 "session.replay",
1648 json!({
1649 "sessionId": run["sessionId"].as_str().unwrap(),
1650 "cursor": replay["latestCursor"],
1651 }),
1652 );
1653 assert_eq!(tail["messages"].as_array().unwrap().len(), 0);
1654 }
1655
1656 #[test]
1657 fn stream_methods_cover_replay_subscribe_and_unsubscribe() {
1658 let temp = tempfile::tempdir().unwrap();
1659 let config = test_config(temp.path());
1660 let created = request(&config, 1, "session.create", json!({"title": "stream rpc"}));
1661 let session_id = created["session"]["session_id"].as_str().unwrap();
1662
1663 let replay = request(
1664 &config,
1665 2,
1666 "stream.replay",
1667 json!({"sessionId": session_id}),
1668 );
1669 assert_eq!(replay["sessionId"], session_id);
1670 assert_eq!(replay["scope"], format!("session:{session_id}"));
1671 assert_eq!(replay["events"].as_array().unwrap().len(), 0);
1672
1673 let subscribed = request(
1674 &config,
1675 3,
1676 "stream.subscribe",
1677 json!({
1678 "sessionId": session_id,
1679 "subscriptionId": "sub_stream_test",
1680 "payloadFormat": "display_message"
1681 }),
1682 );
1683 assert_eq!(subscribed["subscriptionId"], "sub_stream_test");
1684 assert_eq!(subscribed["sessionId"], session_id);
1685 assert_eq!(subscribed["active"], false);
1686 assert_eq!(subscribed["payloadFormat"], "display_message");
1687
1688 let unsubscribed = request(
1689 &config,
1690 4,
1691 "stream.unsubscribe",
1692 json!({"subscriptionId": "sub_stream_test"}),
1693 );
1694 assert_eq!(unsubscribed["subscriptionId"], "sub_stream_test");
1695 assert_eq!(unsubscribed["unsubscribed"], false);
1696 }
1697
1698 #[test]
1699 fn stream_subscribe_requires_live_notification_transport() {
1700 let temp = tempfile::tempdir().unwrap();
1701 let config = test_config(temp.path());
1702 let server = RpcService::replay_only(config, transport::closed_notification_sender());
1703 let line = json!({
1704 "jsonrpc": "2.0",
1705 "id": 1,
1706 "method": "stream.subscribe",
1707 "params": {"sessionId": "session_test"},
1708 })
1709 .to_string();
1710 let (response, shutdown) = server.handle_line(&line);
1711 assert!(!shutdown);
1712 let response = response.unwrap();
1713 assert_eq!(response["id"], 1);
1714 assert_eq!(response["error"]["code"], UNSUPPORTED_FEATURE);
1715 assert!(response["error"]["message"]
1716 .as_str()
1717 .unwrap()
1718 .contains("live notification transport"));
1719 }
1720
1721 #[test]
1722 fn run_output_notification_preserves_environment_lifecycle_event() {
1723 let event = ReplayEvent::new(
1724 ReplayScope::run("run_rpc"),
1725 8,
1726 ReplayEventKind::EnvironmentLifecycle(Box::new(EnvironmentLifecycleEvent {
1727 operation_kind: "environment_mounted".to_string(),
1728 session_id: "session_rpc".to_string(),
1729 run_id: "run_rpc".to_string(),
1730 binding_version: 2,
1731 environment: json!({"bindingVersion": 2, "mounts": []}),
1732 operation_id: Some("envop_1".to_string()),
1733 extra: serde_json::Map::new(),
1734 })),
1735 );
1736
1737 let frame = run_event_notification(
1738 RunStreamEvent::Output(Box::new(event)),
1739 StreamPayloadFormat::Agui,
1740 )
1741 .unwrap();
1742
1743 assert_eq!(frame["method"], "run.output");
1744 assert_eq!(
1745 frame["params"]["event"]["event"]["kind"],
1746 "environment_lifecycle"
1747 );
1748 assert_eq!(
1749 frame["params"]["event"]["event"]["operationKind"],
1750 "environment_mounted"
1751 );
1752 assert_eq!(frame["params"]["projections"][0]["payloadFormat"], "agui");
1753 assert_eq!(frame["params"]["payload"]["type"], "HOST_EVENT");
1754 }
1755
1756 #[test]
1757 fn replay_only_transport_requires_session_scoped_environment_leases() {
1758 let temp = tempfile::tempdir().unwrap();
1759 let config = test_config(temp.path());
1760 let server = RpcService::replay_only(config, transport::closed_notification_sender());
1761
1762 let connection_error = request_error_with_server(
1763 &server,
1764 1,
1765 "environment.attach",
1766 json!({
1767 "attachment": {"id": "workspace", "kind": "local"},
1768 "readiness": {"policy": "required"}
1769 }),
1770 );
1771 assert_eq!(connection_error["code"], UNSUPPORTED_FEATURE);
1772 assert!(connection_error["message"]
1773 .as_str()
1774 .unwrap()
1775 .contains("session scope"));
1776
1777 let attached = request_with_server(
1778 &server,
1779 2,
1780 "environment.attach",
1781 json!({
1782 "scope": {"kind": "session", "sessionId": "session_http"},
1783 "attachment": {"id": "workspace", "kind": "local"},
1784 "readiness": {"policy": "required"}
1785 }),
1786 );
1787 assert_eq!(attached["attachment"]["scope"]["kind"], "session");
1788 assert_eq!(attached["attachment"]["scope"]["sessionId"], "session_http");
1789 }
1790
1791 #[test]
1792 fn run_start_accepts_multiple_environment_attachments() {
1793 let temp = tempfile::tempdir().unwrap();
1794 let config = test_config(temp.path());
1795 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1796 let server = RpcService::new(config, output_sender);
1797 let line = json!({
1798 "jsonrpc": "2.0",
1799 "id": 1,
1800 "method": "run.start",
1801 "params": {
1802 "prompt": "hello",
1803 "environmentAttachments": [
1804 {"id": "local", "kind": "local", "default": true},
1805 {"id": "tools"}
1806 ]
1807 },
1808 })
1809 .to_string();
1810 let (response, shutdown) = server.handle_line(&line);
1811 assert!(!shutdown);
1812 let response = response.unwrap();
1813 let result = response["result"].as_object().unwrap();
1814 assert_eq!(result["status"], "running");
1815 assert_eq!(
1816 result["environmentAttachments"].as_array().unwrap().len(),
1817 2
1818 );
1819 assert_eq!(result["environmentAttachments"][0]["id"], "local");
1820 }
1821
1822 #[test]
1823 fn environment_attachment_methods_manage_local_lease_lifecycle() {
1824 let temp = tempfile::tempdir().unwrap();
1825 let config = test_config(temp.path());
1826 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1827 let server = RpcService::new(config, output_sender);
1828
1829 let attached = request_with_server(
1830 &server,
1831 1,
1832 "environment.attach",
1833 json!({
1834 "scope": {"kind": "connection"},
1835 "attachment": {"id": "workspace", "kind": "local", "mode": "read_only"},
1836 "readiness": {"policy": "required"},
1837 "idempotencyKey": "workspace"
1838 }),
1839 );
1840 let lease_id = attached["attachment"]["attachmentLeaseId"]
1841 .as_str()
1842 .unwrap()
1843 .to_string();
1844 assert!(lease_id.starts_with("envatt_workspace_"));
1845 assert_eq!(attached["attachment"]["status"], "ready");
1846 assert_eq!(attached["attachment"]["readiness"]["transport"], "ready");
1847 assert_eq!(attached["attachment"]["mode"], "read_only");
1848
1849 let repeated = request_with_server(
1850 &server,
1851 2,
1852 "environment.attach",
1853 json!({
1854 "scope": {"kind": "connection"},
1855 "attachment": {"id": "workspace", "kind": "local", "mode": "read_only"},
1856 "readiness": {"policy": "required"},
1857 "idempotencyKey": "workspace"
1858 }),
1859 );
1860 assert_eq!(
1861 repeated["attachment"]["attachmentLeaseId"],
1862 attached["attachment"]["attachmentLeaseId"]
1863 );
1864
1865 let listed = request_with_server(&server, 3, "environment.list", json!({}));
1866 assert_eq!(listed["attachments"].as_array().unwrap().len(), 1);
1867 assert_eq!(listed["attachments"][0]["attachmentLeaseId"], lease_id);
1868
1869 let health = request_with_server(
1870 &server,
1871 4,
1872 "environment.health",
1873 json!({"attachmentLeaseId": lease_id}),
1874 );
1875 assert_eq!(health["status"], "ready");
1876 assert_eq!(health["readiness"]["environment"], "ready");
1877
1878 let detached = request_with_server(
1879 &server,
1880 5,
1881 "environment.detach",
1882 json!({"attachmentLeaseId": lease_id}),
1883 );
1884 assert_eq!(detached["detached"], true);
1885 let listed_detached = request_with_server(
1886 &server,
1887 6,
1888 "environment.list",
1889 json!({"status": "detached"}),
1890 );
1891 assert_eq!(listed_detached["attachments"].as_array().unwrap().len(), 1);
1892 }
1893
1894 #[test]
1895 fn run_start_materializes_environment_attachment_lease_refs() {
1896 let temp = tempfile::tempdir().unwrap();
1897 let config = test_config(temp.path());
1898 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1899 let server = RpcService::new(config, output_sender);
1900
1901 let attached = request_with_server(
1902 &server,
1903 1,
1904 "environment.attach",
1905 json!({
1906 "attachment": {"id": "workspace", "kind": "local", "mode": "read_only"},
1907 "readiness": {"policy": "required"}
1908 }),
1909 );
1910 let lease_id = attached["attachment"]["attachmentLeaseId"]
1911 .as_str()
1912 .unwrap();
1913 let started = request_with_server(
1914 &server,
1915 2,
1916 "run.start",
1917 json!({
1918 "prompt": "hello leased environment",
1919 "environmentAttachments": [
1920 {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
1921 ]
1922 }),
1923 );
1924 assert_eq!(started["status"], "running");
1925 assert_eq!(started["environmentAttachments"][0]["id"], "workspace");
1926 assert_eq!(
1927 started["environmentAttachments"][0]["attachmentLeaseId"],
1928 lease_id
1929 );
1930 assert_eq!(started["environmentAttachments"][0]["mode"], "read_only");
1931 }
1932
1933 #[test]
1934 fn active_mount_idempotent_replay_does_not_duplicate_lease_usage() {
1935 let temp = tempfile::tempdir().unwrap();
1936 let config = test_config(temp.path());
1937 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
1938 let server = RpcService::new(config, output_sender);
1939
1940 let attached = request_with_server(
1941 &server,
1942 1,
1943 "environment.attach",
1944 json!({
1945 "attachment": {"id": "scratch", "kind": "local", "mode": "read_only"},
1946 "readiness": {"policy": "required"}
1947 }),
1948 );
1949 let lease_id = attached["attachment"]["attachmentLeaseId"]
1950 .as_str()
1951 .unwrap()
1952 .to_string();
1953 let started = request_with_server(
1954 &server,
1955 2,
1956 "run.start",
1957 json!({"prompt": "hello active mount replay"}),
1958 );
1959 let run_id = started["runId"].as_str().unwrap().to_string();
1960 let mount_params = json!({
1961 "runId": run_id,
1962 "attachment": {
1963 "id": "scratch",
1964 "attachmentLeaseId": lease_id,
1965 "default": false
1966 },
1967 "injectContext": false,
1968 "expectedBindingVersion": 1,
1969 "idempotencyKey": "mount-scratch"
1970 });
1971 let mounted =
1972 request_with_server(&server, 3, "environment.active_mount", mount_params.clone());
1973 assert_eq!(mounted["bindingVersion"], 2);
1974 let replayed = request_with_server(&server, 4, "environment.active_mount", mount_params);
1975 assert_eq!(replayed, mounted);
1976
1977 let unmounted = request_with_server(
1978 &server,
1979 5,
1980 "environment.active_unmount",
1981 json!({
1982 "runId": run_id,
1983 "mountId": "scratch",
1984 "injectContext": false,
1985 "expectedBindingVersion": 2,
1986 "idempotencyKey": "unmount-scratch"
1987 }),
1988 );
1989 assert_eq!(unmounted["bindingVersion"], 3);
1990 let detached = request_with_server(
1991 &server,
1992 6,
1993 "environment.detach",
1994 json!({"attachmentLeaseId": lease_id}),
1995 );
1996 assert_eq!(detached["detached"], true);
1997 }
1998
1999 #[cfg(unix)]
2000 #[test]
2001 fn envd_attachment_health_accepts_stdio_endpoint() {
2002 let temp = tempfile::tempdir().unwrap();
2003 let config = test_config(temp.path());
2004 let endpoint = stdio_envd_fixture_endpoint(temp.path());
2005 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2006 let server = RpcService::new(config, output_sender);
2007
2008 let health = request_with_server(
2009 &server,
2010 1,
2011 "environment.health",
2012 json!({
2013 "attachment": {
2014 "id": "stdio_data",
2015 "kind": "envd",
2016 "endpointRef": endpoint,
2017 "environmentId": "default",
2018 "mode": "read_only"
2019 }
2020 }),
2021 );
2022
2023 assert_eq!(health["status"], "ready");
2024 assert_eq!(health["readiness"]["transport"], "ready");
2025 assert_eq!(health["readiness"]["environment"], "ready");
2026 }
2027
2028 #[test]
2029 fn run_start_rejects_session_scoped_environment_lease_for_other_session() {
2030 let temp = tempfile::tempdir().unwrap();
2031 let config = test_config(temp.path());
2032 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2033 let server = RpcService::new(config, output_sender);
2034
2035 let attached = request_with_server(
2036 &server,
2037 1,
2038 "environment.attach",
2039 json!({
2040 "scope": {"kind": "session", "sessionId": "session_a"},
2041 "attachment": {"id": "workspace", "kind": "local"},
2042 "readiness": {"policy": "required"}
2043 }),
2044 );
2045 let lease_id = attached["attachment"]["attachmentLeaseId"]
2046 .as_str()
2047 .unwrap();
2048 let error = request_error_with_server(
2049 &server,
2050 2,
2051 "run.start",
2052 json!({
2053 "prompt": "hello wrong session",
2054 "sessionId": "session_b",
2055 "environmentAttachments": [
2056 {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
2057 ]
2058 }),
2059 );
2060 assert_eq!(error["code"], INVALID_PARAMS);
2061 assert!(error["message"].as_str().unwrap().contains("session_a"));
2062 assert!(error["message"].as_str().unwrap().contains("session_b"));
2063 }
2064
2065 #[test]
2066 fn run_start_rejects_ambiguous_session_selector_for_session_scoped_lease() {
2067 let temp = tempfile::tempdir().unwrap();
2068 let config = test_config(temp.path());
2069 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2070 let server = RpcService::new(config, output_sender);
2071
2072 let attached = request_with_server(
2073 &server,
2074 1,
2075 "environment.attach",
2076 json!({
2077 "scope": {"kind": "session", "sessionId": "session_a"},
2078 "attachment": {"id": "workspace", "kind": "local"},
2079 "readiness": {"policy": "required"}
2080 }),
2081 );
2082 let lease_id = attached["attachment"]["attachmentLeaseId"]
2083 .as_str()
2084 .unwrap();
2085 let error = request_error_with_server(
2086 &server,
2087 2,
2088 "run.start",
2089 json!({
2090 "prompt": "hello ambiguous session",
2091 "sessionId": "session_a",
2092 "newSession": true,
2093 "environmentAttachments": [
2094 {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
2095 ]
2096 }),
2097 );
2098 assert_eq!(error["code"], INVALID_PARAMS);
2099 assert!(error["message"]
2100 .as_str()
2101 .unwrap()
2102 .contains("mutually exclusive"));
2103 }
2104
2105 #[test]
2106 fn run_prompt_materializes_environment_attachment_lease_refs() {
2107 let temp = tempfile::tempdir().unwrap();
2108 let config = test_config(temp.path());
2109 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2110 let server = RpcService::new(config, output_sender);
2111
2112 let attached = request_with_server(
2113 &server,
2114 1,
2115 "environment.attach",
2116 json!({
2117 "attachment": {"id": "workspace", "kind": "local", "mode": "read_only"},
2118 "readiness": {"policy": "required"}
2119 }),
2120 );
2121 let lease_id = attached["attachment"]["attachmentLeaseId"]
2122 .as_str()
2123 .unwrap();
2124 let run = request_with_server(
2125 &server,
2126 2,
2127 "run.prompt",
2128 json!({
2129 "prompt": "hello leased blocking environment",
2130 "newSession": true,
2131 "environmentAttachments": [
2132 {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
2133 ]
2134 }),
2135 );
2136 assert_eq!(run["status"], "completed");
2137 assert!(run["sessionId"].as_str().unwrap().starts_with("session_"));
2138 assert!(run["runId"].as_str().unwrap().starts_with("run_"));
2139 }
2140
2141 #[test]
2142 fn envd_attachment_health_reports_unavailable_without_creating_run() {
2143 let temp = tempfile::tempdir().unwrap();
2144 let config = test_config(temp.path());
2145 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2146 let server = RpcService::new(config, output_sender);
2147 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2148 let endpoint = format!("http://{}/rpc", listener.local_addr().unwrap());
2149 drop(listener);
2150
2151 let health = request_with_server(
2152 &server,
2153 1,
2154 "environment.health",
2155 json!({
2156 "attachment": {
2157 "id": "remote",
2158 "kind": "envd",
2159 "endpointRef": endpoint,
2160 "environmentId": "dataset",
2161 "authToken": "secret"
2162 },
2163 "timeoutMs": 100
2164 }),
2165 );
2166 assert_eq!(health["status"], "unavailable");
2167 assert_eq!(health["readiness"]["transport"], "unavailable");
2168 }
2169
2170 #[test]
2171 fn envd_attachment_requires_auth_token() {
2172 let temp = tempfile::tempdir().unwrap();
2173 let config = test_config(temp.path());
2174 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2175 let server = RpcService::new(config, output_sender);
2176
2177 let error = request_error_with_server(
2178 &server,
2179 1,
2180 "run.start",
2181 json!({
2182 "prompt": "hello",
2183 "environmentAttachments": [
2184 {
2185 "id": "workspace",
2186 "kind": "envd",
2187 "endpointRef": "http://127.0.0.1:8766/rpc",
2188 "default": true
2189 }
2190 ]
2191 }),
2192 );
2193 assert_eq!(error["code"], INVALID_PARAMS);
2194 assert!(error["message"].as_str().unwrap().contains("authToken"));
2195 }
2196
2197 #[test]
2198 fn envd_attachment_rejects_empty_auth_token() {
2199 let temp = tempfile::tempdir().unwrap();
2200 let config = test_config(temp.path());
2201 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2202 let server = RpcService::new(config, output_sender);
2203
2204 let error = request_error_with_server(
2205 &server,
2206 1,
2207 "run.start",
2208 json!({
2209 "prompt": "hello",
2210 "environmentAttachments": [
2211 {
2212 "id": "workspace",
2213 "kind": "envd",
2214 "endpointRef": "http://127.0.0.1:8766/rpc",
2215 "authToken": " ",
2216 "default": true
2217 }
2218 ]
2219 }),
2220 );
2221 assert_eq!(error["code"], INVALID_PARAMS);
2222 assert!(error["message"]
2223 .as_str()
2224 .unwrap()
2225 .contains("cannot be empty"));
2226 }
2227
2228 #[test]
2229 fn run_start_fails_before_start_when_required_envd_is_unavailable() {
2230 let temp = tempfile::tempdir().unwrap();
2231 let config = test_config(temp.path());
2232 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2233 let server = RpcService::new(config, output_sender);
2234 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2235 let endpoint = format!("http://{}/rpc", listener.local_addr().unwrap());
2236 drop(listener);
2237
2238 let error = request_error_with_server(
2239 &server,
2240 1,
2241 "run.start",
2242 json!({
2243 "prompt": "hello",
2244 "environmentAttachments": [
2245 {
2246 "id": "workspace",
2247 "kind": "envd",
2248 "endpointRef": endpoint,
2249 "environmentId": "dataset",
2250 "authToken": "secret",
2251 "default": true
2252 }
2253 ]
2254 }),
2255 );
2256 assert_eq!(error["code"], starweaver_rpc_core::ENVIRONMENT_UNAVAILABLE);
2257 assert!(error["message"]
2258 .as_str()
2259 .unwrap()
2260 .to_ascii_lowercase()
2261 .contains("connection"));
2262 }
2263
2264 #[test]
2265 fn run_start_reprobes_best_effort_envd_lease_before_starting() {
2266 let temp = tempfile::tempdir().unwrap();
2267 let config = test_config(temp.path());
2268 let (output_sender, _output_receiver) = mpsc::channel::<Value>();
2269 let server = RpcService::new(config, output_sender);
2270 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2271 let endpoint = format!("http://{}/rpc", listener.local_addr().unwrap());
2272 drop(listener);
2273
2274 let attached = request_with_server(
2275 &server,
2276 1,
2277 "environment.attach",
2278 json!({
2279 "attachment": {
2280 "id": "remote",
2281 "kind": "envd",
2282 "endpointRef": endpoint,
2283 "environmentId": "dataset",
2284 "authToken": "secret"
2285 },
2286 "readiness": {"policy": "best_effort", "timeoutMs": 100}
2287 }),
2288 );
2289 assert_eq!(attached["attachment"]["status"], "unavailable");
2290 assert!(attached["attachment"].get("authToken").is_none());
2291 let lease_id = attached["attachment"]["attachmentLeaseId"]
2292 .as_str()
2293 .unwrap();
2294
2295 let error = request_error_with_server(
2296 &server,
2297 2,
2298 "run.start",
2299 json!({
2300 "prompt": "hello",
2301 "environmentAttachments": [
2302 {"id": "workspace", "attachmentLeaseId": lease_id, "default": true}
2303 ]
2304 }),
2305 );
2306 assert_eq!(error["code"], starweaver_rpc_core::ENVIRONMENT_UNAVAILABLE);
2307 }
2308
2309 #[test]
2310 fn run_start_streams_agui_payloads_and_session_output_replays() {
2311 let temp = tempfile::tempdir().unwrap();
2312 let config = test_config(temp.path());
2313 let (output_sender, output_receiver) = mpsc::channel::<Value>();
2314 let server = RpcService::new(config, output_sender);
2315
2316 let started = request_with_server(
2317 &server,
2318 1,
2319 "run.start",
2320 json!({"prompt": "hello live rpc", "newSession": true}),
2321 );
2322 let session_id = started["sessionId"].as_str().unwrap().to_string();
2323 let run_id = started["runId"].as_str().unwrap().to_string();
2324 assert_eq!(started["status"], "running");
2325 assert_eq!(started["payloadFormat"], "agui");
2326
2327 let mut saw_agui_output = false;
2328 let mut saw_terminal_status = false;
2329 for _ in 0..100 {
2330 let frame = output_receiver
2331 .recv_timeout(std::time::Duration::from_secs(2))
2332 .unwrap();
2333 match frame["method"].as_str() {
2334 Some("run.output") => {
2335 assert_eq!(frame["params"]["payloadFormat"], "agui");
2336 assert!(frame["params"]["payload"]["type"].is_string());
2337 saw_agui_output = true;
2338 }
2339 Some("run.status") if frame["params"]["status"] == "completed" => {
2340 saw_terminal_status = true;
2341 break;
2342 }
2343 _ => {}
2344 }
2345 }
2346 assert!(saw_agui_output);
2347 assert!(saw_terminal_status);
2348
2349 let output = request_with_server(
2350 &server,
2351 2,
2352 "session.output",
2353 json!({"sessionId": session_id, "runId": run_id}),
2354 );
2355 assert_eq!(output["payloadFormat"], "agui");
2356 let events = output["events"].as_array().unwrap();
2357 assert!(!events.is_empty());
2358 assert!(events
2359 .iter()
2360 .any(|event| event["payload"]["type"] == "RUN_FINISHED"));
2361 }
2362
2363 #[test]
2364 fn rpc_run_prompt_expands_configured_slash_command() {
2365 let temp = tempfile::tempdir().unwrap();
2366 let global = temp.path().join("global");
2367 std::fs::create_dir_all(&global).unwrap();
2368 std::fs::write(
2369 global.join("config.toml"),
2370 r#"
2371[general]
2372model = "local_echo"
2373
2374[commands.review]
2375description = "Review changes"
2376aliases = ["rv"]
2377prompt = "Review via RPC."
2378"#,
2379 )
2380 .unwrap();
2381 let config = test_config(temp.path());
2382
2383 let run = request(
2384 &config,
2385 1,
2386 "run.prompt",
2387 json!({
2388 "prompt":"/rv staged diff",
2389 "newSession": true,
2390 "profile":"default_model",
2391 }),
2392 );
2393 assert_eq!(run["status"], "completed");
2394
2395 let store = crate::LocalStore::open(&config).unwrap();
2396 let run_record = store
2397 .load_run(
2398 run["sessionId"].as_str().unwrap(),
2399 run["runId"].as_str().unwrap(),
2400 )
2401 .unwrap();
2402 let value = serde_json::to_value(run_record).unwrap();
2403 assert_eq!(
2404 value["input"][0]["text"],
2405 "Review via RPC.\n\nUser instruction: staged diff"
2406 );
2407 assert_eq!(value["metadata"]["cli.slash_command.name"], "review");
2408 assert_eq!(value["metadata"]["cli.slash_command.invoked"], "rv");
2409 }
2410
2411 #[test]
2412 fn http_transport_dispatches_json_rpc_requests() {
2413 let temp = tempfile::tempdir().unwrap();
2414 let config = test_config(temp.path());
2415
2416 let (response, shutdown) = http_post(
2417 &config,
2418 &json!({
2419 "jsonrpc": "2.0",
2420 "id": 1,
2421 "method": "initialize",
2422 "params": {"clientInfo": {"name": "http-test"}},
2423 }),
2424 );
2425 assert!(!shutdown.load(Ordering::SeqCst));
2426 let body = http_body(&response);
2427 assert_eq!(body["jsonrpc"], "2.0");
2428 assert_eq!(body["id"], 1);
2429 assert_eq!(body["result"]["protocolVersion"], PROTOCOL_VERSION);
2430 assert_eq!(body["result"]["serverInfo"]["name"], "starweaver-cli");
2431 }
2432
2433 #[test]
2434 fn http_transport_shutdown_marks_server_shutdown() {
2435 let temp = tempfile::tempdir().unwrap();
2436 let config = test_config(temp.path());
2437
2438 let (response, shutdown) = http_post(
2439 &config,
2440 &json!({
2441 "jsonrpc": "2.0",
2442 "id": 7,
2443 "method": "shutdown",
2444 "params": {},
2445 }),
2446 );
2447 assert!(shutdown.load(Ordering::SeqCst));
2448 let body = http_body(&response);
2449 assert_eq!(body["id"], 7);
2450 assert_eq!(body["result"]["status"], "shutdown");
2451 }
2452}