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                mutation_status: crate::agent_events::ToolMutationStatus::Unknown,
118                changed_paths: None,
119                executor: None,
120                parsing: None,
121                raw_input: None,
122                raw_input_partial: None,
123                audit: None,
124            },
125        ));
126
127        let i = bump(20, &mut now, &mut idx);
128        events.push(envelope(
129            i,
130            now,
131            AgentEvent::Plan {
132                session_id: session_id.clone(),
133                plan: pr_plan(pr),
134            },
135        ));
136
137        tool_call_counter += 1;
138        let checks_id = format!("call_{tool_call_counter}");
139        let i = bump(20, &mut now, &mut idx);
140        events.push(envelope(
141            i,
142            now,
143            AgentEvent::ToolCall {
144                session_id: session_id.clone(),
145                tool_call_id: checks_id.clone(),
146                tool_name: "gh_pr_checks_list".to_string(),
147                kind: None,
148                status: ToolCallStatus::Pending,
149                raw_input: json!({"repo": format!("{}/{}", state.owner, pr.repo), "pr_number": pr.number}),
150                parsing: None,
151                audit: None,
152            },
153        ));
154        let i = bump(40, &mut now, &mut idx);
155        events.push(envelope(
156            i,
157            now,
158            AgentEvent::ToolCallUpdate {
159                session_id: session_id.clone(),
160                tool_call_id: checks_id,
161                tool_name: "gh_pr_checks_list".to_string(),
162                status: ToolCallStatus::Completed,
163                raw_output: Some(checks_summary(pr)),
164                error: None,
165                duration_ms: Some(40),
166                execution_duration_ms: Some(40),
167                error_category: None,
168                mutation_status: crate::agent_events::ToolMutationStatus::Unknown,
169                changed_paths: None,
170                executor: None,
171                parsing: None,
172                raw_input: None,
173                raw_input_partial: None,
174                audit: None,
175            },
176        ));
177
178        let i = bump(10, &mut now, &mut idx);
179        events.push(envelope(
180            i,
181            now,
182            AgentEvent::Plan {
183                session_id: session_id.clone(),
184                plan: risk_plan(pr),
185            },
186        ));
187    }
188
189    let i = bump(50, &mut now, &mut idx);
190    events.push(envelope(
191        i,
192        now,
193        AgentEvent::AgentThoughtChunk {
194            session_id,
195            content: format!(
196                "Sweep complete: {} PR(s) inspected, {} require follow-up",
197                state
198                    .pull_requests
199                    .values()
200                    .filter(|p| p.state == "open")
201                    .count(),
202                state
203                    .pull_requests
204                    .values()
205                    .filter(|p| p.state == "open" && needs_followup(p))
206                    .count()
207            ),
208        },
209    ));
210
211    let _ = bump(0, &mut now, &mut idx);
212    events
213}
214
215fn pr_summary(pr: &PlaygroundPullRequest) -> Value {
216    let failing: Vec<String> = pr
217        .checks
218        .iter()
219        .filter(|c| {
220            c.conclusion
221                .as_deref()
222                .map(|c| matches!(c, "failure" | "timed_out" | "cancelled"))
223                .unwrap_or(false)
224        })
225        .map(|c| c.name.clone())
226        .collect();
227    json!({
228        "repo": pr.repo,
229        "pr_number": pr.number,
230        "title": pr.title,
231        "state": pr.state,
232        "head_branch": pr.head_branch,
233        "base_branch": pr.base_branch,
234        "mergeable": pr.mergeable,
235        "mergeable_state": pr.mergeable_state,
236        "failing_checks": failing,
237        "stale_threads": Vec::<String>::new(),
238        "merge_conflicts": pr.mergeable_state == "dirty",
239        "merge_queue_status": pr.merge_queue_status,
240    })
241}
242
243fn pr_plan(pr: &PlaygroundPullRequest) -> Value {
244    json!({
245        "step": "intake",
246        "repo": pr.repo,
247        "pr_number": pr.number,
248        "head_branch": pr.head_branch,
249        "approval_required": false,
250    })
251}
252
253fn risk_plan(pr: &PlaygroundPullRequest) -> Value {
254    let risk = if needs_followup(pr) { "high" } else { "low" };
255    json!({
256        "step": "decide_risk",
257        "repo": pr.repo,
258        "pr_number": pr.number,
259        "review_risk": risk,
260        "approval_required": false,
261    })
262}
263
264fn checks_summary(pr: &PlaygroundPullRequest) -> Value {
265    let runs: Vec<Value> = pr
266        .checks
267        .iter()
268        .map(|c| {
269            json!({
270                "name": c.name,
271                "status": c.status,
272                "conclusion": c.conclusion,
273            })
274        })
275        .collect();
276    json!({"check_runs": runs})
277}
278
279fn needs_followup(pr: &PlaygroundPullRequest) -> bool {
280    pr.mergeable_state == "behind"
281        || pr.mergeable_state == "dirty"
282        || pr.mergeable_state == "blocked"
283        || pr
284            .checks
285            .iter()
286            .any(|c| matches!(c.conclusion.as_deref(), Some("failure" | "timed_out")))
287}