Skip to main content

apm_core/wrapper/builtin/
mod.rs

1pub mod claude;
2pub mod mock_happy;
3pub mod mock_sad;
4pub mod mock_random;
5pub mod debug;
6
7pub use claude::ClaudeWrapper;
8pub use mock_happy::MockHappyWrapper;
9pub use mock_sad::MockSadWrapper;
10pub use mock_random::MockRandomWrapper;
11pub use debug::DebugWrapper;
12
13use std::collections::HashMap;
14use crate::config::{Config, TransitionConfig, StateConfig};
15use crate::wrapper::WrapperContext;
16
17pub(crate) fn load_transitions_with_outcomes(
18    ctx: &WrapperContext,
19) -> anyhow::Result<Vec<(TransitionConfig, StateConfig)>> {
20    let config = Config::load(&ctx.root)?;
21    let current = config.workflow.states.iter()
22        .find(|s| s.id == ctx.current_state)
23        .ok_or_else(|| anyhow::anyhow!("state '{}' not found in workflow", ctx.current_state))?;
24    let state_map: HashMap<&str, &StateConfig> = config.workflow.states.iter()
25        .map(|s| (s.id.as_str(), s))
26        .collect();
27    let mut result = Vec::new();
28    for t in &current.transitions {
29        if let Some(&target) = state_map.get(t.to.as_str()) {
30            result.push((t.clone(), target.clone()));
31        }
32    }
33    Ok(result)
34}
35
36pub(crate) fn is_impl_mode(transitions: &[(TransitionConfig, StateConfig)]) -> bool {
37    use crate::config::CompletionStrategy;
38    transitions.iter().any(|(t, _)| t.completion != CompletionStrategy::None)
39}
40
41pub(crate) fn happy_script(id: &str, target: &str, impl_mode: bool) -> String {
42    if impl_mode {
43        format!(
44            r#"#!/bin/sh
45set -e
46APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
47ID="{id}"
48printf 'mock: placeholder implementation for ticket %s\n' "$ID" > mock-implementation.txt
49git add mock-implementation.txt
50git commit -m "mock: placeholder commit for ticket $ID"
51printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"git_commit","input":{{}}}}'
52printf '%s\n' '{{"type":"tool_use","id":"mock-2","name":"apm_state","input":{{}}}}'
53"$APM" state "$ID" {target}
54rm -f "$0"
55"#
56        )
57    } else {
58        format!(
59            r#"#!/bin/sh
60set -e
61APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
62ID="{id}"
63"$APM" spec "$ID" --section "Problem" --set "Mock spec — no real problem analyzed."
64printf '%s\n' "- [ ] Mock criterion 1" "- [ ] Mock criterion 2" > ".apm-mock-ac-$$.txt"
65"$APM" spec "$ID" --section "Acceptance criteria" --set-file ".apm-mock-ac-$$.txt"
66rm -f ".apm-mock-ac-$$.txt"
67"$APM" spec "$ID" --section "Out of scope" --set "Nothing in scope for this mock run"
68"$APM" spec "$ID" --section "Approach" --set "Mock approach — no real implementation analyzed."
69"$APM" set "$ID" effort 1
70"$APM" set "$ID" risk 1
71printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"write_spec","input":{{}}}}'
72printf '%s\n' '{{"type":"tool_use","id":"mock-2","name":"apm_state","input":{{}}}}'
73"$APM" state "$ID" {target}
74rm -f "$0"
75"#
76        )
77    }
78}
79
80pub(crate) fn sad_script(id: &str, target: &str) -> String {
81    format!(
82        r#"#!/bin/sh
83set -e
84APM="${{APM_BIN:?APM_BIN not set — see wrapper contract}}"
85ID="{id}"
86"$APM" spec "$ID" --section "Problem" --set "Mock sad run — spec intentionally incomplete."
87printf '%s\n' '{{"type":"tool_use","id":"mock-1","name":"write_partial_spec","input":{{}}}}'
88"$APM" state "$ID" {target}
89rm -f "$0"
90"#
91    )
92}
93
94pub(crate) fn seed_from_ctx(ctx: &WrapperContext) -> u64 {
95    // Check ctx.options["seed"] first (set by [workers.options] seed = "...")
96    if let Some(s) = ctx.options.get("seed").and_then(|s| s.parse().ok()) {
97        return s;
98    }
99    // Fall back to APM_OPT_SEED env var (for test injection or external scripts)
100    if let Some(s) = std::env::var("APM_OPT_SEED").ok().and_then(|s| s.parse().ok()) {
101        return s;
102    }
103    // Fall back to time-based random
104    use std::time::{SystemTime, UNIX_EPOCH};
105    SystemTime::now()
106        .duration_since(UNIX_EPOCH)
107        .unwrap_or_default()
108        .subsec_nanos() as u64
109}
110
111pub(crate) fn write_and_spawn_script(
112    name: &str,
113    script: &str,
114    ctx: &WrapperContext,
115) -> anyhow::Result<std::process::Child> {
116    use std::os::unix::fs::PermissionsExt;
117    use std::os::unix::process::CommandExt;
118    use crate::wrapper::CONTRACT_VERSION;
119
120    // Write the script file
121    let script_path = ctx.worktree_path.join(format!(".apm-mock-{name}-{:04x}.sh", super::rand_u16()));
122    std::fs::write(&script_path, script)?;
123    std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755))?;
124
125    // Determine APM_BIN: ctx.options["apm_bin"] for tests, else current_exe
126    let apm_bin = ctx.options.get("apm_bin")
127        .cloned()
128        .unwrap_or_else(|| super::resolve_apm_cli_bin());
129
130    let mut cmd = std::process::Command::new("/bin/sh");
131    cmd.arg(&script_path);
132
133    // Set APM contract env vars
134    cmd.env("APM_AGENT_NAME", &ctx.worker_name);
135    cmd.env("APM_TICKET_ID", &ctx.ticket_id);
136    cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
137    cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
138    cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
139    cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
140    cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
141    cmd.env("APM_PROFILE", &ctx.profile);
142    if let Some(ref prefix) = ctx.role_prefix {
143        cmd.env("APM_ROLE_PREFIX", prefix);
144    }
145    cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
146    cmd.env("APM_BIN", &apm_bin);
147    cmd.env("APM_PROJECT_ROOT", ctx.root.to_string_lossy().as_ref());
148
149    // Forward options as APM_OPT_<KEY>
150    for (k, v) in &ctx.options {
151        let env_key = format!(
152            "APM_OPT_{}",
153            k.to_uppercase().replace('.', "_").replace('-', "_")
154        );
155        cmd.env(&env_key, v);
156    }
157
158    cmd.current_dir(&ctx.worktree_path);
159    cmd.process_group(0);
160
161    let log_file = std::fs::File::create(&ctx.log_path)?;
162    let log_clone = log_file.try_clone()?;
163    cmd.stdout(log_file);
164    cmd.stderr(log_clone);
165
166    Ok(cmd.spawn()?)
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::collections::HashMap;
173    use std::path::PathBuf;
174
175    fn make_ctx_with_options(opts: HashMap<String, String>) -> WrapperContext {
176        WrapperContext {
177            worker_name: "test".into(),
178            ticket_id: "t".into(),
179            ticket_branch: "b".into(),
180            worktree_path: PathBuf::from("/tmp"),
181            system_prompt_file: PathBuf::from("/tmp/sys"),
182            user_message_file: PathBuf::from("/tmp/msg"),
183            skip_permissions: false,
184            profile: "default".into(),
185            role_prefix: None,
186            options: opts,
187            model: None,
188            log_path: PathBuf::from("/tmp/log"),
189            container: None,
190            extra_env: HashMap::new(),
191            root: PathBuf::from("/tmp"),
192            keychain: HashMap::new(),
193            current_state: "test".into(),
194            command: None,
195        }
196    }
197
198    #[test]
199    fn seed_from_ctx_uses_explicit_option() {
200        let mut opts = HashMap::new();
201        opts.insert("seed".into(), "12345".into());
202        let ctx = make_ctx_with_options(opts);
203        assert_eq!(seed_from_ctx(&ctx), 12345);
204    }
205
206    #[test]
207    fn seed_from_ctx_falls_back_when_no_option() {
208        // Without an explicit seed and no APM_OPT_SEED env, returns
209        // a time-based value — just assert that it doesn't panic.
210        let ctx = make_ctx_with_options(HashMap::new());
211        let _ = seed_from_ctx(&ctx);
212    }
213
214    #[test]
215    fn happy_script_includes_target_state_and_id() {
216        let s = happy_script("abc123", "implemented", true);
217        assert!(s.contains("ID=\"abc123\""), "id must appear: {s}");
218        assert!(s.contains("apm\" state \"$ID\" implemented") || s.contains("$APM\" state \"$ID\" implemented"),
219            "target transition must appear: {s}");
220    }
221
222    #[test]
223    fn happy_script_spec_mode_writes_spec_sections() {
224        let s = happy_script("abc123", "specd", false);
225        assert!(s.contains("--section \"Problem\""), "spec mode must populate Problem: {s}");
226        assert!(s.contains("--section \"Acceptance criteria\""), "spec mode must populate AC: {s}");
227    }
228
229    #[test]
230    fn happy_script_impl_mode_creates_commit() {
231        let s = happy_script("abc123", "implemented", true);
232        assert!(s.contains("git commit"), "impl mode must create commit: {s}");
233    }
234
235    #[test]
236    fn sad_script_includes_target_state() {
237        let s = sad_script("abc123", "blocked");
238        assert!(s.contains("ID=\"abc123\""), "id must appear: {s}");
239        assert!(s.contains("apm\" state \"$ID\" blocked") || s.contains("$APM\" state \"$ID\" blocked"),
240            "sad target must appear: {s}");
241    }
242
243    fn make_transition(to: &str, completion: crate::config::CompletionStrategy) -> crate::config::TransitionConfig {
244        crate::config::TransitionConfig {
245            to: to.into(),
246            trigger: "command:state".into(),
247            label: String::new(),
248            hint: String::new(),
249            completion,
250            focus_section: None,
251            context_section: None,
252            warning: None,
253            on_failure: None,
254            outcome: None,
255            profile: None,
256            instructions: None,
257            role_prefix: None,
258            agent: None,
259        }
260    }
261
262    fn make_state(id: &str) -> crate::config::StateConfig {
263        crate::config::StateConfig {
264            id: id.into(),
265            label: id.into(),
266            description: String::new(),
267            actionable: vec![],
268            terminal: false,
269            worker_end: false,
270            satisfies_deps: crate::config::SatisfiesDeps::Bool(false),
271            dep_requires: None,
272            transitions: vec![],
273            instructions: None,
274        }
275    }
276
277    #[test]
278    fn is_impl_mode_true_when_any_completion_strategy() {
279        use crate::config::CompletionStrategy;
280        assert!(is_impl_mode(&[(make_transition("implemented", CompletionStrategy::Merge), make_state("implemented"))]));
281    }
282
283    #[test]
284    fn is_impl_mode_false_when_all_none() {
285        use crate::config::CompletionStrategy;
286        assert!(!is_impl_mode(&[(make_transition("specd", CompletionStrategy::None), make_state("specd"))]));
287    }
288}