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