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::IterationStart {
55            session_id: session_id.clone(),
56            iteration: 1,
57            provider: String::new(),
58            model: String::new(),
59        },
60    ));
61
62    let i = bump(10, &mut now, &mut idx);
63    events.push(envelope(
64        i,
65        now,
66        AgentEvent::AgentThoughtChunk {
67            session_id: session_id.clone(),
68            content: format!(
69                "Sweep playground scenario={} ({} repos, {} PRs)",
70                state.scenario,
71                state.repos.len(),
72                state.pull_requests.len()
73            ),
74        },
75    ));
76
77    let mut prs: Vec<&PlaygroundPullRequest> = state
78        .pull_requests
79        .values()
80        .filter(|pr| pr.state == "open")
81        .collect();
82    prs.sort_by_key(|pr| (pr.repo.clone(), pr.number));
83
84    let mut tool_call_counter = 0u64;
85    for pr in prs {
86        tool_call_counter += 1;
87        let intake_id = format!("call_{tool_call_counter}");
88        let i = bump(20, &mut now, &mut idx);
89        events.push(envelope(
90            i,
91            now,
92            AgentEvent::ToolCall {
93                session_id: session_id.clone(),
94                tool_call_id: intake_id.clone(),
95                tool_name: "gh_pull_request_get".to_string(),
96                kind: None,
97                status: ToolCallStatus::Pending,
98                raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
99                parsing: None,
100                audit: None,
101            },
102        ));
103        let i = bump(40, &mut now, &mut idx);
104        events.push(envelope(
105            i,
106            now,
107            AgentEvent::ToolCallUpdate {
108                session_id: session_id.clone(),
109                tool_call_id: intake_id,
110                tool_name: "gh_pull_request_get".to_string(),
111                status: ToolCallStatus::Completed,
112                raw_output: Some(pr_summary(pr)),
113                error: None,
114                duration_ms: Some(40),
115                execution_duration_ms: Some(40),
116                error_category: None,
117                executor: None,
118                parsing: None,
119                raw_input: None,
120                raw_input_partial: None,
121                audit: None,
122            },
123        ));
124
125        let i = bump(20, &mut now, &mut idx);
126        events.push(envelope(
127            i,
128            now,
129            AgentEvent::Plan {
130                session_id: session_id.clone(),
131                plan: pr_plan(pr),
132            },
133        ));
134
135        tool_call_counter += 1;
136        let checks_id = format!("call_{tool_call_counter}");
137        let i = bump(20, &mut now, &mut idx);
138        events.push(envelope(
139            i,
140            now,
141            AgentEvent::ToolCall {
142                session_id: session_id.clone(),
143                tool_call_id: checks_id.clone(),
144                tool_name: "gh_pr_checks_list".to_string(),
145                kind: None,
146                status: ToolCallStatus::Pending,
147                raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
148                parsing: None,
149                audit: None,
150            },
151        ));
152        let i = bump(40, &mut now, &mut idx);
153        events.push(envelope(
154            i,
155            now,
156            AgentEvent::ToolCallUpdate {
157                session_id: session_id.clone(),
158                tool_call_id: checks_id,
159                tool_name: "gh_pr_checks_list".to_string(),
160                status: ToolCallStatus::Completed,
161                raw_output: Some(checks_summary(pr)),
162                error: None,
163                duration_ms: Some(40),
164                execution_duration_ms: Some(40),
165                error_category: None,
166                executor: None,
167                parsing: None,
168                raw_input: None,
169                raw_input_partial: None,
170                audit: None,
171            },
172        ));
173
174        let i = bump(10, &mut now, &mut idx);
175        events.push(envelope(
176            i,
177            now,
178            AgentEvent::Plan {
179                session_id: session_id.clone(),
180                plan: risk_plan(pr),
181            },
182        ));
183    }
184
185    let i = bump(50, &mut now, &mut idx);
186    events.push(envelope(
187        i,
188        now,
189        AgentEvent::AgentThoughtChunk {
190            session_id: session_id.clone(),
191            content: format!(
192                "Sweep complete: {} PR(s) inspected, {} require follow-up",
193                state
194                    .pull_requests
195                    .values()
196                    .filter(|p| p.state == "open")
197                    .count(),
198                state
199                    .pull_requests
200                    .values()
201                    .filter(|p| p.state == "open" && needs_followup(p))
202                    .count()
203            ),
204        },
205    ));
206
207    let _ = bump(0, &mut now, &mut idx);
208    events
209}
210
211fn pr_summary(pr: &PlaygroundPullRequest) -> Value {
212    let failing: Vec<String> = pr
213        .checks
214        .iter()
215        .filter(|c| {
216            c.conclusion
217                .as_deref()
218                .map(|c| matches!(c, "failure" | "timed_out" | "cancelled"))
219                .unwrap_or(false)
220        })
221        .map(|c| c.name.clone())
222        .collect();
223    json!({
224        "repo": pr.repo,
225        "pr_number": pr.number,
226        "title": pr.title,
227        "state": pr.state,
228        "head_branch": pr.head_branch,
229        "base_branch": pr.base_branch,
230        "mergeable": pr.mergeable,
231        "mergeable_state": pr.mergeable_state,
232        "failing_checks": failing,
233        "stale_threads": Vec::<String>::new(),
234        "merge_conflicts": pr.mergeable_state == "dirty",
235        "merge_queue_status": pr.merge_queue_status,
236    })
237}
238
239fn pr_plan(pr: &PlaygroundPullRequest) -> Value {
240    json!({
241        "step": "intake",
242        "repo": pr.repo,
243        "pr_number": pr.number,
244        "head_branch": pr.head_branch,
245        "approval_required": false,
246    })
247}
248
249fn risk_plan(pr: &PlaygroundPullRequest) -> Value {
250    let risk = if needs_followup(pr) { "high" } else { "low" };
251    json!({
252        "step": "decide_risk",
253        "repo": pr.repo,
254        "pr_number": pr.number,
255        "review_risk": risk,
256        "approval_required": false,
257    })
258}
259
260fn checks_summary(pr: &PlaygroundPullRequest) -> Value {
261    let runs: Vec<Value> = pr
262        .checks
263        .iter()
264        .map(|c| {
265            json!({
266                "name": c.name,
267                "status": c.status,
268                "conclusion": c.conclusion,
269            })
270        })
271        .collect();
272    json!({"check_runs": runs})
273}
274
275fn needs_followup(pr: &PlaygroundPullRequest) -> bool {
276    pr.mergeable_state == "behind"
277        || pr.mergeable_state == "dirty"
278        || pr.mergeable_state == "blocked"
279        || pr
280            .checks
281            .iter()
282            .any(|c| matches!(c.conclusion.as_deref(), Some("failure" | "timed_out")))
283}