1use super::{Application, ApplicationError, Inner};
2use crate::event::{EventKind, StateEvent};
3use crate::store::{NewTraceSpan, TraceEnding};
4use crate::terminal::TerminalObservation;
5use crate::{
6 TerminalId, TerminalStatus, TraceAnchor, TraceEventId, TraceEventKind, TraceEventsPage,
7 TraceSpanId, TraceSpanStatus, Workflow, WorkflowId, WorkflowTracePage,
8};
9
10#[derive(Clone, Debug)]
11pub(super) struct PendingTraceEnding {
12 observation: TerminalObservation,
13 status: TraceSpanStatus,
14 kind: TraceEventKind,
15 message: String,
16}
17
18impl PendingTraceEnding {
19 fn as_store_ending(&self, terminal_id: TerminalId) -> TraceEnding<'_> {
20 TraceEnding {
21 observed_at: self.observation.observed_at,
22 status: self.status,
23 kind: self.kind,
24 message: &self.message,
25 anchor: Some(TraceAnchor {
26 terminal_id,
27 byte_offset: self.observation.byte_offset,
28 boundary_sizes: self.observation.boundary_sizes.clone(),
29 }),
30 }
31 }
32}
33
34impl Application {
35 pub fn trace_storage_status(
40 &self,
41 span: Option<TraceSpanId>,
42 ) -> Result<crate::TraceStorageStatus, ApplicationError> {
43 let inner = self.lock_inner()?;
44 let status = inner.folders.read_store().storage_status(span)?;
45 let request = inner.folders.read_store().maintenance_request(false);
46 drop(inner);
47 if let Some(request) = request {
48 self.history
49 .lock()
50 .map_err(|_| ApplicationError::Poisoned)?
51 .request(request);
52 }
53 Ok(status)
54 }
55
56 pub fn pin_trace_details(
61 &self,
62 span: TraceSpanId,
63 pinned: bool,
64 ) -> Result<(), ApplicationError> {
65 self.lock_inner()?
66 .folders
67 .store()
68 .pin_trace_details(span, pinned)?;
69 Ok(())
70 }
71
72 pub fn clear_trace_details(&self) -> Result<(), ApplicationError> {
77 self.lock_inner()?.folders.store().request_trace_clear()?;
78 if let Some(request) = self
79 .lock_inner()?
80 .folders
81 .read_store()
82 .maintenance_request(true)
83 {
84 self.history
85 .lock()
86 .map_err(|_| ApplicationError::Poisoned)?
87 .request(request);
88 }
89 Ok(())
90 }
91 pub fn trace_detail(
96 &self,
97 activity: crate::TraceActivityId,
98 output: bool,
99 offset: u64,
100 limit: usize,
101 ) -> Result<crate::TraceDetailPage, ApplicationError> {
102 Ok(self
103 .lock_inner()?
104 .folders
105 .read_store()
106 .trace_detail(activity, output, offset, limit)?)
107 }
108 pub fn trace_activities(
114 &self,
115 span_id: TraceSpanId,
116 after: Option<crate::TraceActivityId>,
117 limit: usize,
118 ) -> Result<crate::TraceActivitiesPage, ApplicationError> {
119 self.poll_harness_steps()?;
120 if let Some(request) = self
121 .lock_inner()?
122 .folders
123 .read_store()
124 .history_request(span_id)?
125 {
126 self.history
127 .lock()
128 .map_err(|_| ApplicationError::Poisoned)?
129 .request(request);
130 }
131 Ok(self
132 .lock_inner()?
133 .folders
134 .read_store()
135 .trace_activities(span_id, after, limit)?)
136 }
137
138 pub(super) fn poll_shell_observations(&self) -> Result<(), ApplicationError> {
139 let mut inner = self.lock_inner()?;
140 inner.record_shell_observations(Vec::new());
141 if inner.pending_shell_marks.is_empty() {
142 inner.record_shell_observations(self.terminal_output.take_shell_observations()?);
143 }
144 inner.finish_shell_process_endings(&self.terminal_output);
145 Ok(())
146 }
147
148 pub fn workflow_trace(
153 &self,
154 workflow_id: WorkflowId,
155 before: Option<TraceSpanId>,
156 limit: usize,
157 ) -> Result<WorkflowTracePage, ApplicationError> {
158 self.poll_harness_steps()?;
159 self.poll_shell_observations()?;
160 let inner = self.lock_inner()?;
161 let mut page = inner
162 .folders
163 .read_store()
164 .workflow_trace(workflow_id, before, limit)?;
165 for span in &mut page.spans {
166 let live_assignment = page
167 .lanes
168 .iter()
169 .find(|lane| lane.lane_id == span.lane_id)
170 .and_then(|lane| lane.agent_id)
171 .is_some_and(|agent_id| {
172 inner.workflows.workflows.iter().any(|workflow| {
173 workflow.workflow_id == workflow_id
174 && workflow.run.as_ref().is_some_and(|run| {
175 run.status == crate::RunStatus::Running
176 && run
177 .active_agents()
178 .iter()
179 .any(|agent| agent.agent_id == agent_id.0)
180 })
181 && workflow.agents.iter().any(|agent| {
182 agent.agent_id == agent_id
183 && span.terminal_id.is_none_or(|id| agent.terminal_id == id)
184 })
185 })
186 });
187 span.is_live = span.status == TraceSpanStatus::Running
188 && (live_assignment
189 || span.terminal_id.is_some_and(|id| {
190 inner.trace_spans.get(&id) == Some(&span.span_id)
191 && inner.terminals.get(&id) == Some(&TerminalStatus::Running)
192 }));
193 }
194 Ok(page)
195 }
196
197 pub fn trace_events(
202 &self,
203 span_id: TraceSpanId,
204 after: Option<TraceEventId>,
205 limit: usize,
206 ) -> Result<TraceEventsPage, ApplicationError> {
207 self.poll_harness_steps()?;
208 Ok(self
209 .lock_inner()?
210 .folders
211 .read_store()
212 .trace_events(span_id, after, limit)?)
213 }
214}
215
216impl Inner {
217 pub(super) fn record_shell_observations(
218 &mut self,
219 observations: Vec<crate::terminal::ShellObservation>,
220 ) {
221 for item in &observations {
222 if let Some(workflow) = self.workflows.workflows.iter().find(|workflow| {
223 workflow.kind == crate::WorkflowKind::Terminal
224 && workflow.terminal_id == item.terminal_id
225 }) {
226 self.pending_shell_workflows
227 .entry(item.terminal_id)
228 .or_insert_with(|| workflow.clone());
229 }
230 }
231 self.pending_shell_marks.extend(observations);
232 while let Some(item) = self.pending_shell_marks.front().cloned() {
233 if let Err(error) = self.record_shell_mark(item) {
234 tracing::warn!(%error, "couldn't persist shell command boundary");
235 break;
236 }
237 self.pending_shell_marks.pop_front();
238 }
239 self.pending_shell_workflows.retain(|id, _| {
240 self.pending_shell_marks
241 .iter()
242 .any(|item| item.terminal_id == *id)
243 });
244 }
245
246 pub(super) fn defer_shell_process_ending(
247 &mut self,
248 id: TerminalId,
249 observation: &TerminalObservation,
250 status: TraceSpanStatus,
251 kind: TraceEventKind,
252 message: &str,
253 ) -> bool {
254 if !observation.integrated_shell
255 && !self.command_shells.contains(&id)
256 && !self
257 .pending_shell_marks
258 .iter()
259 .any(|item| item.terminal_id == id)
260 {
261 return false;
262 }
263 self.shell_process_endings.insert(
264 id,
265 PendingTraceEnding {
266 observation: observation.clone(),
267 status,
268 kind,
269 message: message.into(),
270 },
271 );
272 true
273 }
274
275 pub(super) fn finish_shell_process_endings(
276 &mut self,
277 output: &crate::terminal::TerminalStream,
278 ) {
279 let finished = self
280 .shell_process_endings
281 .iter()
282 .filter(|(id, _)| {
283 output.reader_finished(**id)
284 && !self
285 .pending_shell_marks
286 .iter()
287 .any(|item| item.terminal_id == **id)
288 })
289 .map(|(&id, ending)| (id, ending.clone()))
290 .collect::<Vec<_>>();
291 for (id, mut ending) in finished {
292 if self.command_shells.contains(&id) {
293 ending.status = TraceSpanStatus::Stopped;
294 ending.kind = TraceEventKind::WorkflowEvent;
295 ending.message = "Shell ended before the command's ending was recorded.".into();
296 }
297 if let Err(error) = self.end_trace(
298 id,
299 ending.observation,
300 ending.status,
301 ending.kind,
302 &ending.message,
303 ) {
304 tracing::warn!(%error, "couldn't persist shell process ending");
305 } else {
306 self.shell_process_endings.remove(&id);
307 self.command_shells.remove(&id);
308 }
309 }
310 }
311
312 fn record_shell_mark(
313 &mut self,
314 item: crate::terminal::ShellObservation,
315 ) -> Result<(), ApplicationError> {
316 use crate::terminal::ShellMark;
317 let terminal_id = item.terminal_id;
318 let Some(workflow) = self
319 .workflows
320 .workflows
321 .iter()
322 .find(|workflow| {
323 workflow.kind == crate::WorkflowKind::Terminal
324 && workflow.terminal_id == terminal_id
325 })
326 .cloned()
327 .or_else(|| self.pending_shell_workflows.get(&terminal_id).cloned())
328 else {
329 return Ok(());
330 };
331 if !matches!(&item.mark, ShellMark::Stopped(_))
332 && !self.command_shells.contains(&terminal_id)
333 {
334 if let Some(span) = self.trace_spans.get(&terminal_id).copied() {
335 self.folders.store().activate_command_trace(span)?;
336 self.trace_spans.remove(&terminal_id);
337 }
338 self.command_shells.insert(terminal_id);
339 }
340 match item.mark {
341 ShellMark::Prompt => {}
342 ShellMark::CommandStart(title) => {
343 self.end_trace(
344 terminal_id,
345 item.observation.clone(),
346 TraceSpanStatus::Stopped,
347 TraceEventKind::WorkflowEvent,
348 "Command ending was not recorded.",
349 )?;
350 let mut span = new_span(&workflow);
351 span.title = &title;
352 span.started_at = item.observation.observed_at;
353 span.anchor = Some(TraceAnchor {
354 terminal_id,
355 byte_offset: item.observation.byte_offset,
356 boundary_sizes: item.observation.boundary_sizes,
357 });
358 let id = self.folders.store().start_command_trace(&span)?;
359 self.trace_spans.insert(terminal_id, id);
360 }
361 ShellMark::CommandEnd(code) => {
362 self.end_trace(
363 terminal_id,
364 item.observation,
365 if code == 0 {
366 TraceSpanStatus::Completed
367 } else {
368 TraceSpanStatus::Failed
369 },
370 TraceEventKind::WorkflowEvent,
371 &format!("Command exited with code {code}."),
372 )?;
373 }
374 ShellMark::Lost => {
375 self.end_trace(
376 terminal_id,
377 item.observation.clone(),
378 TraceSpanStatus::Stopped,
379 TraceEventKind::WorkflowEvent,
380 "Shell integration boundaries were lost.",
381 )?;
382 let mut span = new_span(&workflow);
383 span.started_at = item.observation.observed_at;
384 span.anchor = Some(TraceAnchor {
385 terminal_id,
386 byte_offset: item.observation.byte_offset,
387 boundary_sizes: item.observation.boundary_sizes,
388 });
389 let id = self.folders.store().start_trace_span(&span)?;
390 self.trace_spans.insert(terminal_id, id);
391 self.command_shells.remove(&terminal_id);
392 }
393 ShellMark::Stopped(message) => {
394 self.end_trace(
395 terminal_id,
396 item.observation,
397 TraceSpanStatus::Stopped,
398 TraceEventKind::WorkflowEvent,
399 &message,
400 )?;
401 self.command_shells.remove(&terminal_id);
402 }
403 }
404 if let Err(error) = self.publish_trace(workflow.workflow_id) {
406 tracing::warn!(%error, "couldn't publish shell trace revision");
407 }
408 Ok(())
409 }
410
411 pub(super) fn start_trace(&mut self, workflow: &Workflow) -> Result<(), ApplicationError> {
412 self.folders.store().remember_terminals(workflow)?;
413 if workflow.kind == crate::WorkflowKind::Draft {
414 return Ok(());
415 }
416 if workflow.kind == crate::WorkflowKind::Agents {
417 let keys = workflow
418 .agents
419 .iter()
420 .map(|agent| format!("agent:{}", agent.agent_id.0))
421 .collect::<Vec<_>>();
422 let spans = workflow
423 .agents
424 .iter()
425 .zip(&keys)
426 .filter(|(agent, _)| agent.terminal_id.value() != 0)
427 .map(|(agent, key)| NewTraceSpan {
428 workflow_id: workflow.workflow_id,
429 lane_key: key,
430 lane_name: &agent.role,
431 is_agent: true,
432 role: Some(&agent.role),
433 harness: None,
434 title: "Shell",
435 started_at: workflow.started_at,
436 anchor: Some(TraceAnchor {
437 terminal_id: agent.terminal_id,
438 byte_offset: 0,
439 boundary_sizes: Some(Vec::new()),
440 }),
441 })
442 .collect::<Vec<_>>();
443 let ids = self.folders.store().start_trace_spans(&spans)?;
444 for (span, id) in spans.iter().zip(ids) {
445 if let Some(anchor) = &span.anchor {
446 self.trace_spans.insert(anchor.terminal_id, id);
447 }
448 }
449 return self.publish_trace(workflow.workflow_id);
450 }
451 let span_id = self.folders.store().start_trace_span(&new_span(workflow))?;
452 self.trace_spans.insert(workflow.terminal_id, span_id);
453 self.publish_trace(workflow.workflow_id)
454 }
455
456 pub(super) fn start_agent_trace(
457 &mut self,
458 workflow: &Workflow,
459 harness: crate::HarnessId,
460 placeholder: TerminalId,
461 observation: Option<TerminalObservation>,
462 session: Option<&str>,
463 ) -> Result<(), ApplicationError> {
464 let ending = self
465 .pending_trace_endings
466 .get(&placeholder)
467 .cloned()
468 .or_else(|| {
469 observation.map(|observation| PendingTraceEnding {
470 observation,
471 status: TraceSpanStatus::Stopped,
472 kind: TraceEventKind::ProcessStopped,
473 message: "Shell stopped when its agent started.".to_owned(),
474 })
475 });
476 let ending = ending
477 .as_ref()
478 .map(|ending| ending.as_store_ending(placeholder));
479 let placeholder_span = self.trace_spans.get(&placeholder).copied();
480 let span_id = self.folders.store().start_agent_trace(
481 &new_span(workflow),
482 harness,
483 placeholder_span.zip(ending.as_ref()),
484 session,
485 )?;
486 self.trace_spans.remove(&placeholder);
487 self.pending_trace_endings.remove(&placeholder);
488 self.trace_spans.insert(workflow.terminal_id, span_id);
489 self.publish_trace(workflow.workflow_id)
490 }
491
492 pub(super) fn stop_trace(
493 &mut self,
494 terminal_id: TerminalId,
495 observation: TerminalObservation,
496 message: &str,
497 ) -> Result<(), ApplicationError> {
498 self.stop_pending_shell_trace(terminal_id, &observation, message);
499 self.end_trace(
500 terminal_id,
501 observation,
502 TraceSpanStatus::Stopped,
503 TraceEventKind::ProcessStopped,
504 message,
505 )
506 }
507
508 fn stop_pending_shell_trace(
509 &mut self,
510 terminal_id: TerminalId,
511 observation: &TerminalObservation,
512 message: &str,
513 ) {
514 self.shell_process_endings.remove(&terminal_id);
515 if self
516 .pending_shell_marks
517 .iter()
518 .any(|item| item.terminal_id == terminal_id)
519 {
520 self.pending_shell_marks
521 .push_back(crate::terminal::ShellObservation {
522 terminal_id,
523 mark: crate::terminal::ShellMark::Stopped(message.into()),
524 observation: observation.clone(),
525 });
526 } else {
527 self.command_shells.remove(&terminal_id);
528 }
529 }
530
531 pub(super) fn end_trace(
532 &mut self,
533 terminal_id: TerminalId,
534 observation: TerminalObservation,
535 status: TraceSpanStatus,
536 kind: TraceEventKind,
537 message: &str,
538 ) -> Result<(), ApplicationError> {
539 let Some(span_id) = self.trace_spans.get(&terminal_id).copied() else {
540 if let Some(&workflow_id) = self.step_terminals.get(&terminal_id) {
541 let ending = self
542 .pending_trace_endings
543 .entry(terminal_id)
544 .or_insert_with(|| PendingTraceEnding {
545 observation,
546 status,
547 kind,
548 message: message.into(),
549 });
550 self.folders.store().record_harness_process_ending(
551 workflow_id,
552 terminal_id,
553 &ending.as_store_ending(terminal_id),
554 )?;
555 self.pending_trace_endings.remove(&terminal_id);
556 self.publish_trace(workflow_id)?;
557 }
558 return Ok(());
559 };
560 let ending = self
561 .pending_trace_endings
562 .entry(terminal_id)
563 .or_insert_with(|| PendingTraceEnding {
564 observation,
565 status,
566 kind,
567 message: message.to_owned(),
568 });
569 let workflow_id = self
570 .folders
571 .store()
572 .finish_trace_span(span_id, &ending.as_store_ending(terminal_id))?;
573 self.trace_spans.remove(&terminal_id);
574 self.pending_trace_endings.remove(&terminal_id);
575 if let Some(id) = workflow_id {
576 self.publish_trace(id)?;
577 }
578 Ok(())
579 }
580
581 pub(super) fn close_workflow_trace(
582 &mut self,
583 workflow_id: WorkflowId,
584 observations: &[(TerminalId, TerminalObservation)],
585 ) -> Result<(), ApplicationError> {
586 let idle_endings: Vec<_> = observations
587 .iter()
588 .filter(|(terminal_id, _)| {
589 self.step_terminals.contains_key(terminal_id)
590 && !self.trace_spans.contains_key(terminal_id)
591 })
592 .map(|(terminal_id, observation)| {
593 (
594 *terminal_id,
595 TraceEnding {
596 observed_at: observation.observed_at,
597 status: TraceSpanStatus::Stopped,
598 kind: TraceEventKind::ProcessStopped,
599 message: "Process stopped when its workflow was closed.",
600 anchor: Some(TraceAnchor {
601 terminal_id: *terminal_id,
602 byte_offset: observation.byte_offset,
603 boundary_sizes: observation.boundary_sizes.clone(),
604 }),
605 },
606 )
607 })
608 .collect();
609 let pending = observations
610 .iter()
611 .filter_map(|(terminal_id, observation)| {
612 self.trace_spans.get(terminal_id).map(|span_id| {
613 let ending = self
614 .pending_trace_endings
615 .get(terminal_id)
616 .cloned()
617 .unwrap_or_else(|| PendingTraceEnding {
618 observation: observation.clone(),
619 status: TraceSpanStatus::Stopped,
620 kind: TraceEventKind::ProcessStopped,
621 message: "Process stopped when its workflow was closed.".to_owned(),
622 });
623 (*terminal_id, *span_id, ending)
624 })
625 })
626 .collect::<Vec<_>>();
627 let endings = pending
628 .iter()
629 .map(|(terminal_id, span_id, ending)| (*span_id, ending.as_store_ending(*terminal_id)))
630 .collect::<Vec<_>>();
631 let changed = self.folders.store().close_workflow_and_trace(
632 workflow_id,
633 crate::workflow::timestamp(),
634 &endings,
635 &idle_endings,
636 )?;
637 for (terminal_id, _) in observations {
638 self.trace_spans.remove(terminal_id);
639 self.pending_trace_endings.remove(terminal_id);
640 }
641 for (terminal_id, observation) in observations {
642 self.stop_pending_shell_trace(
643 *terminal_id,
644 observation,
645 "Command stopped when its workflow was closed.",
646 );
647 }
648 if changed {
649 self.publish_trace(workflow_id)?;
650 }
651 Ok(())
652 }
653
654 pub(super) fn retry_trace_endings(&mut self) {
655 let pending = self.pending_trace_endings.clone();
656 for (id, ending) in pending {
657 if let Err(error) = self.end_trace(
658 id,
659 ending.observation,
660 ending.status,
661 ending.kind,
662 &ending.message,
663 ) {
664 tracing::warn!(%error, "failed to retry observed trace ending");
665 }
666 }
667 }
668
669 pub(super) fn publish_trace(&mut self, id: WorkflowId) -> Result<(), ApplicationError> {
670 let summary = self.folders.store().trace_summary(id)?;
671 self.events
672 .append(EventKind::State(StateEvent::TraceChanged(summary)))?;
673 Ok(())
674 }
675}
676
677fn new_span(workflow: &Workflow) -> NewTraceSpan<'_> {
678 let is_agent = workflow.kind == crate::WorkflowKind::SingleAgent;
679 NewTraceSpan {
680 workflow_id: workflow.workflow_id,
681 lane_key: if is_agent { "agent" } else { "terminal" },
682 lane_name: if is_agent { "Agent" } else { "Terminal" },
683 is_agent,
684 role: is_agent.then_some("agent"),
685 harness: workflow.harness.map(|harness| harness.definition().name),
686 title: if is_agent { &workflow.name } else { "Shell" },
687 started_at: workflow.started_at,
688 anchor: Some(TraceAnchor {
689 terminal_id: workflow.terminal_id,
690 byte_offset: 0,
691 boundary_sizes: Some(Vec::new()),
692 }),
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use std::thread;
699 use std::time::{Duration, Instant};
700
701 use super::*;
702 use crate::{
703 Command, CommandDisposition, RequestId, TerminalSize, WorkflowKind, WorkflowStatus,
704 };
705
706 fn create(application: &Application, folder: &std::path::Path) -> Workflow {
707 application
708 .handle_command(
709 RequestId(1),
710 Command::OpenFolder {
711 path: folder.to_owned(),
712 },
713 )
714 .unwrap();
715 let receipt = application
716 .handle_command(
717 RequestId(2),
718 Command::CreateWorkflow {
719 folder: folder.to_owned(),
720 session_id: None,
721 kind: WorkflowKind::Terminal,
722 roles: Vec::new(),
723 size: TerminalSize {
724 rows: 24,
725 columns: 80,
726 pixel_width: 800,
727 pixel_height: 480,
728 },
729 },
730 )
731 .unwrap();
732 assert_eq!(receipt.disposition, CommandDisposition::Accepted);
733 application.snapshot().unwrap().workflows.workflows[0].clone()
734 }
735
736 fn reject_trace_writes(application: &Application) {
737 application.lock_inner().unwrap().folders.store().execute_test_sql(
738 "CREATE TRIGGER reject_trace BEFORE INSERT ON trace_events BEGIN SELECT RAISE(FAIL, 'test recording failure'); END;"
739 );
740 }
741
742 fn allow_trace_writes(application: &Application) {
743 application
744 .lock_inner()
745 .unwrap()
746 .folders
747 .store()
748 .execute_test_sql("DROP TRIGGER reject_trace");
749 }
750
751 fn wait_shell_trace(
752 application: &Application,
753 workflow: &Workflow,
754 ready: impl Fn(&WorkflowTracePage) -> bool,
755 ) -> WorkflowTracePage {
756 let deadline = Instant::now() + Duration::from_secs(10);
757 loop {
758 while let Some(chunk) = application.next_terminal_chunk().unwrap() {
759 if chunk.bytes.windows(4).any(|bytes| bytes == b"\x1b[0c") {
762 application
763 .write_terminal_input(workflow.terminal_id, b"\x1b[?62;4c")
764 .unwrap();
765 }
766 }
767 let page = application
768 .workflow_trace(workflow.workflow_id, None, 200)
769 .unwrap();
770 if ready(&page) {
771 return page;
772 }
773 assert!(Instant::now() < deadline, "shell trace timed out: {page:?}");
774 thread::sleep(Duration::from_millis(5));
775 }
776 }
777
778 fn check_command_shell(shell: &str) {
779 let folder = tempfile::tempdir().unwrap();
780 let home = tempfile::tempdir().unwrap();
781 let profile = "printf 'TWINE_STARTUP\\n'\n";
782 for file in [".zshenv", ".zprofile", ".zshrc", ".zlogin", ".bash_profile"] {
783 std::fs::write(home.path().join(file), profile).unwrap();
784 }
785 std::fs::create_dir_all(home.path().join(".config/fish")).unwrap();
786 std::fs::write(home.path().join(".config/fish/config.fish"), profile).unwrap();
787 let mut application = Application::with_event_capacity(4096).unwrap();
788 application.terminals.set_test_shell(shell.into());
789 application
790 .terminals
791 .set_test_shell_home(home.path().to_owned());
792 let workflow = create(&application, folder.path());
793 wait_shell_trace(&application, &workflow, |page| page.spans.is_empty());
794 let commands = [
795 ("printf 'TWINE_OUTPUT ☃\\n'", TraceSpanStatus::Completed, 0),
796 ("false", TraceSpanStatus::Failed, 1),
797 (
798 "printf 'pipe\\n' | cat; true",
799 TraceSpanStatus::Completed,
800 0,
801 ),
802 ("false", TraceSpanStatus::Failed, 1),
803 ];
804 for (index, (command, status, code)) in commands.iter().enumerate() {
805 application
806 .write_terminal_input(workflow.terminal_id, format!("{command}\n").as_bytes())
807 .unwrap();
808 let page = wait_shell_trace(&application, &workflow, |page| {
809 page.spans.len() == index + 1 && page.spans[0].ended_at.is_some()
810 });
811 let span = &page.spans[0];
812 assert_eq!(&span.title, command, "{shell}");
813 assert_eq!(&span.status, status, "{shell}");
814 assert!(!span.is_live);
815 let events = application
816 .trace_events(span.span_id, None, 10)
817 .unwrap()
818 .events;
819 assert_eq!(events.len(), 2, "{shell}: {events:?}");
820 assert_eq!(events[0].message, "Command started.");
821 assert_eq!(
822 events[1].message,
823 format!("Command exited with code {code}.")
824 );
825 let start = events[0].anchor.as_ref().unwrap();
826 let end = events[1].anchor.as_ref().unwrap();
827 assert!(end.byte_offset > start.byte_offset);
828 assert_eq!(start.terminal_id, workflow.terminal_id);
829 let crate::TranscriptRead::Output(page) = application
830 .read_terminal_transcript(workflow.terminal_id, 0, crate::MAX_TRANSCRIPT_READ_BYTES)
831 .unwrap()
832 else {
833 panic!("missing transcript");
834 };
835 assert!(page.replay_available);
836 assert!(page.bytes[..usize::try_from(start.byte_offset).unwrap()].ends_with(b"\x07"));
837 if index == 0 {
838 assert!(
839 String::from_utf8_lossy(
840 &page.bytes[usize::try_from(start.byte_offset).unwrap()
841 ..usize::try_from(end.byte_offset).unwrap()]
842 )
843 .contains("TWINE_OUTPUT ☃")
844 );
845 application
846 .write_terminal_input(
847 workflow.terminal_id,
848 if shell == "/bin/bash" {
849 b"\n# comment only\n"
850 } else {
851 b"\n"
852 },
853 )
854 .unwrap();
855 }
856 }
857 application
858 .write_terminal_input(workflow.terminal_id, b"exit 7\n")
859 .unwrap();
860 let page = wait_shell_trace(&application, &workflow, |page| {
861 page.spans.len() == 5 && page.spans[0].ended_at.is_some()
862 });
863 assert_eq!(page.spans[0].title, "exit 7");
864 assert_eq!(page.spans[0].status, TraceSpanStatus::Failed);
865 for file in [".zshenv", ".zprofile", ".zshrc", ".zlogin", ".bash_profile"] {
866 assert_eq!(
867 std::fs::read_to_string(home.path().join(file)).unwrap(),
868 profile
869 );
870 }
871 }
872
873 #[test]
874 fn zsh_commands_have_titles_statuses_and_output_anchors() {
875 check_command_shell("/bin/zsh");
876 }
877
878 #[test]
879 fn bash_commands_have_titles_statuses_and_output_anchors() {
880 check_command_shell("/bin/bash");
881 }
882
883 #[test]
884 fn fish_commands_have_titles_statuses_and_output_anchors() {
885 let shell = [
886 "/opt/homebrew/bin/fish",
887 "/usr/local/bin/fish",
888 "/usr/bin/fish",
889 ]
890 .into_iter()
891 .find(|path| std::path::Path::new(path).is_file());
892 if let Some(shell) = shell {
893 check_command_shell(shell);
894 } else {
895 assert!(
896 std::env::var_os("TWINE_REQUIRE_FISH_TESTS").is_none(),
897 "install fish to run the shell integration tests"
898 );
899 }
900 }
901
902 #[test]
903 fn bash_disabled_history_uses_one_lifetime_span() {
904 let folder = tempfile::tempdir().unwrap();
905 let home = tempfile::tempdir().unwrap();
906 std::fs::write(home.path().join(".bash_profile"), "HISTSIZE=0\n").unwrap();
907 let mut application = Application::with_event_capacity(4096).unwrap();
908 application.terminals.set_test_shell("/bin/bash".into());
909 application
910 .terminals
911 .set_test_shell_home(home.path().to_owned());
912 let workflow = create(&application, folder.path());
913 application
914 .write_terminal_input(workflow.terminal_id, b"false\nexit 7\n")
915 .unwrap();
916 let page = wait_shell_trace(&application, &workflow, |page| {
917 page.spans[0].ended_at.is_some()
918 });
919 assert_eq!(page.spans.len(), 1);
920 assert_eq!(page.spans[0].title, "Shell");
921 assert_eq!(page.spans[0].status, TraceSpanStatus::Exited);
922 }
923
924 #[test]
925 fn bash_history_disabled_during_a_session_falls_back_once() {
926 let folder = tempfile::tempdir().unwrap();
927 let home = tempfile::tempdir().unwrap();
928 let mut application = Application::with_event_capacity(4096).unwrap();
929 application.terminals.set_test_shell("/bin/bash".into());
930 application
931 .terminals
932 .set_test_shell_home(home.path().to_owned());
933 let workflow = create(&application, folder.path());
934 wait_shell_trace(&application, &workflow, |page| page.spans.is_empty());
935 application
936 .write_terminal_input(workflow.terminal_id, b"HISTSIZE=0\n")
937 .unwrap();
938 wait_shell_trace(&application, &workflow, |page| {
939 page.spans.len() == 1 && page.spans[0].ended_at.is_some()
940 });
941 application
942 .write_terminal_input(workflow.terminal_id, b"true\nfalse\nexit\n")
943 .unwrap();
944 let page = wait_shell_trace(&application, &workflow, |page| {
945 page.spans.len() == 2 && page.spans[0].ended_at.is_some()
946 });
947 assert_eq!(page.spans[0].title, "Shell");
948 assert_eq!(page.spans[1].title, "HISTSIZE=0");
949 assert_eq!(page.spans[1].status, TraceSpanStatus::Completed);
950 }
951
952 fn command_observation(
953 workflow: &Workflow,
954 mark: crate::terminal::ShellMark,
955 ) -> crate::terminal::ShellObservation {
956 crate::terminal::ShellObservation {
957 terminal_id: workflow.terminal_id,
958 mark,
959 observation: TerminalObservation {
960 integrated_shell: true,
961 observed_at: workflow.started_at + 1,
962 byte_offset: 100,
963 boundary_sizes: Some(Vec::new()),
964 },
965 }
966 }
967
968 #[test]
969 fn delayed_command_start_is_persisted_and_stopped_after_workflow_close() {
970 check_delayed_command_close(false);
971 }
972
973 #[test]
974 fn delayed_command_start_is_persisted_and_stopped_after_folder_close() {
975 check_delayed_command_close(true);
976 }
977
978 fn check_delayed_command_close(close_folder: bool) {
979 use crate::terminal::ShellMark;
980 let folder = tempfile::tempdir().unwrap();
981 let mut application = Application::with_event_capacity(4096).unwrap();
982 application.terminals.set_test_shell("/bin/sh".into());
983 let workflow = create(&application, folder.path());
984 application
985 .lock_inner()
986 .unwrap()
987 .record_shell_observations(vec![command_observation(&workflow, ShellMark::Prompt)]);
988 reject_trace_writes(&application);
989 application
990 .lock_inner()
991 .unwrap()
992 .record_shell_observations(vec![command_observation(
993 &workflow,
994 ShellMark::CommandStart("delayed".into()),
995 )]);
996 application
997 .handle_command(
998 RequestId(3),
999 if close_folder {
1000 Command::CloseFolder
1001 } else {
1002 Command::CloseWorkflow {
1003 workflow_id: workflow.workflow_id,
1004 }
1005 },
1006 )
1007 .unwrap();
1008 assert_eq!(application.snapshot().unwrap().workflows.workflows, []);
1009 allow_trace_writes(&application);
1010 let page = application
1011 .workflow_trace(workflow.workflow_id, None, 10)
1012 .unwrap();
1013 assert_eq!(page.spans.len(), 1);
1014 assert_eq!(page.spans[0].title, "delayed");
1015 assert_eq!(page.spans[0].status, TraceSpanStatus::Stopped);
1016 assert_eq!(
1017 application
1018 .trace_events(page.spans[0].span_id, None, 10)
1019 .unwrap()
1020 .events
1021 .len(),
1022 2
1023 );
1024 assert!(
1025 application
1026 .lock_inner()
1027 .unwrap()
1028 .pending_shell_workflows
1029 .is_empty()
1030 );
1031 }
1032
1033 #[test]
1034 fn failed_command_ending_survives_folder_close_and_storage_recovery() {
1035 use crate::terminal::ShellMark;
1036 let folder = tempfile::tempdir().unwrap();
1037 let mut application = Application::with_event_capacity(4096).unwrap();
1038 application.terminals.set_test_shell("/bin/sh".into());
1039 let workflow = create(&application, folder.path());
1040 application
1041 .lock_inner()
1042 .unwrap()
1043 .record_shell_observations(vec![
1044 command_observation(&workflow, ShellMark::Prompt),
1045 command_observation(
1046 &workflow,
1047 ShellMark::CommandStart("completed before close".into()),
1048 ),
1049 ]);
1050 reject_trace_writes(&application);
1051 application
1052 .lock_inner()
1053 .unwrap()
1054 .record_shell_observations(vec![command_observation(
1055 &workflow,
1056 ShellMark::CommandEnd(0),
1057 )]);
1058 application
1059 .handle_command(RequestId(3), Command::CloseFolder)
1060 .unwrap();
1061 allow_trace_writes(&application);
1062 let page = application
1063 .workflow_trace(workflow.workflow_id, None, 10)
1064 .unwrap();
1065 assert_eq!(page.spans.len(), 1);
1066 assert_eq!(page.spans[0].title, "completed before close");
1067 assert_eq!(page.spans[0].status, TraceSpanStatus::Completed);
1068 assert_eq!(
1069 application
1070 .trace_events(page.spans[0].span_id, None, 10)
1071 .unwrap()
1072 .events
1073 .len(),
1074 2
1075 );
1076 }
1077
1078 #[test]
1079 fn failed_integration_loss_preserves_its_fallback_when_the_folder_closes() {
1080 use crate::terminal::ShellMark;
1081 let folder = tempfile::tempdir().unwrap();
1082 let mut application = Application::with_event_capacity(4096).unwrap();
1083 application.terminals.set_test_shell("/bin/sh".into());
1084 let workflow = create(&application, folder.path());
1085 application
1086 .lock_inner()
1087 .unwrap()
1088 .record_shell_observations(vec![command_observation(
1089 &workflow,
1090 ShellMark::CommandStart("running".into()),
1091 )]);
1092 reject_trace_writes(&application);
1093 application
1094 .lock_inner()
1095 .unwrap()
1096 .record_shell_observations(vec![command_observation(&workflow, ShellMark::Lost)]);
1097 application
1098 .handle_command(RequestId(3), Command::CloseFolder)
1099 .unwrap();
1100 allow_trace_writes(&application);
1101 let page = application
1102 .workflow_trace(workflow.workflow_id, None, 10)
1103 .unwrap();
1104 assert_eq!(page.spans.len(), 2);
1105 assert_eq!(page.spans[0].title, "Shell");
1106 assert_eq!(page.spans[1].title, "running");
1107 assert!(
1108 page.spans
1109 .iter()
1110 .all(|span| span.status == TraceSpanStatus::Stopped)
1111 );
1112 }
1113
1114 #[test]
1115 fn integrated_process_exit_defers_before_its_first_mark_and_preserves_fallback_status() {
1116 use crate::terminal::{ShellMark, TerminalStream, TranscriptRecorder};
1117 let folder = tempfile::tempdir().unwrap();
1118 let mut application = Application::with_event_capacity(4096).unwrap();
1119 application.terminals.set_test_shell("/bin/sh".into());
1120 let workflow = create(&application, folder.path());
1121 let observation = command_observation(&workflow, ShellMark::Prompt).observation;
1122 let finished = TerminalStream::new(
1123 4096,
1124 4,
1125 std::sync::Arc::new(TranscriptRecorder::temporary().unwrap()),
1126 )
1127 .unwrap();
1128 let mut inner = application.lock_inner().unwrap();
1129 assert!(inner.defer_shell_process_ending(
1130 workflow.terminal_id,
1131 &observation,
1132 TraceSpanStatus::Failed,
1133 TraceEventKind::ProcessFailed,
1134 "exit 7"
1135 ));
1136 inner.finish_shell_process_endings(&application.terminal_output);
1137 assert!(
1138 inner
1139 .shell_process_endings
1140 .contains_key(&workflow.terminal_id)
1141 );
1142 inner.record_shell_observations(vec![
1143 command_observation(&workflow, ShellMark::Prompt),
1144 command_observation(&workflow, ShellMark::CommandStart("exit 7".into())),
1145 command_observation(&workflow, ShellMark::CommandEnd(7)),
1146 ]);
1147 inner.finish_shell_process_endings(&finished);
1148 drop(inner);
1149 let page = application
1150 .workflow_trace(workflow.workflow_id, None, 10)
1151 .unwrap();
1152 assert_eq!(page.spans[0].title, "exit 7");
1153 assert_eq!(page.spans[0].status, TraceSpanStatus::Failed);
1154
1155 let second_folder = tempfile::tempdir().unwrap();
1156 let mut fallback = Application::with_event_capacity(4096).unwrap();
1157 fallback.terminals.set_test_shell("/bin/sh".into());
1158 let second = create(&fallback, second_folder.path());
1159 let mut inner = fallback.lock_inner().unwrap();
1160 inner.defer_shell_process_ending(
1161 second.terminal_id,
1162 &observation,
1163 TraceSpanStatus::Exited,
1164 TraceEventKind::ProcessExited,
1165 "exit 0",
1166 );
1167 inner.finish_shell_process_endings(&finished);
1168 drop(inner);
1169 let page = fallback
1170 .workflow_trace(second.workflow_id, None, 10)
1171 .unwrap();
1172 assert_eq!(page.spans[0].title, "Shell");
1173 assert_eq!(page.spans[0].status, TraceSpanStatus::Exited);
1174 }
1175
1176 #[test]
1177 fn command_boundaries_retry_in_order_after_a_store_failure() {
1178 use crate::terminal::{ShellMark, ShellObservation};
1179 let folder = tempfile::tempdir().unwrap();
1180 let application = Application::with_event_capacity(4096).unwrap();
1181 let workflow = create(&application, folder.path());
1182 reject_trace_writes(&application);
1183 let marks = [
1184 ShellMark::Prompt,
1185 ShellMark::CommandStart("first command".into()),
1186 ShellMark::CommandEnd(1),
1187 ShellMark::CommandStart("second command".into()),
1188 ShellMark::CommandEnd(0),
1189 ]
1190 .into_iter()
1191 .enumerate()
1192 .map(|(i, mark)| ShellObservation {
1193 terminal_id: workflow.terminal_id,
1194 mark,
1195 observation: TerminalObservation {
1196 integrated_shell: false,
1197 observed_at: workflow.started_at + i as u64,
1198 byte_offset: i as u64 + 100,
1199 boundary_sizes: Some(Vec::new()),
1200 },
1201 })
1202 .collect();
1203 application
1204 .lock_inner()
1205 .unwrap()
1206 .record_shell_observations(marks);
1207 assert_eq!(
1208 application.lock_inner().unwrap().pending_shell_marks.len(),
1209 4
1210 );
1211 allow_trace_writes(&application);
1212 let page = application
1213 .workflow_trace(workflow.workflow_id, None, 10)
1214 .unwrap();
1215 assert_eq!(page.spans.len(), 2);
1216 assert_eq!(page.spans[0].title, "second command");
1217 assert_eq!(page.spans[0].status, TraceSpanStatus::Completed);
1218 assert_eq!(page.spans[1].title, "first command");
1219 assert_eq!(page.spans[1].status, TraceSpanStatus::Failed);
1220 for span in page.spans {
1221 let events = application
1222 .trace_events(span.span_id, None, 10)
1223 .unwrap()
1224 .events;
1225 assert_eq!(events.len(), 2);
1226 assert_eq!(events[0].timestamp, span.started_at);
1227 assert_eq!(events[1].timestamp, span.ended_at.unwrap());
1228 }
1229 }
1230
1231 #[test]
1232 fn shell_marks_do_not_create_command_spans_in_agent_terminals() {
1233 let folder = tempfile::tempdir().unwrap();
1234 let application = Application::with_event_capacity(4096).unwrap();
1235 let workflow = create_agents(&application, folder.path()).unwrap();
1236 application
1237 .lock_inner()
1238 .unwrap()
1239 .record_shell_observations(vec![crate::terminal::ShellObservation {
1240 terminal_id: workflow.agents[0].terminal_id,
1241 mark: crate::terminal::ShellMark::CommandStart("not a terminal workflow".into()),
1242 observation: TerminalObservation {
1243 integrated_shell: false,
1244 observed_at: workflow.started_at,
1245 byte_offset: 42,
1246 boundary_sizes: Some(Vec::new()),
1247 },
1248 }]);
1249 let page = application
1250 .workflow_trace(workflow.workflow_id, None, 10)
1251 .unwrap();
1252 assert_eq!(page.spans.len(), 2);
1253 assert!(page.spans.iter().all(|span| span.title == "Shell"));
1254 }
1255
1256 fn create_agents(
1257 application: &Application,
1258 folder: &std::path::Path,
1259 ) -> Result<Workflow, ApplicationError> {
1260 application.handle_command(
1261 RequestId(1),
1262 Command::OpenFolder {
1263 path: folder.to_owned(),
1264 },
1265 )?;
1266 application.handle_command(
1267 RequestId(2),
1268 Command::CreateWorkflow {
1269 folder: folder.to_owned(),
1270 session_id: None,
1271 kind: WorkflowKind::Agents,
1272 roles: vec!["Worker".to_owned(), "Worker".to_owned()],
1273 size: TerminalSize {
1274 rows: 24,
1275 columns: 80,
1276 pixel_width: 800,
1277 pixel_height: 480,
1278 },
1279 },
1280 )?;
1281 Ok(application.snapshot()?.workflows.workflows[0].clone())
1282 }
1283
1284 #[test]
1285 fn agents_with_the_same_role_have_distinct_lanes_and_exit_independently() {
1286 let folder = tempfile::tempdir().unwrap();
1287 let application = Application::with_event_capacity(4096).unwrap();
1288 let workflow = create_agents(&application, folder.path()).unwrap();
1289 let page = application
1290 .workflow_trace(workflow.workflow_id, None, 10)
1291 .unwrap();
1292 assert_eq!(page.summary.agent_count, 2);
1293 assert_eq!(page.lanes.len(), 2);
1294 assert!(
1295 page.lanes
1296 .iter()
1297 .all(|lane| lane.is_agent && lane.role.as_deref() == Some("Worker"))
1298 );
1299 assert_ne!(page.spans[0].lane_id, page.spans[1].lane_id);
1300 assert!(
1301 page.spans
1302 .iter()
1303 .all(|span| span.is_live && span.started_at == workflow.started_at)
1304 );
1305 let first = workflow.agents[0].terminal_id;
1306 let second = workflow.agents[1].terminal_id;
1307 application.lock_inner().unwrap().record_terminal_exit_at(
1308 first,
1309 Ok(crate::TerminalExit {
1310 exit_code: 3,
1311 signal: None,
1312 }),
1313 TerminalObservation {
1314 integrated_shell: false,
1315 observed_at: workflow.started_at + 10,
1316 byte_offset: 42,
1317 boundary_sizes: Some(Vec::new()),
1318 },
1319 );
1320 let workflows = application.snapshot().unwrap().workflows.workflows;
1321 assert_eq!(workflows[0].status, WorkflowStatus::Running);
1322 let page = application
1323 .workflow_trace(workflow.workflow_id, None, 10)
1324 .unwrap();
1325 assert_eq!(page.spans.iter().filter(|span| span.is_live).count(), 1);
1326 let span = page
1327 .spans
1328 .iter()
1329 .find(|span| span.terminal_id == Some(first))
1330 .unwrap();
1331 assert_eq!(span.status, TraceSpanStatus::Exited);
1332 let events = application
1333 .trace_events(span.span_id, None, 10)
1334 .unwrap()
1335 .events;
1336 assert_eq!(events[1].anchor.as_ref().unwrap().byte_offset, 42);
1337 application.lock_inner().unwrap().record_terminal_exit_at(
1338 second,
1339 Ok(crate::TerminalExit {
1340 exit_code: 0,
1341 signal: None,
1342 }),
1343 TerminalObservation {
1344 integrated_shell: false,
1345 observed_at: workflow.started_at + 20,
1346 byte_offset: 70,
1347 boundary_sizes: Some(Vec::new()),
1348 },
1349 );
1350 let snapshot = application.snapshot().unwrap();
1351 assert_eq!(
1352 snapshot.workflows.workflows[0].status,
1353 WorkflowStatus::Exited
1354 );
1355 assert_eq!(
1356 snapshot.workflows.workflows[0].ended_at,
1357 Some(workflow.started_at + 20)
1358 );
1359 application
1360 .handle_command(
1361 RequestId(3),
1362 Command::CloseWorkflow {
1363 workflow_id: workflow.workflow_id,
1364 },
1365 )
1366 .unwrap();
1367 let page = application
1368 .workflow_trace(workflow.workflow_id, None, 10)
1369 .unwrap();
1370 assert_eq!(page.spans.len(), 2);
1371 assert!(
1372 page.spans
1373 .iter()
1374 .all(|span| !span.is_live && span.status == TraceSpanStatus::Exited)
1375 );
1376 for span in page.spans {
1377 assert_eq!(
1378 application
1379 .trace_events(span.span_id, None, 10)
1380 .unwrap()
1381 .events
1382 .len(),
1383 2
1384 );
1385 }
1386 }
1387
1388 #[test]
1389 fn failed_multi_agent_close_rolls_back_every_ending_and_then_closes_all_processes() {
1390 let folder = tempfile::tempdir().unwrap();
1391 let application = Application::with_event_capacity(4096).unwrap();
1392 let workflow = create_agents(&application, folder.path()).unwrap();
1393 application.lock_inner().unwrap().folders.store().execute_test_sql(
1394 "CREATE TRIGGER reject_trace BEFORE INSERT ON trace_events WHEN NEW.kind = 'processStopped' AND NEW.span_id = 2 BEGIN SELECT RAISE(FAIL, 'test second ending failure'); END;"
1395 );
1396 assert!(
1397 application
1398 .handle_command(
1399 RequestId(3),
1400 Command::CloseWorkflow {
1401 workflow_id: workflow.workflow_id
1402 }
1403 )
1404 .is_err()
1405 );
1406 let page = application
1407 .workflow_trace(workflow.workflow_id, None, 10)
1408 .unwrap();
1409 assert!(
1410 page.spans
1411 .iter()
1412 .all(|span| span.is_live && span.status == TraceSpanStatus::Running)
1413 );
1414 for span in &page.spans {
1415 assert_eq!(
1416 application
1417 .trace_events(span.span_id, None, 10)
1418 .unwrap()
1419 .events
1420 .len(),
1421 1
1422 );
1423 }
1424 assert_eq!(
1425 application
1426 .lock_inner()
1427 .unwrap()
1428 .folders
1429 .read_store()
1430 .workflows(folder.path())
1431 .unwrap()
1432 .len(),
1433 1
1434 );
1435 allow_trace_writes(&application);
1436 application
1437 .handle_command(
1438 RequestId(4),
1439 Command::CloseWorkflow {
1440 workflow_id: workflow.workflow_id,
1441 },
1442 )
1443 .unwrap();
1444 let page = application
1445 .workflow_trace(workflow.workflow_id, None, 10)
1446 .unwrap();
1447 assert!(
1448 page.spans
1449 .iter()
1450 .all(|span| !span.is_live && span.status == TraceSpanStatus::Stopped)
1451 );
1452 for span in &page.spans {
1453 assert_eq!(
1454 application
1455 .trace_events(span.span_id, None, 10)
1456 .unwrap()
1457 .events
1458 .len(),
1459 2
1460 );
1461 }
1462 assert_eq!(application.snapshot().unwrap().terminals, []);
1463 for agent in workflow.agents {
1464 assert!(
1465 application
1466 .write_terminal_input(agent.terminal_id, b"echo alive\n")
1467 .is_err()
1468 );
1469 }
1470 }
1471
1472 #[test]
1473 fn failed_multi_agent_start_rolls_back_all_spans_and_stops_every_process() {
1474 let folder = tempfile::tempdir().unwrap();
1475 let application = Application::with_event_capacity(4096).unwrap();
1476 application.lock_inner().unwrap().folders.store().execute_test_sql(
1477 "CREATE TRIGGER reject_trace BEFORE INSERT ON trace_events WHEN NEW.kind = 'processStarted' AND NEW.span_id = 2 BEGIN SELECT RAISE(FAIL, 'test second start failure'); END;"
1478 );
1479 assert!(create_agents(&application, folder.path()).is_err());
1480 let snapshot = application.snapshot().unwrap();
1481 assert_eq!(snapshot.terminals, []);
1482 assert_eq!(snapshot.workflows.workflows, []);
1483 let page = application.workflow_trace(WorkflowId(1), None, 10).unwrap();
1484 assert_eq!(page.spans, []);
1485 assert_eq!(page.lanes, []);
1486 for id in [1, 2] {
1487 assert!(
1488 application
1489 .terminals
1490 .size(TerminalId::from_value(id))
1491 .is_none()
1492 );
1493 }
1494 }
1495
1496 #[test]
1497 fn restoring_agents_reuses_lane_identity_and_cleans_up_all_failed_trace_starts() {
1498 let folder = tempfile::tempdir().unwrap();
1499 let application = Application::with_event_capacity(4096).unwrap();
1500 let workflow = create_agents(&application, folder.path()).unwrap();
1501 let initial = application
1502 .workflow_trace(workflow.workflow_id, None, 10)
1503 .unwrap();
1504 application
1505 .handle_command(RequestId(3), Command::CloseFolder)
1506 .unwrap();
1507 application.lock_inner().unwrap().folders.store().execute_test_sql(
1508 "CREATE TRIGGER reject_trace BEFORE INSERT ON trace_events WHEN NEW.kind = 'processStarted' AND NEW.span_id = 4 BEGIN SELECT RAISE(FAIL, 'test second restored start failure'); END;"
1509 );
1510 application
1511 .handle_command(
1512 RequestId(4),
1513 Command::OpenFolder {
1514 path: folder.path().to_owned(),
1515 },
1516 )
1517 .unwrap();
1518 let snapshot = application.snapshot().unwrap();
1519 assert_eq!(snapshot.terminals, []);
1520 let failed = &snapshot.workflows.workflows[0];
1521 assert_eq!(failed.status, WorkflowStatus::Failed);
1522 assert!(
1523 failed
1524 .agents
1525 .iter()
1526 .all(|agent| agent.terminal_id.value() == 0)
1527 );
1528 let page = application
1529 .workflow_trace(workflow.workflow_id, None, 10)
1530 .unwrap();
1531 assert_eq!(page.spans.len(), 2);
1532 assert_eq!(page.lanes, initial.lanes);
1533 allow_trace_writes(&application);
1534 application
1535 .handle_command(RequestId(5), Command::CloseFolder)
1536 .unwrap();
1537 application
1538 .handle_command(
1539 RequestId(6),
1540 Command::OpenFolder {
1541 path: folder.path().to_owned(),
1542 },
1543 )
1544 .unwrap();
1545 let page = application
1546 .workflow_trace(workflow.workflow_id, None, 10)
1547 .unwrap();
1548 assert_eq!(page.lanes, initial.lanes);
1549 assert_eq!(page.spans.len(), 4);
1550 assert_eq!(page.spans.iter().filter(|span| span.is_live).count(), 2);
1551 }
1552
1553 #[test]
1554 fn trace_write_failure_never_prevents_folder_process_cleanup() {
1555 let folder = tempfile::tempdir().unwrap();
1556 let application = Application::with_event_capacity(4096).unwrap();
1557 let workflow = create(&application, folder.path());
1558 reject_trace_writes(&application);
1559 application
1560 .handle_command(RequestId(3), Command::CloseFolder)
1561 .unwrap();
1562 let snapshot = application.snapshot().unwrap();
1563 assert!(snapshot.folders.open_folder.is_none());
1564 assert_eq!(snapshot.workflows.workflows, []);
1565 assert_eq!(snapshot.terminals, []);
1566 assert!(
1567 application
1568 .write_terminal_input(workflow.terminal_id, b"echo alive\n")
1569 .is_err()
1570 );
1571 allow_trace_writes(&application);
1572 application
1573 .handle_command(RequestId(4), Command::Ping)
1574 .unwrap();
1575 let page = application
1576 .workflow_trace(workflow.workflow_id, None, 10)
1577 .unwrap();
1578 assert_eq!(page.spans[0].status, TraceSpanStatus::Stopped);
1579 assert_eq!(
1580 application
1581 .trace_events(page.spans[0].span_id, None, 10)
1582 .unwrap()
1583 .events
1584 .len(),
1585 2
1586 );
1587 }
1588
1589 #[test]
1590 fn shutdown_retries_endings_even_after_folder_cleanup_removes_the_process() {
1591 let data = tempfile::tempdir().unwrap();
1592 let folder = tempfile::tempdir().unwrap();
1593 let mut application =
1594 Application::with_config(data.path(), crate::config::Config::default()).unwrap();
1595 application.terminals.set_test_shell("/bin/sh".into());
1596 let workflow = create(&application, folder.path());
1597 reject_trace_writes(&application);
1598 application
1599 .handle_command(RequestId(3), Command::CloseFolder)
1600 .unwrap();
1601 assert_eq!(
1602 application
1603 .lock_inner()
1604 .unwrap()
1605 .pending_trace_endings
1606 .len(),
1607 1
1608 );
1609 allow_trace_writes(&application);
1610 drop(application);
1611 let application =
1612 Application::with_config(data.path(), crate::config::Config::default()).unwrap();
1613 let page = application
1614 .workflow_trace(workflow.workflow_id, None, 10)
1615 .unwrap();
1616 assert_eq!(page.spans[0].status, TraceSpanStatus::Stopped);
1617 let events = application
1618 .trace_events(page.spans[0].span_id, None, 10)
1619 .unwrap()
1620 .events;
1621 assert_eq!(events.len(), 2);
1622 assert!(events[1].message.contains("folder was closed"));
1623 }
1624
1625 #[test]
1626 fn failed_restoration_stops_the_process_and_publishes_a_visible_failed_tab() {
1627 let folder = tempfile::tempdir().unwrap();
1628 let application = Application::with_event_capacity(4096).unwrap();
1629 let workflow = create(&application, folder.path());
1630 application
1631 .handle_command(RequestId(3), Command::CloseFolder)
1632 .unwrap();
1633 reject_trace_writes(&application);
1634 application
1635 .handle_command(
1636 RequestId(4),
1637 Command::OpenFolder {
1638 path: folder.path().to_owned(),
1639 },
1640 )
1641 .unwrap();
1642 let snapshot = application.snapshot().unwrap();
1643 assert_eq!(snapshot.workflows.workflows.len(), 1);
1644 assert_eq!(
1645 snapshot.workflows.workflows[0].status,
1646 WorkflowStatus::Failed
1647 );
1648 assert_eq!(snapshot.workflows.workflows[0].terminal_id.value(), 0);
1649 assert_eq!(snapshot.terminals, []);
1650 allow_trace_writes(&application);
1651 application
1652 .handle_command(RequestId(5), Command::CloseFolder)
1653 .unwrap();
1654 application
1655 .handle_command(
1656 RequestId(6),
1657 Command::OpenFolder {
1658 path: folder.path().to_owned(),
1659 },
1660 )
1661 .unwrap();
1662 let snapshot = application.snapshot().unwrap();
1663 assert_eq!(snapshot.terminals.len(), 1);
1664 assert_eq!(
1665 snapshot.workflows.workflows[0].workflow_id,
1666 workflow.workflow_id
1667 );
1668 assert_eq!(
1669 snapshot.workflows.workflows[0].status,
1670 WorkflowStatus::Running
1671 );
1672 assert_eq!(snapshot.traces[0].span_count, 2);
1673 }
1674
1675 #[test]
1676 fn a_failed_exit_write_is_retried_with_its_original_observation() {
1677 let folder = tempfile::tempdir().unwrap();
1678 let application = Application::with_event_capacity(4096).unwrap();
1679 let workflow = create(&application, folder.path());
1680 reject_trace_writes(&application);
1681 let at = workflow.started_at + 25;
1682 application.lock_inner().unwrap().record_terminal_exit_at(
1683 workflow.terminal_id,
1684 Ok(crate::TerminalExit {
1685 exit_code: 7,
1686 signal: None,
1687 }),
1688 TerminalObservation {
1689 integrated_shell: false,
1690 observed_at: at,
1691 byte_offset: 42,
1692 boundary_sizes: Some(Vec::new()),
1693 },
1694 );
1695 assert_eq!(
1696 application
1697 .lock_inner()
1698 .unwrap()
1699 .pending_trace_endings
1700 .len(),
1701 1
1702 );
1703 allow_trace_writes(&application);
1704 application
1705 .handle_command(
1706 RequestId(3),
1707 Command::CloseWorkflow {
1708 workflow_id: workflow.workflow_id,
1709 },
1710 )
1711 .unwrap();
1712 let page = application
1713 .workflow_trace(workflow.workflow_id, None, 10)
1714 .unwrap();
1715 let events = application
1716 .trace_events(page.spans[0].span_id, None, 10)
1717 .unwrap()
1718 .events;
1719 assert_eq!(events.len(), 2);
1720 assert_eq!(events[1].kind, TraceEventKind::ProcessExited);
1721 assert!(events[1].message.contains("code 7"));
1722 assert_eq!(events[1].timestamp, at);
1723 assert_eq!(events[1].anchor.as_ref().unwrap().byte_offset, 42);
1724 }
1725
1726 #[test]
1727 fn failed_workflow_close_rolls_back_its_trace_ending() {
1728 let folder = tempfile::tempdir().unwrap();
1729 let application = Application::with_event_capacity(4096).unwrap();
1730 let workflow = create(&application, folder.path());
1731 application.lock_inner().unwrap().folders.store().execute_test_sql(
1732 "CREATE TRIGGER reject_close BEFORE UPDATE OF closed_at ON workflows BEGIN SELECT RAISE(FAIL, 'test close failure'); END;"
1733 );
1734 assert!(
1735 application
1736 .handle_command(
1737 RequestId(3),
1738 Command::CloseWorkflow {
1739 workflow_id: workflow.workflow_id
1740 }
1741 )
1742 .is_err()
1743 );
1744 let page = application
1745 .workflow_trace(workflow.workflow_id, None, 10)
1746 .unwrap();
1747 assert!(page.spans[0].is_live);
1748 assert_eq!(page.spans[0].status, TraceSpanStatus::Running);
1749 assert_eq!(
1750 application
1751 .trace_events(page.spans[0].span_id, None, 10)
1752 .unwrap()
1753 .events
1754 .len(),
1755 1
1756 );
1757 application
1758 .write_terminal_input(workflow.terminal_id, b"echo alive\n")
1759 .unwrap();
1760 application
1761 .lock_inner()
1762 .unwrap()
1763 .folders
1764 .store()
1765 .execute_test_sql("DROP TRIGGER reject_close");
1766 application
1767 .handle_command(
1768 RequestId(4),
1769 Command::CloseWorkflow {
1770 workflow_id: workflow.workflow_id,
1771 },
1772 )
1773 .unwrap();
1774 assert_eq!(
1775 application
1776 .workflow_trace(workflow.workflow_id, None, 10)
1777 .unwrap()
1778 .spans[0]
1779 .status,
1780 TraceSpanStatus::Stopped
1781 );
1782 }
1783
1784 #[test]
1785 fn failed_trace_ending_rolls_back_the_workflow_closed_marker() {
1786 let folder = tempfile::tempdir().unwrap();
1787 let application = Application::with_event_capacity(4096).unwrap();
1788 let workflow = create(&application, folder.path());
1789 reject_trace_writes(&application);
1790 assert!(
1791 application
1792 .handle_command(
1793 RequestId(3),
1794 Command::CloseWorkflow {
1795 workflow_id: workflow.workflow_id,
1796 }
1797 )
1798 .is_err()
1799 );
1800 let stored = application
1801 .lock_inner()
1802 .unwrap()
1803 .folders
1804 .read_store()
1805 .workflows(folder.path())
1806 .unwrap();
1807 assert_eq!(stored.len(), 1);
1808 assert_eq!(stored[0].workflow_id, workflow.workflow_id);
1809 let page = application
1810 .workflow_trace(workflow.workflow_id, None, 10)
1811 .unwrap();
1812 assert!(page.spans[0].is_live);
1813 assert_eq!(page.spans[0].status, TraceSpanStatus::Running);
1814 assert_eq!(
1815 application
1816 .trace_events(page.spans[0].span_id, None, 10)
1817 .unwrap()
1818 .events
1819 .len(),
1820 1
1821 );
1822 assert_eq!(application.snapshot().unwrap().workflows.workflows.len(), 1);
1823 allow_trace_writes(&application);
1824 application
1825 .handle_command(
1826 RequestId(4),
1827 Command::CloseWorkflow {
1828 workflow_id: workflow.workflow_id,
1829 },
1830 )
1831 .unwrap();
1832 assert!(
1833 application
1834 .lock_inner()
1835 .unwrap()
1836 .folders
1837 .read_store()
1838 .workflows(folder.path())
1839 .unwrap()
1840 .is_empty()
1841 );
1842 assert_eq!(
1843 application
1844 .workflow_trace(workflow.workflow_id, None, 10)
1845 .unwrap()
1846 .spans[0]
1847 .status,
1848 TraceSpanStatus::Stopped
1849 );
1850 }
1851
1852 #[test]
1853 fn durable_trace_anchors_read_the_original_binary_transcript_after_relaunch() {
1854 let data = tempfile::tempdir().unwrap();
1855 let folder = tempfile::tempdir().unwrap();
1856 let (workflow_id, span_id, anchor, expected, events) = {
1857 let mut application =
1858 Application::with_config(data.path(), crate::config::Config::default()).unwrap();
1859 application.terminals.set_test_shell("/bin/sh".into());
1860 let workflow = create(&application, folder.path());
1861 application
1862 .write_terminal_input(
1863 workflow.terminal_id,
1864 b"printf '\\377\\000\\033[Htrace bytes\\n'\n",
1865 )
1866 .unwrap();
1867 let deadline = Instant::now() + Duration::from_secs(3);
1868 loop {
1869 while application.next_terminal_chunk().unwrap().is_some() {}
1870 let crate::TranscriptRead::Output(page) = application
1871 .read_terminal_transcript(
1872 workflow.terminal_id,
1873 0,
1874 crate::MAX_TRANSCRIPT_READ_BYTES,
1875 )
1876 .unwrap()
1877 else {
1878 panic!("new transcript must be retained");
1879 };
1880 if page
1881 .bytes
1882 .windows(5)
1883 .any(|bytes| bytes == [0xff, 0, 0x1b, b'[', b'H'])
1884 {
1885 break;
1886 }
1887 assert!(
1888 Instant::now() < deadline,
1889 "shell did not produce binary output"
1890 );
1891 thread::sleep(Duration::from_millis(5));
1892 }
1893 application
1894 .handle_command(
1895 RequestId(3),
1896 Command::CloseWorkflow {
1897 workflow_id: workflow.workflow_id,
1898 },
1899 )
1900 .unwrap();
1901 let trace = application
1902 .workflow_trace(workflow.workflow_id, None, 10)
1903 .unwrap();
1904 let span_id = trace.spans[0].span_id;
1905 let events = application.trace_events(span_id, None, 10).unwrap().events;
1906 assert_eq!(events.len(), 2);
1907 let anchor = events[1].anchor.as_ref().unwrap().clone();
1908 assert_eq!(anchor.terminal_id, workflow.terminal_id);
1909 assert!(anchor.byte_offset > 0);
1910 let expected = application
1911 .read_terminal_transcript(anchor.terminal_id, 0, crate::MAX_TRANSCRIPT_READ_BYTES)
1912 .unwrap();
1913 let crate::TranscriptRead::Output(page) = &expected else {
1914 panic!("closed transcript must be retained");
1915 };
1916 assert!(anchor.byte_offset <= page.end_offset);
1917 assert!(
1918 page.bytes
1919 .windows(5)
1920 .any(|bytes| bytes == [0xff, 0, 0x1b, b'[', b'H'])
1921 );
1922 (workflow.workflow_id, span_id, anchor, expected, events)
1923 };
1924 let mut application =
1925 Application::with_config(data.path(), crate::config::Config::default()).unwrap();
1926 application.terminals.set_test_shell("/bin/sh".into());
1927 let fresh = create(&application, folder.path());
1928 assert!(fresh.terminal_id.value() > anchor.terminal_id.value());
1929 assert_eq!(
1930 application
1931 .read_terminal_transcript(anchor.terminal_id, 0, crate::MAX_TRANSCRIPT_READ_BYTES)
1932 .unwrap(),
1933 expected
1934 );
1935 let crate::TranscriptRead::Output(page) = application
1936 .read_terminal_transcript(anchor.terminal_id, anchor.byte_offset, 1)
1937 .unwrap()
1938 else {
1939 panic!("trace anchor must remain readable");
1940 };
1941 assert_eq!(page.offset, anchor.byte_offset);
1942 assert_eq!(
1943 application.trace_events(span_id, None, 10).unwrap().events,
1944 events
1945 );
1946 let historical = application.workflow_trace(workflow_id, None, 10).unwrap();
1947 assert_eq!(historical.spans[0].terminal_id, Some(anchor.terminal_id));
1948 assert!(!historical.spans[0].is_live);
1949 }
1950
1951 #[test]
1952 fn observed_output_anchors_survive_queue_drain_and_text_creates_no_events() {
1953 let folder = tempfile::tempdir().unwrap();
1954 let application = Application::with_event_capacity(4096).unwrap();
1955 let workflow = create(&application, folder.path());
1956 application
1957 .write_terminal_input(
1958 workflow.terminal_id,
1959 b"printf 'processExited processStarted\\n'\n",
1960 )
1961 .unwrap();
1962 let deadline = Instant::now() + Duration::from_secs(3);
1963 let mut output = Vec::new();
1964 while Instant::now() < deadline {
1965 while let Some(chunk) = application.next_terminal_chunk().unwrap() {
1966 output.extend(chunk.bytes);
1967 }
1968 if String::from_utf8_lossy(&output).contains("processExited processStarted") {
1969 break;
1970 }
1971 thread::sleep(Duration::from_millis(5));
1972 }
1973 assert!(String::from_utf8_lossy(&output).contains("processExited processStarted"));
1974 let page = application
1975 .workflow_trace(workflow.workflow_id, None, 10)
1976 .unwrap();
1977 let span_id = page.spans[0].span_id;
1978 assert!(page.spans[0].is_live);
1979 assert_eq!(
1980 application
1981 .trace_events(span_id, None, 10)
1982 .unwrap()
1983 .events
1984 .len(),
1985 1
1986 );
1987 let observation = application.terminals.observe(workflow.terminal_id).unwrap();
1988 application
1989 .handle_command(
1990 RequestId(3),
1991 Command::CloseWorkflow {
1992 workflow_id: workflow.workflow_id,
1993 },
1994 )
1995 .unwrap();
1996 let page = application
1997 .workflow_trace(workflow.workflow_id, None, 10)
1998 .unwrap();
1999 assert_eq!(page.spans[0].status, TraceSpanStatus::Stopped);
2000 assert!(!page.spans[0].is_live);
2001 let events = application.trace_events(span_id, None, 10).unwrap().events;
2002 assert_eq!(events.len(), 2);
2003 assert!(events[1].anchor.as_ref().unwrap().byte_offset >= observation.byte_offset);
2004 assert!(events[1].anchor.as_ref().unwrap().byte_offset > 0);
2005 }
2006
2007 #[test]
2008 fn natural_exit_records_an_exit_without_completing_work_and_close_is_idempotent() {
2009 let folder = tempfile::tempdir().unwrap();
2010 let application = Application::with_event_capacity(4096).unwrap();
2011 let workflow = create(&application, folder.path());
2012 application
2013 .write_terminal_input(workflow.terminal_id, b"exit 7\n")
2014 .unwrap();
2015 let deadline = Instant::now() + Duration::from_secs(3);
2016 while application.snapshot().unwrap().workflows.workflows[0].status
2017 == WorkflowStatus::Running
2018 {
2019 assert!(Instant::now() < deadline, "process exit should be observed");
2020 while application.next_terminal_chunk().unwrap().is_some() {}
2021 thread::sleep(Duration::from_millis(5));
2022 }
2023 let page = application
2024 .workflow_trace(workflow.workflow_id, None, 10)
2025 .unwrap();
2026 let span_id = page.spans[0].span_id;
2027 assert_eq!(page.spans[0].status, TraceSpanStatus::Exited);
2028 application
2029 .handle_command(
2030 RequestId(3),
2031 Command::CloseWorkflow {
2032 workflow_id: workflow.workflow_id,
2033 },
2034 )
2035 .unwrap();
2036 let events = application.trace_events(span_id, None, 10).unwrap().events;
2037 assert_eq!(events.len(), 2);
2038 assert_eq!(events[1].kind, TraceEventKind::ProcessExited);
2039 assert!(events[1].message.contains("code 7"));
2040 }
2041
2042 #[test]
2043 fn folder_reopen_keeps_history_and_opens_a_distinct_span() {
2044 let folder = tempfile::tempdir().unwrap();
2045 let application = Application::with_event_capacity(4096).unwrap();
2046 let workflow = create(&application, folder.path());
2047 let original = application
2048 .workflow_trace(workflow.workflow_id, None, 10)
2049 .unwrap()
2050 .spans[0]
2051 .span_id;
2052 application
2053 .handle_command(RequestId(3), Command::CloseFolder)
2054 .unwrap();
2055 application
2056 .handle_command(
2057 RequestId(4),
2058 Command::OpenFolder {
2059 path: folder.path().to_owned(),
2060 },
2061 )
2062 .unwrap();
2063 let page = application
2064 .workflow_trace(workflow.workflow_id, None, 10)
2065 .unwrap();
2066 assert_eq!(page.summary.span_count, 2);
2067 assert_eq!(page.lanes.len(), 1);
2068 assert_ne!(page.spans[0].span_id, original);
2069 assert!(page.spans[0].is_live);
2070 assert_eq!(page.spans[1].span_id, original);
2071 assert_eq!(page.spans[1].status, TraceSpanStatus::Stopped);
2072 assert!(!page.spans[1].is_live);
2073 assert_eq!(application.snapshot().unwrap().traces[0], page.summary);
2074 }
2075}