1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
10use tokio::process::{Child, Command};
11
12use crate::error::{Error, Result};
13use crate::handshake::{decode_output_config, encode_input_config, frame};
14use crate::protocol::{
15 ClientInfo, CustomAgent, FilesystemWorkspace, GeminiAPIEndpoint, HarnessConfig,
16 HarnessConfigSessionContinuationMode, HarnessSideTools, InputConfig, LifecycleHook,
17 McpServerConfig, ModelConfig, ModelType, PolicyConfig, SystemInstructions, Tool,
18 VertexEndpoint, Workspace,
19};
20
21pub const HARNESS_PATH_ENV: &str = "ANTIGRAVITY_HARNESS_PATH";
23
24const STDERR_TAIL_LINES: usize = 200;
26
27const STDERR_FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
32
33pub fn find_harness() -> Result<PathBuf> {
52 if let Some(path) = std::env::var_os(HARNESS_PATH_ENV) {
53 let path = PathBuf::from(path);
54 return match std::fs::metadata(&path) {
55 Ok(_) => Ok(path),
56 Err(source) => Err(Error::HarnessNotExecutable { path, source }),
57 };
58 }
59 which::which("localharness").map_err(|_| Error::HarnessNotFound)
60}
61
62#[derive(Debug, Clone)]
67pub struct ModelBuilder(ModelConfig);
68
69impl ModelBuilder {
70 pub fn gemini(name: impl Into<String>, api_key: impl Into<String>) -> Self {
72 Self(ModelConfig {
73 name: Some(name.into()),
74 types: vec![ModelType::Text],
75 gemini_api_endpoint: Some(GeminiAPIEndpoint {
76 api_key: Some(api_key.into()),
77 ..Default::default()
78 }),
79 ..Default::default()
80 })
81 }
82
83 pub fn vertex(
86 name: impl Into<String>,
87 project: impl Into<String>,
88 location: impl Into<String>,
89 ) -> Self {
90 Self(ModelConfig {
91 name: Some(name.into()),
92 types: vec![ModelType::Text],
93 vertex_endpoint: Some(VertexEndpoint {
94 project: Some(project.into()),
95 location: Some(location.into()),
96 ..Default::default()
97 }),
98 ..Default::default()
99 })
100 }
101
102 pub fn types(mut self, types: impl IntoIterator<Item = ModelType>) -> Self {
104 self.0.types = types.into_iter().collect();
105 self
106 }
107
108 pub fn from_config(config: ModelConfig) -> Self {
110 Self(config)
111 }
112
113 pub fn build(self) -> ModelConfig {
115 self.0
116 }
117}
118
119#[derive(Debug, Clone)]
125pub struct HarnessOptions {
126 binary: Option<PathBuf>,
127 storage_directory: Option<PathBuf>,
128 env: HashMap<String, String>,
129 client_info: Option<ClientInfo>,
130 config: HarnessConfig,
131}
132
133impl Default for HarnessOptions {
134 fn default() -> Self {
135 Self {
136 binary: None,
137 storage_directory: None,
138 env: HashMap::new(),
139 client_info: None,
140 config: HarnessConfig {
141 harness_side_tools: Some(HarnessSideTools::read_only()),
147 ..Default::default()
148 },
149 }
150 }
151}
152
153impl HarnessOptions {
154 pub fn new() -> Self {
159 Self::default()
160 }
161
162 pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
164 self.binary = Some(path.into());
165 self
166 }
167
168 pub fn storage_directory(mut self, path: impl Into<PathBuf>) -> Self {
172 self.storage_directory = Some(path.into());
173 self
174 }
175
176 pub fn workspace(mut self, directory: impl AsRef<Path>) -> Self {
178 self.config.workspaces.push(Workspace {
179 filesystem_workspace: Some(FilesystemWorkspace {
180 directory: Some(directory.as_ref().display().to_string()),
181 }),
182 });
183 self
184 }
185
186 pub fn model(mut self, model: ModelBuilder) -> Self {
188 self.config.models.push(model.build());
189 self
190 }
191
192 pub fn system_instructions(mut self, instructions: SystemInstructions) -> Self {
194 self.config.system_instructions = Some(instructions);
195 self
196 }
197
198 pub fn cascade_id(mut self, id: impl Into<String>) -> Self {
200 self.config.cascade_id = Some(id.into());
201 self.config.session_continuation_mode =
202 Some(HarnessConfigSessionContinuationMode::CreateOrResume);
203 self
204 }
205
206 pub fn continuation_mode(mut self, mode: HarnessConfigSessionContinuationMode) -> Self {
208 self.config.session_continuation_mode = Some(mode);
209 self
210 }
211
212 pub fn tool(mut self, tool: Tool) -> Self {
216 self.config.tools.push(tool);
217 self
218 }
219
220 pub fn harness_side_tools(mut self, tools: HarnessSideTools) -> Self {
226 self.config.harness_side_tools = Some(tools);
227 self
228 }
229
230 pub fn mcp_server(mut self, server: McpServerConfig) -> Self {
232 self.config.mcp_servers.push(server);
233 self
234 }
235
236 pub fn hook(mut self, hook: LifecycleHook) -> Self {
239 self.config.enabled_hooks.push(hook);
240 self
241 }
242
243 pub fn subagent(mut self, agent: CustomAgent) -> Self {
245 self.config.custom_subagents.push(agent);
246 self
247 }
248
249 pub fn policy(mut self, policy: PolicyConfig) -> Self {
251 self.config.policy_config = Some(policy);
252 self
253 }
254
255 pub fn skills_path(mut self, path: impl AsRef<Path>) -> Self {
257 self.config
258 .skills_paths
259 .push(path.as_ref().display().to_string());
260 self
261 }
262
263 pub fn app_data_dir(mut self, path: impl AsRef<Path>) -> Self {
265 self.config.app_data_dir = Some(path.as_ref().display().to_string());
266 self
267 }
268
269 pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
271 self.env.insert(key.into(), value.into());
272 self
273 }
274
275 pub fn harness_config(mut self, config: HarnessConfig) -> Self {
282 self.config = config;
283 self
284 }
285
286 pub fn config(&self) -> &HarnessConfig {
288 &self.config
289 }
290
291 fn input_config(&self) -> InputConfig {
292 InputConfig {
293 storage_directory: self
294 .storage_directory
295 .as_ref()
296 .map(|p| p.display().to_string())
297 .or(Some(String::new())),
298 client_info: Some(self.client_info.clone().unwrap_or_else(default_client_info)),
299 env: self.env.clone(),
300 ..Default::default()
301 }
302 }
303}
304
305fn default_client_info() -> ClientInfo {
306 ClientInfo {
307 language: Some("rust".into()),
308 version: Some(env!("CARGO_PKG_VERSION").into()),
309 language_version: Some(String::new()),
310 os: Some(std::env::consts::OS.into()),
311 os_version: Some(String::new()),
312 }
313}
314
315#[derive(Debug)]
319pub struct Harness {
320 child: Child,
321 port: u16,
322 api_key: String,
323 stderr: Arc<Mutex<Vec<String>>>,
324 stderr_done: tokio::sync::watch::Receiver<bool>,
327 _stdin: tokio::process::ChildStdin,
331 _stdout: tokio::process::ChildStdout,
332}
333
334impl Harness {
335 pub async fn launch(options: &HarnessOptions) -> Result<Self> {
337 let binary = match &options.binary {
338 Some(path) => path.clone(),
339 None => find_harness()?,
340 };
341 log::debug!("launching localharness at {}", binary.display());
342
343 let mut command = Command::new(&binary);
344 command
345 .stdin(Stdio::piped())
346 .stdout(Stdio::piped())
347 .stderr(Stdio::piped());
348 for (key, value) in &options.env {
349 command.env(key, value);
350 }
351 let mut child = command
352 .spawn()
353 .map_err(|source| Error::HarnessNotExecutable {
354 path: binary.clone(),
355 source,
356 })?;
357
358 let stderr = Arc::new(Mutex::new(Vec::new()));
359 let (done_tx, stderr_done) = tokio::sync::watch::channel(false);
360 match child.stderr.take() {
361 Some(pipe) => spawn_stderr_drain(pipe, Arc::clone(&stderr), done_tx),
362 None => {
364 let _ = done_tx.send(true);
365 }
366 }
367
368 let mut stdin = child.stdin.take().expect("stdin was piped");
369 let body = encode_input_config(&options.input_config());
370 stdin.write_all(&frame(&body)).await?;
371 stdin.flush().await?;
372
373 let mut stdout = child.stdout.take().expect("stdout was piped");
374 let mut len = [0u8; 4];
375 if stdout.read_exact(&mut len).await.is_err() {
376 return Err(Error::HandshakeFailed {
377 stderr: drain_tail(&stderr),
378 });
379 }
380 let mut buf = vec![0u8; u32::from_le_bytes(len) as usize];
381 if stdout.read_exact(&mut buf).await.is_err() {
382 return Err(Error::HandshakeFailed {
383 stderr: drain_tail(&stderr),
384 });
385 }
386
387 let config = decode_output_config(&buf)?;
388 let port = config.port.unwrap_or_default();
389 let api_key = config.api_key.unwrap_or_default();
390 if port <= 0 || port > i32::from(u16::MAX) {
391 return Err(Error::HandshakeFailed {
392 stderr: format!("harness reported an unusable port {port}"),
393 });
394 }
395 log::debug!("harness listening on port {port}");
396
397 Ok(Self {
398 child,
399 port: port as u16,
400 api_key,
401 stderr,
402 stderr_done,
403 _stdin: stdin,
404 _stdout: stdout,
405 })
406 }
407
408 pub fn port(&self) -> u16 {
410 self.port
411 }
412
413 pub fn api_key(&self) -> &str {
415 &self.api_key
416 }
417
418 pub fn stderr_tail(&self) -> String {
424 drain_tail(&self.stderr)
425 }
426
427 pub async fn stderr_after_exit(&self) -> String {
436 let mut done = self.stderr_done.clone();
437 if !*done.borrow() {
438 let _ = tokio::time::timeout(STDERR_FLUSH_TIMEOUT, done.changed()).await;
439 }
440 drain_tail(&self.stderr)
441 }
442
443 pub async fn shutdown(mut self) -> Result<()> {
445 self.child.start_kill()?;
446 self.child.wait().await?;
447 Ok(())
448 }
449}
450
451fn spawn_stderr_drain(
452 pipe: tokio::process::ChildStderr,
453 sink: Arc<Mutex<Vec<String>>>,
454 done: tokio::sync::watch::Sender<bool>,
455) {
456 tokio::spawn(async move {
457 use tokio::io::AsyncBufReadExt;
458 let mut lines = BufReader::new(pipe).lines();
459 while let Ok(Some(line)) = lines.next_line().await {
460 log::debug!("localharness: {line}");
461 if let Ok(mut buf) = sink.lock() {
462 if buf.len() == STDERR_TAIL_LINES {
463 buf.remove(0);
464 }
465 buf.push(line);
466 }
467 }
468 let _ = done.send(true);
469 });
470}
471
472fn drain_tail(sink: &Arc<Mutex<Vec<String>>>) -> String {
473 sink.lock().map(|buf| buf.join("\n")).unwrap_or_default()
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479
480 #[test]
481 fn input_config_carries_client_info_and_env() {
482 let options = HarnessOptions::new().env("FOO", "bar");
483 let config = options.input_config();
484 assert_eq!(config.env.get("FOO").map(String::as_str), Some("bar"));
485 assert_eq!(
486 config.client_info.unwrap().language.as_deref(),
487 Some("rust")
488 );
489 }
490
491 #[test]
492 fn builder_accumulates_workspaces_and_models() {
493 let options = HarnessOptions::new()
494 .workspace("/tmp/a")
495 .workspace("/tmp/b")
496 .model(ModelBuilder::gemini("gemini-flash-latest", "k"));
497 assert_eq!(options.config().workspaces.len(), 2);
498 assert_eq!(options.config().models.len(), 1);
499 assert_eq!(
500 options.config().workspaces[0]
501 .filesystem_workspace
502 .as_ref()
503 .unwrap()
504 .directory
505 .as_deref(),
506 Some("/tmp/a")
507 );
508 }
509
510 #[test]
511 fn cascade_id_implies_create_or_resume() {
512 let options = HarnessOptions::new().cascade_id("abc");
513 assert_eq!(
514 options.config().session_continuation_mode,
515 Some(HarnessConfigSessionContinuationMode::CreateOrResume)
516 );
517 }
518
519 #[test]
520 fn missing_binary_is_reported_as_not_found() {
521 let path = PathBuf::from("/nonexistent/localharness");
522 let err = std::fs::metadata(&path).unwrap_err();
523 let err = Error::HarnessNotExecutable { path, source: err };
524 assert!(err.to_string().contains("is not usable"));
525 }
526}