Skip to main content

harn_vm/orchestration/playground/
transcript.rs

1//! Synthesize a canonical Merge Captain JSONL transcript from a
2//! `PlaygroundState`. This is what the existing `--backend mock` driver
3//! produces when pointed at a real on-disk playground (vs. the transcript
4//! replay path, which still works for the preexisting JSON manifest).
5//!
6//! The transcript intentionally mirrors the same `PersistedAgentEvent`
7//! envelope that the audit oracle and JSONL sink consume, so byte-stable
8//! receipts can be diffed across runs.
9
10use serde_json::{json, Value};
11
12use crate::agent_events::{AgentEvent, PersistedAgentEvent, ToolCallStatus};
13
14use super::state::{PlaygroundPullRequest, PlaygroundState};
15
16pub struct TranscriptOptions {
17    pub session_id: String,
18}
19
20impl Default for TranscriptOptions {
21    fn default() -> Self {
22        TranscriptOptions {
23            session_id: "merge-captain-playground".to_string(),
24        }
25    }
26}
27
28pub fn synthesize_sweep(
29    state: &PlaygroundState,
30    options: &TranscriptOptions,
31) -> Vec<PersistedAgentEvent> {
32    let mut events: Vec<PersistedAgentEvent> = Vec::new();
33    let mut now = state.now_ms;
34    let mut idx: u64 = 0;
35    let bump = |delta: i64, now: &mut i64, idx: &mut u64| {
36        *now = now.saturating_add(delta);
37        let value = *idx;
38        *idx += 1;
39        value
40    };
41
42    let session_id = options.session_id.clone();
43    let envelope = |index: u64, at: i64, event: AgentEvent| PersistedAgentEvent {
44        index,
45        emitted_at_ms: at,
46        frame_depth: Some(0),
47        event,
48    };
49
50    let i = bump(0, &mut now, &mut idx);
51    events.push(envelope(
52        i,
53        now,
54        AgentEvent::TurnStart {
55            session_id: session_id.clone(),
56            iteration: 1,
57        },
58    ));
59
60    let i = bump(10, &mut now, &mut idx);
61    events.push(envelope(
62        i,
63        now,
64        AgentEvent::AgentThoughtChunk {
65            session_id: session_id.clone(),
66            content: format!(
67                "Sweep playground scenario={} ({} repos, {} PRs)",
68                state.scenario,
69                state.repos.len(),
70                state.pull_requests.len()
71            ),
72        },
73    ));
74
75    let mut prs: Vec<&PlaygroundPullRequest> = state
76        .pull_requests
77        .values()
78        .filter(|pr| pr.state == "open")
79        .collect();
80    prs.sort_by_key(|pr| (pr.repo.clone(), pr.number));
81
82    let mut tool_call_counter = 0u64;
83    for pr in prs {
84        tool_call_counter += 1;
85        let intake_id = format!("call_{tool_call_counter}");
86        let i = bump(20, &mut now, &mut idx);
87        events.push(envelope(
88            i,
89            now,
90            AgentEvent::ToolCall {
91                session_id: session_id.clone(),
92                tool_call_id: intake_id.clone(),
93                tool_name: "gh_pull_request_get".to_string(),
94                kind: None,
95                status: ToolCallStatus::Pending,
96                raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
97                parsing: None,
98                audit: None,
99            },
100        ));
101        let i = bump(40, &mut now, &mut idx);
102        events.push(envelope(
103            i,
104            now,
105            AgentEvent::ToolCallUpdate {
106                session_id: session_id.clone(),
107                tool_call_id: intake_id,
108                tool_name: "gh_pull_request_get".to_string(),
109                status: ToolCallStatus::Completed,
110                raw_output: Some(pr_summary(pr)),
111                error: None,
112                duration_ms: Some(40),
113                execution_duration_ms: Some(40),
114                error_category: None,
115                executor: None,
116                parsing: None,
117                raw_input: None,
118                raw_input_partial: None,
119                audit: None,
120            },
121        ));
122
123        let i = bump(20, &mut now, &mut idx);
124        events.push(envelope(
125            i,
126            now,
127            AgentEvent::Plan {
128                session_id: session_id.clone(),
129                plan: pr_plan(pr),
130            },
131        ));
132
133        tool_call_counter += 1;
134        let checks_id = format!("call_{tool_call_counter}");
135        let i = bump(20, &mut now, &mut idx);
136        events.push(envelope(
137            i,
138            now,
139            AgentEvent::ToolCall {
140                session_id: session_id.clone(),
141                tool_call_id: checks_id.clone(),
142                tool_name: "gh_pr_checks_list".to_string(),
143                kind: None,
144                status: ToolCallStatus::Pending,
145                raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
146                parsing: None,
147                audit: None,
148            },
149        ));
150        let i = bump(40, &mut now, &mut idx);
151        events.push(envelope(
152            i,
153            now,
154            AgentEvent::ToolCallUpdate {
155                session_id: session_id.clone(),
156                tool_call_id: checks_id,
157                tool_name: "gh_pr_checks_list".to_string(),
158                status: ToolCallStatus::Completed,
159                raw_output: Some(checks_summary(pr)),
160                error: None,
161                duration_ms: Some(40),
162                execution_duration_ms: Some(40),
163                error_category: None,
164                executor: None,
165                parsing: None,
166                raw_input: None,
167                raw_input_partial: None,
168                audit: None,
169            },
170        ));
171
172        let i = bump(10, &mut now, &mut idx);
173        events.push(envelope(
174            i,
175            now,
176            AgentEvent::Plan {
177                session_id: session_id.clone(),
178                plan: risk_plan(pr),
179            },
180        ));
181    }
182
183    let i = bump(50, &mut now, &mut idx);
184    events.push(envelope(
185        i,
186        now,
187        AgentEvent::AgentThoughtChunk {
188            session_id: session_id.clone(),
189            content: format!(
190                "Sweep complete: {} PR(s) inspected, {} require follow-up",
191                state
192                    .pull_requests
193                    .values()
194                    .filter(|p| p.state == "open")
195                    .count(),
196                state
197                    .pull_requests
198                    .values()
199                    .filter(|p| p.state == "open" && needs_followup(p))
200                    .count()
201            ),
202        },
203    ));
204
205    let _ = bump(0, &mut now, &mut idx);
206    events
207}
208
209fn pr_summary(pr: &PlaygroundPullRequest) -> Value {
210    let failing: Vec<String> = pr
211        .checks
212        .iter()
213        .filter(|c| {
214            c.conclusion
215                .as_deref()
216                .map(|c| matches!(c, "failure" | "timed_out" | "cancelled"))
217                .unwrap_or(false)
218        })
219        .map(|c| c.name.clone())
220        .collect();
221    json!({
222        "repo": pr.repo,
223        "pr_number": pr.number,
224        "title": pr.title,
225        "state": pr.state,
226        "head_branch": pr.head_branch,
227        "base_branch": pr.base_branch,
228        "mergeable": pr.mergeable,
229        "mergeable_state": pr.mergeable_state,
230        "failing_checks": failing,
231        "stale_threads": Vec::<String>::new(),
232        "merge_conflicts": pr.mergeable_state == "dirty",
233        "merge_queue_status": pr.merge_queue_status,
234    })
235}
236
237fn pr_plan(pr: &PlaygroundPullRequest) -> Value {
238    json!({
239        "step": "intake",
240        "repo": pr.repo,
241        "pr_number": pr.number,
242        "head_branch": pr.head_branch,
243        "approval_required": false,
244    })
245}
246
247fn risk_plan(pr: &PlaygroundPullRequest) -> Value {
248    let risk = if needs_followup(pr) { "high" } else { "low" };
249    json!({
250        "step": "decide_risk",
251        "repo": pr.repo,
252        "pr_number": pr.number,
253        "review_risk": risk,
254        "approval_required": false,
255    })
256}
257
258fn checks_summary(pr: &PlaygroundPullRequest) -> Value {
259    let runs: Vec<Value> = pr
260        .checks
261        .iter()
262        .map(|c| {
263            json!({
264                "name": c.name,
265                "status": c.status,
266                "conclusion": c.conclusion,
267            })
268        })
269        .collect();
270    json!({"check_runs": runs})
271}
272
273fn needs_followup(pr: &PlaygroundPullRequest) -> bool {
274    pr.mergeable_state == "behind"
275        || pr.mergeable_state == "dirty"
276        || pr.mergeable_state == "blocked"
277        || pr
278            .checks
279            .iter()
280            .any(|c| matches!(c.conclusion.as_deref(), Some("failure" | "timed_out")))
281}