1use crate::console::{ConsoleRenderer, ConsoleRendererConfig, ConsoleSink, SyncMemorySink};
8use crate::grok_serve::{GrokServeOptions, ManagedGrokServe};
9use crate::html_report::{build_html_report, write_html_report, HtmlReport, HtmlReportParams};
10use monoloop_connector_grok::{
11 EncodedAcpSessionMessage, GrokConnector, GrokConnectorLimits, GrokServerConfig,
12 GrokSessionConfig, InMemorySecretResolver, RawDumpCollector, RawDumpSnapshot, SecretRef,
13};
14use monoloop_contracts::{
15 CanonicalUnit, DialectBinding, DialectDescriptor, InterpretationId, InterpretationLimits,
16 InterpreterOutputEvent,
17};
18use monoloop_interpreter::{DefaultInterpreterFactory, InterpreterFactory, StartInterpretation};
19use std::path::PathBuf;
20use std::sync::Arc;
21use std::time::Duration;
22
23#[derive(Clone, Debug)]
25pub struct LiveGrokRunOptions {
26 pub prompt: String,
28 pub cwd: PathBuf,
30 pub serve: GrokServeOptions,
32 pub title: String,
34 pub artifact_stem: PathBuf,
37 pub request_deadline: Duration,
40 pub prompt_wait_ceiling: Option<Duration>,
43 pub connect_timeout: Duration,
45 pub render_console: bool,
47}
48
49impl LiveGrokRunOptions {
50 pub fn for_project(project: impl Into<PathBuf>, prompt: impl Into<String>) -> Self {
52 let project = project.into();
53 let log = project.join("target/grok-serve.managed.log");
54 Self {
55 prompt: prompt.into(),
56 cwd: project.clone(),
57 serve: GrokServeOptions {
58 port: None, secret: std::env::var("GROK_AGENT_SECRET")
60 .unwrap_or_else(|_| "monoloop-live-test".into()),
61 grok_bin: PathBuf::from(
62 std::env::var("GROK_BIN").unwrap_or_else(|_| "grok".into()),
63 ),
64 ready_timeout: Duration::from_secs(15),
65 log_path: Some(log),
66 },
67 title: "Live Grok Build — interpretation review".into(),
68 artifact_stem: project.join("target/live_grok_run"),
69 request_deadline: Duration::from_secs(2 * 60 * 60),
70 prompt_wait_ceiling: None,
71 connect_timeout: Duration::from_secs(30),
72 render_console: true,
73 }
74 }
75}
76
77#[derive(Clone, Debug)]
79pub struct LiveGrokRunReport {
80 pub session_id: String,
82 pub prompt_result: String,
84 pub timed_out: bool,
86 pub events: Vec<InterpreterOutputEvent>,
88 pub html: HtmlReport,
90 pub raw: RawDumpSnapshot,
92 pub console_text: String,
94 pub sequence_text: String,
96 pub paths: LiveGrokArtifactPaths,
98 pub port: u16,
100}
101
102#[derive(Clone, Debug)]
104pub struct LiveGrokArtifactPaths {
105 pub html: PathBuf,
107 pub raw: PathBuf,
109 pub sequence: PathBuf,
111 pub chat: PathBuf,
113}
114
115pub async fn run_live_grok_prompt(opts: LiveGrokRunOptions) -> Result<LiveGrokRunReport, String> {
119 if let Some(parent) = opts.artifact_stem.parent() {
120 if !parent.as_os_str().is_empty() {
121 std::fs::create_dir_all(parent)
122 .map_err(|e| format!("create artifact dir {}: {e}", parent.display()))?;
123 }
124 }
125
126 println!(
127 "live-grok: starting managed serve (ready ≤ {:?})…",
128 opts.serve.ready_timeout
129 );
130 let serve = ManagedGrokServe::start(opts.serve.clone()).await?;
131 let port = serve.port();
132 let secret = serve.secret().to_string();
133 println!("live-grok: serve up pid={:?} port={port}", serve.pid());
134
135 let result = run_session_with_serve(&opts, &serve, &secret, port).await;
136
137 println!("live-grok: stopping serve…");
138 if let Err(e) = serve.stop().await {
139 eprintln!("live-grok: serve stop warning: {e}");
140 } else {
141 println!("live-grok: serve stopped");
142 }
143
144 result
145}
146
147async fn run_session_with_serve(
148 opts: &LiveGrokRunOptions,
149 _serve: &ManagedGrokServe,
150 secret: &str,
151 port: u16,
152) -> Result<LiveGrokRunReport, String> {
153 let secrets = Arc::new(InMemorySecretResolver::new());
154 secrets.insert("GROK_WS_SECRET", secret);
155 let dump = Arc::new(RawDumpCollector::enabled());
156
157 let mut limits = GrokConnectorLimits::default();
158 limits.request_deadline = opts.request_deadline;
159 limits.connect_deadline = opts.connect_timeout;
160
161 let mut config = GrokServerConfig::loopback(port, SecretRef::new("GROK_WS_SECRET"))
162 .map_err(|e| format!("server config: {e}"))?;
163 config.limits = limits;
164 let config = config.with_raw_dump(Arc::clone(&dump));
165
166 let connector = GrokConnector::new(secrets);
167 println!("live-grok: connecting ws://127.0.0.1:{port}/ws …");
168 let pending = connector
169 .connect(config)
170 .map_err(|e| format!("connect begin: {e}"))?;
171 let server = tokio::time::timeout(opts.connect_timeout, pending.opened)
172 .await
173 .map_err(|_| "connect timed out".to_string())?
174 .map_err(|e| format!("connect channel: {e}"))?
175 .map_err(|e| format!("connect failed: {e}"))?;
176 println!("live-grok: connected + initialized");
177
178 let pending_sess = server
179 .sessions
180 .begin_new(GrokSessionConfig {
181 cwd: Some(opts.cwd.display().to_string()),
182 mcp_servers: vec![],
183 permission_mode: Some("always-approve".into()),
184 agent_profile: None,
185 extension_metadata: Some(serde_json::json!({ "yoloMode": true })),
186 })
187 .map_err(|e| format!("session/new begin: {e}"))?;
188 let session = tokio::time::timeout(opts.connect_timeout, pending_sess.opened)
189 .await
190 .map_err(|_| "session/new timed out".to_string())?
191 .map_err(|e| format!("session channel: {e}"))?
192 .map_err(|e| format!("session/new failed: {e}"))?;
193 let session_id = session.session_id.as_str().to_string();
194 println!("live-grok: sessionId={session_id}");
195
196 let factory = DefaultInterpreterFactory::new();
197 let interp = factory
198 .start(StartInterpretation {
199 interpretation_id: InterpretationId::generate(),
200 connection_id: session.connection_id.clone(),
201 external_session_id: Some(session.session_id.clone().into()),
202 dialect: DialectBinding::negotiated(DialectDescriptor::acp_json_rpc("1")),
203 limits: InterpretationLimits::default(),
204 })
205 .map_err(|e| format!("start interpretation: {e}"))?;
206
207 let sink = Arc::new(SyncMemorySink::new());
208 let renderer = ConsoleRenderer::new(
209 ConsoleRendererConfig {
210 show_tool_payloads: true,
211 max_content_chars: 2000,
212 ..Default::default()
213 },
214 sink.clone() as Arc<dyn ConsoleSink>,
215 );
216
217 let output = Arc::clone(&session.output);
218 let input = interp.input.clone();
219 let drain = tokio::spawn(async move {
220 loop {
221 match output.receive().await {
222 Ok(Some(bytes)) => {
223 if input.push_bytes(bytes).await.is_err() {
224 break;
225 }
226 }
227 Ok(None) => break,
228 Err(_) => break,
229 }
230 }
231 });
232
233 println!(
234 "live-grok: session/prompt (request_deadline={:?}, outer_ceiling={:?})…",
235 opts.request_deadline, opts.prompt_wait_ceiling
236 );
237 let exchange = session
238 .input
239 .begin_send(EncodedAcpSessionMessage {
240 method: "session/prompt".into(),
241 params: serde_json::json!({
242 "prompt": [
243 { "type": "text", "text": opts.prompt }
244 ]
245 }),
246 })
247 .map_err(|e| format!("begin_send: {e}"))?;
248
249 let (prompt_result, timed_out) = match opts.prompt_wait_ceiling {
250 Some(ceiling) => match tokio::time::timeout(ceiling, exchange.response).await {
251 Ok(Ok(Ok(v))) => {
252 println!("live-grok: prompt complete");
253 (format!("{v}"), false)
254 }
255 Ok(Ok(Err(e))) => (format!("error:{e}"), false),
256 Ok(Err(_)) => ("channel_dropped".into(), false),
257 Err(_) => {
258 eprintln!(
259 "live-grok: outer ceiling {:?} hit — salvaging streamed events",
260 ceiling
261 );
262 ("timeout".into(), true)
263 }
264 },
265 None => match exchange.response.await {
266 Ok(Ok(v)) => {
267 println!("live-grok: prompt complete");
268 (format!("{v}"), false)
269 }
270 Ok(Err(e)) => (format!("error:{e}"), false),
271 Err(_) => ("channel_dropped".into(), false),
272 },
273 };
274
275 tokio::time::sleep(Duration::from_millis(400)).await;
277 session
278 .control
279 .cancel(monoloop_connector_grok::CancellationReason::CallerRequested);
280 let _ = tokio::time::timeout(Duration::from_secs(3), drain).await;
281 let _ = interp.input.finish_clean().await;
282
283 let mut events = Vec::new();
284 loop {
285 match tokio::time::timeout(Duration::from_secs(2), interp.events.recv()).await {
286 Ok(Some(ev)) => {
287 if opts.render_console {
288 renderer.render(&ev);
289 }
290 let done = matches!(ev, InterpreterOutputEvent::Ended(_));
291 events.push(ev);
292 if done {
293 break;
294 }
295 }
296 Ok(None) => break,
297 Err(_) => break,
298 }
299 }
300
301 let raw = dump.snapshot();
302 let html = build_html_report(
303 &events,
304 &HtmlReportParams {
305 title: opts.title.clone(),
306 show_tool_payloads: true,
307 ..Default::default()
308 },
309 );
310
311 let sequence_text = format_sequence(&session_id, &prompt_result, &events);
312 let paths = LiveGrokArtifactPaths {
313 html: PathBuf::from(format!("{}.html", opts.artifact_stem.display())),
314 raw: PathBuf::from(format!("{}.raw.txt", opts.artifact_stem.display())),
315 sequence: PathBuf::from(format!("{}.sequence.txt", opts.artifact_stem.display())),
316 chat: PathBuf::from(format!("{}.chat.txt", opts.artifact_stem.display())),
317 };
318
319 std::fs::write(&paths.raw, raw.format_text()).map_err(|e| format!("write raw: {e}"))?;
320 std::fs::write(&paths.sequence, &sequence_text).map_err(|e| format!("write sequence: {e}"))?;
321 write_html_report(&paths.html, &html).map_err(|e| format!("write html: {e}"))?;
322 std::fs::write(&paths.chat, &html.chat_projection.plain_text)
323 .map_err(|e| format!("write chat: {e}"))?;
324
325 Ok(LiveGrokRunReport {
326 session_id,
327 prompt_result,
328 timed_out,
329 events,
330 html,
331 raw,
332 console_text: sink.join(),
333 sequence_text,
334 paths,
335 port,
336 })
337}
338
339fn format_sequence(
340 session_id: &str,
341 prompt_result: &str,
342 events: &[InterpreterOutputEvent],
343) -> String {
344 let mut sequence = String::new();
345 sequence.push_str("=== LIVE GROK MANAGED RUN — CANONICAL EVENT SEQUENCE ===\n");
346 sequence.push_str(&format!("sessionId={session_id}\n"));
347 sequence.push_str(&format!("prompt_result={prompt_result}\n\n"));
348 for (i, ev) in events.iter().enumerate() {
349 sequence.push_str(&format!("{:04} {}\n", i, describe_event(ev)));
350 }
351 sequence.push_str(&format!("\n=== total events: {} ===\n", events.len()));
352 sequence.push_str("\n=== TOOL ACTIONS (compressed) ===\n");
353 for line in tool_summary(events) {
354 sequence.push_str(&line);
355 sequence.push('\n');
356 }
357 sequence
358}
359
360fn describe_event(ev: &InterpreterOutputEvent) -> String {
361 match ev {
362 InterpreterOutputEvent::Unit(u) => {
363 let s = u.snapshot();
364 match &s.unit {
365 CanonicalUnit::Text(t) => format!(
366 "TEXT ch={:?} g={} | {}",
367 t.channel,
368 s.unit_generation,
369 truncate(&t.content, 120)
370 ),
371 CanonicalUnit::Tool(t) => format!(
372 "TOOL action={} name={:?} req={:?} exec={:?} g={} wait={:?} args={}",
373 t.tool_action_id.as_str(),
374 t.tool_name,
375 t.request_state,
376 t.execution_state,
377 s.unit_generation,
378 t.waiting_for,
379 t.request_payload
380 .as_deref()
381 .map(|p| truncate(p, 80))
382 .unwrap_or_else(|| "-".into())
383 ),
384 CanonicalUnit::Boundary(b) => format!("BOUNDARY {:?}", b.kind),
385 CanonicalUnit::Structure(st) => {
386 format!("STRUCTURE {:?} | {}", st.kind, truncate(&st.content, 80))
387 }
388 CanonicalUnit::Diagnostic(d) => {
389 format!("DIAG {:?} | {}", d.kind, truncate(&d.message, 100))
390 }
391 CanonicalUnit::Paragraph(p) => format!("PARAGRAPH {:?}", p.kind),
392 CanonicalUnit::Usage(u) => format!("USAGE {u:?}"),
393 }
394 }
395 InterpreterOutputEvent::Ended(e) => format!(
396 "END {:?} events={} sentences={} unresolved={}",
397 e.kind, e.canonical_event_count, e.completed_sentence_count, e.unresolved_text_bytes
398 ),
399 }
400}
401
402fn tool_summary(events: &[InterpreterOutputEvent]) -> Vec<String> {
403 use std::collections::BTreeMap;
404 let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
405 for ev in events {
406 if let InterpreterOutputEvent::Unit(u) = ev {
407 if let CanonicalUnit::Tool(t) = &u.snapshot().unit {
408 let id = t.tool_action_id.as_str().to_string();
409 map.entry(id).or_default().push(format!(
410 "g{} {:?} name={:?} terminal={:?}",
411 u.snapshot().unit_generation,
412 t.request_state,
413 t.tool_name,
414 t.terminal_outcome
415 ));
416 }
417 }
418 }
419 map.into_iter()
420 .map(|(id, gens)| format!("{id}: {}", gens.join(" → ")))
421 .collect()
422}
423
424fn truncate(s: &str, max: usize) -> String {
425 let t: String = s.chars().take(max).collect();
426 if s.chars().count() > max {
427 format!("{t}…")
428 } else {
429 t
430 }
431}