Skip to main content

twine_core/application/
traces.rs

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    /// Reads cached trace storage statistics and schedules background maintenance.
36    ///
37    /// # Errors
38    /// Returns storage or application-lock failures.
39    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    /// Protects a step's full retained details from automatic cleanup.
57    ///
58    /// # Errors
59    /// Returns storage or application-lock failures.
60    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    /// Clears unpinned, completed full details in the background. Native files are untouched.
73    ///
74    /// # Errors
75    /// Returns storage or application-lock failures.
76    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    /// Reads full recorded activity input or output in bounded UTF-8 pages.
92    ///
93    /// # Errors
94    /// Returns an error for missing activity, invalid page size, or unavailable payload storage.
95    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    /// Reads native tool and subagent activity in stable ID order. Refresh from the beginning when
109    /// the workflow revision changes, because a finish updates an existing activity.
110    ///
111    /// # Errors
112    /// Returns an error if the span is missing, the page limit is invalid, or storage fails.
113    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    /// Reads a bounded page of the workflow's durable trace, including closed workflow history.
149    ///
150    /// # Errors
151    /// Returns an error if the workflow is missing, the page limit is invalid, or storage fails.
152    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    /// Reads a bounded page of events for a span, in durable event ID order.
198    ///
199    /// # Errors
200    /// Returns an error if the span is missing, the page limit is invalid, or storage fails.
201    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        // A journal failure must not retry an already committed command boundary.
405        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                // Fish probes device attributes before its first prompt. Stand in for the
760                // terminal emulator so PTY integration tests exercise actual interactive hooks.
761                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}