Skip to main content

apm_core/wrapper/
custom.rs

1use std::io::Write;
2use std::path::{Path, PathBuf};
3use serde::Deserialize;
4use anyhow::Context;
5use super::{Wrapper, WrapperContext, CONTRACT_VERSION};
6
7#[derive(Debug, Clone, PartialEq)]
8enum ParserStrategy {
9    Canonical,
10    External,
11}
12
13impl ParserStrategy {
14    fn from_manifest(m: Option<&Manifest>) -> Self {
15        match m.and_then(|m| Some(m.parser.as_str())) {
16            Some("external") => Self::External,
17            _ => Self::Canonical,
18        }
19    }
20}
21
22/// Locate an executable binary by name or absolute path.
23/// For absolute paths: checks the file exists.
24/// For relative names: walks PATH entries and returns the first executable match.
25fn find_binary(cmd: &str) -> anyhow::Result<PathBuf> {
26    let p = Path::new(cmd);
27    if p.is_absolute() {
28        if p.is_file() {
29            return Ok(p.to_path_buf());
30        }
31        anyhow::bail!("parser binary not found: {}", cmd);
32    }
33    let path_var = std::env::var("PATH").unwrap_or_default();
34    for dir in std::env::split_paths(&path_var) {
35        let candidate = dir.join(cmd);
36        if !candidate.is_file() {
37            continue;
38        }
39        #[cfg(unix)]
40        {
41            use std::os::unix::fs::PermissionsExt;
42            if let Ok(meta) = candidate.metadata() {
43                if meta.permissions().mode() & 0o111 == 0 {
44                    continue;
45                }
46            }
47        }
48        return Ok(candidate);
49    }
50    anyhow::bail!("parser binary not found: {}", cmd);
51}
52
53fn default_contract_version() -> u32 { CONTRACT_VERSION }
54fn default_parser() -> String { "canonical".to_string() }
55
56#[derive(Debug, Deserialize, Clone)]
57pub struct Manifest {
58    #[serde(default)]
59    pub name: Option<String>,
60    #[serde(default = "default_contract_version")]
61    pub contract_version: u32,
62    #[serde(default = "default_parser")]
63    pub parser: String,
64    #[serde(default)]
65    pub parser_command: Option<String>,
66}
67
68pub enum WrapperKind {
69    Custom { script_path: PathBuf, manifest: Option<Manifest> },
70    Builtin(String),
71}
72
73pub struct CustomWrapper {
74    pub script_path: PathBuf,
75    pub manifest: Option<Manifest>,
76}
77
78fn check_contract_version(declared: u32, apm_version: u32, log_path: &Path) -> anyhow::Result<()> {
79    match declared.cmp(&apm_version) {
80        std::cmp::Ordering::Greater => anyhow::bail!(
81            "wrapper targets contract version {} but this APM build supports up to \
82             version {}; upgrade APM",
83            declared,
84            apm_version,
85        ),
86        std::cmp::Ordering::Less => {
87            if let Ok(mut f) = std::fs::OpenOptions::new()
88                .append(true)
89                .create(true)
90                .open(log_path)
91            {
92                let _ = writeln!(
93                    f,
94                    "[apm] warning: wrapper targets contract version {} but this APM \
95                     build is version {}; the wrapper may not use newer env vars",
96                    declared, apm_version,
97                );
98            }
99        }
100        std::cmp::Ordering::Equal => {}
101    }
102    Ok(())
103}
104
105impl Wrapper for CustomWrapper {
106    fn spawn(&self, ctx: &WrapperContext) -> anyhow::Result<std::process::Child> {
107        // Layer 2 spawn-time safety net: check contract_version unconditionally.
108        // Even if apm validate already passed, the manifest may have been edited
109        // between validate and this spawn call.
110        let declared = self.manifest.as_ref().map_or(1, |m| m.contract_version);
111        check_contract_version(declared, CONTRACT_VERSION, &ctx.log_path)
112            .map_err(|e| anyhow::anyhow!("wrapper '{}': {}", self.script_path.display(), e))?;
113
114        let apm_bin = std::env::current_exe()
115            .and_then(|p| p.canonicalize())
116            .map(|p| p.to_string_lossy().into_owned())
117            .unwrap_or_default();
118
119        let mut cmd = std::process::Command::new(&self.script_path);
120
121        set_apm_env(&mut cmd, ctx, &apm_bin);
122        for (k, v) in &ctx.extra_env {
123            cmd.env(k, v);
124        }
125        cmd.current_dir(&ctx.worktree_path);
126
127        let strategy = ParserStrategy::from_manifest(self.manifest.as_ref());
128
129        #[cfg(unix)]
130        use std::os::unix::process::CommandExt;
131
132        match strategy {
133            ParserStrategy::Canonical => {
134                let log_file = std::fs::File::create(&ctx.log_path)?;
135                let log_clone = log_file.try_clone()?;
136                cmd.stdout(log_file);
137                cmd.stderr(log_clone);
138                #[cfg(unix)]
139                cmd.process_group(0);
140                Ok(cmd.spawn()?)
141            }
142            ParserStrategy::External => {
143                let manifest_path = self.script_path
144                    .parent()
145                    .map(|p| p.join("manifest.toml"))
146                    .unwrap_or_else(|| PathBuf::from("manifest.toml"));
147
148                // Require parser_command
149                let parser_cmd_str = self.manifest.as_ref()
150                    .and_then(|m| m.parser_command.as_deref())
151                    .ok_or_else(|| anyhow::anyhow!(
152                        "{}: parser = \"external\" but parser_command is not set",
153                        manifest_path.display()
154                    ))?
155                    .to_owned();
156
157                // Validate binary is findable before spawning any process
158                let parser_bin = find_binary(&parser_cmd_str)?;
159
160                // Open log file; clone for each stream that writes to it:
161                // 1. wrapper.stderr, 2. parser.stdout, 3. parser.stderr
162                let log_file_wrapper_stderr = std::fs::File::create(&ctx.log_path)?;
163                let log_file_parser_stdout = log_file_wrapper_stderr.try_clone()?;
164                let log_file_parser_stderr = log_file_wrapper_stderr.try_clone()?;
165
166                use std::process::Stdio;
167
168                // Spawn wrapper: stdout piped to feed parser stdin; stderr directly to log
169                cmd.stdout(Stdio::piped());
170                cmd.stderr(log_file_wrapper_stderr);
171                #[cfg(unix)]
172                cmd.process_group(0);
173                let mut wrapper_child = cmd.spawn()?;
174
175                let wrapper_stdout = wrapper_child.stdout.take()
176                    .ok_or_else(|| anyhow::anyhow!("failed to capture wrapper stdout pipe"))?;
177
178                // Reap wrapper in background thread; append diagnostic exit line to log
179                let log_path_clone = ctx.log_path.clone();
180                std::thread::spawn(move || {
181                    let status = wrapper_child.wait();
182                    if let Ok(mut f) = std::fs::OpenOptions::new()
183                        .append(true)
184                        .create(true)
185                        .open(&log_path_clone)
186                    {
187                        let status_str = match status {
188                            Ok(s) => format!("{s}"),
189                            Err(e) => format!("error: {e}"),
190                        };
191                        let _ = writeln!(f, "[apm] wrapper exited: {status_str}");
192                    }
193                });
194
195                // Spawn parser: stdin = wrapper stdout pipe; stdout/stderr -> log
196                let mut parser_cmd = std::process::Command::new(&parser_bin);
197                parser_cmd.stdin(Stdio::from(wrapper_stdout));
198                parser_cmd.stdout(log_file_parser_stdout);
199                parser_cmd.stderr(log_file_parser_stderr);
200                parser_cmd.current_dir(&ctx.worktree_path);
201                #[cfg(unix)]
202                parser_cmd.process_group(0);
203
204                Ok(parser_cmd.spawn()?)
205            }
206        }
207    }
208}
209
210fn set_apm_env(cmd: &mut std::process::Command, ctx: &WrapperContext, apm_bin: &str) {
211    cmd.env("APM_AGENT_NAME", &ctx.worker_name);
212    cmd.env("APM_TICKET_ID", &ctx.ticket_id);
213    cmd.env("APM_TICKET_BRANCH", &ctx.ticket_branch);
214    cmd.env("APM_TICKET_WORKTREE", ctx.worktree_path.to_string_lossy().as_ref());
215    cmd.env("APM_SYSTEM_PROMPT_FILE", ctx.system_prompt_file.to_string_lossy().as_ref());
216    cmd.env("APM_USER_MESSAGE_FILE", ctx.user_message_file.to_string_lossy().as_ref());
217    cmd.env("APM_SKIP_PERMISSIONS", if ctx.skip_permissions { "1" } else { "0" });
218    cmd.env("APM_PROFILE", &ctx.profile);
219    if let Some(ref prefix) = ctx.role_prefix {
220        cmd.env("APM_ROLE_PREFIX", prefix);
221    }
222    cmd.env("APM_WRAPPER_VERSION", CONTRACT_VERSION.to_string());
223    cmd.env("APM_BIN", apm_bin);
224    for (k, v) in &ctx.options {
225        let env_key = format!(
226            "APM_OPT_{}",
227            k.to_uppercase().replace('.', "_").replace('-', "_")
228        );
229        cmd.env(&env_key, v);
230    }
231}
232
233pub(crate) fn find_script(root: &Path, name: &str) -> Option<PathBuf> {
234    let dir = root.join(".apm").join("agents").join(name);
235    let mut candidates: Vec<PathBuf> = std::fs::read_dir(&dir)
236        .ok()?
237        .filter_map(|e| e.ok())
238        .filter_map(|e| {
239            let path = e.path();
240            let fname = path.file_name()?.to_str()?.to_owned();
241            if !fname.starts_with("wrapper.") {
242                return None;
243            }
244            #[cfg(unix)]
245            {
246                use std::os::unix::fs::PermissionsExt;
247                let meta = path.metadata().ok()?;
248                if meta.permissions().mode() & 0o111 == 0 {
249                    return None;
250                }
251            }
252            Some(path)
253        })
254        .collect();
255    candidates.sort();
256    candidates.into_iter().next()
257}
258
259pub(crate) fn parse_manifest(root: &Path, name: &str) -> anyhow::Result<Option<Manifest>> {
260    let path = root.join(".apm").join("agents").join(name).join("manifest.toml");
261    if !path.exists() {
262        return Ok(None);
263    }
264    let content = std::fs::read_to_string(&path)
265        .with_context(|| format!("reading {}", path.display()))?;
266
267    #[derive(Deserialize)]
268    struct ManifestFile { wrapper: Manifest }
269
270    let file: ManifestFile = toml::from_str(&content)
271        .with_context(|| format!("parsing {}", path.display()))?;
272    Ok(Some(file.wrapper))
273}
274
275pub(crate) fn manifest_unknown_keys(root: &Path, name: &str) -> anyhow::Result<Vec<String>> {
276    let path = root.join(".apm").join("agents").join(name).join("manifest.toml");
277    if !path.exists() {
278        return Ok(vec![]);
279    }
280    let content = std::fs::read_to_string(&path)
281        .with_context(|| format!("reading {}", path.display()))?;
282    let table: toml::Value = content.parse::<toml::Value>()
283        .with_context(|| format!("parsing {}", path.display()))?;
284    let known = ["name", "contract_version", "parser", "parser_command"];
285    let unknown = match table.get("wrapper").and_then(|v| v.as_table()) {
286        Some(t) => t.keys()
287            .filter(|k| !known.contains(&k.as_str()))
288            .cloned()
289            .collect(),
290        None => vec![],
291    };
292    Ok(unknown)
293}
294
295#[cfg(test)]
296mod tests {
297    use super::*;
298    use std::collections::HashMap;
299
300    fn make_ctx(wt: &std::path::Path, log: &std::path::Path) -> WrapperContext {
301        WrapperContext {
302            worker_name: "test-worker".to_string(),
303            ticket_id: "test-id".to_string(),
304            ticket_branch: "ticket/test-id".to_string(),
305            worktree_path: wt.to_path_buf(),
306            system_prompt_file: wt.join("sys.txt"),
307            user_message_file: wt.join("msg.txt"),
308            skip_permissions: false,
309            profile: "default".to_string(),
310            role_prefix: None,
311            options: HashMap::new(),
312            model: None,
313            log_path: log.to_path_buf(),
314            container: None,
315            extra_env: HashMap::new(),
316            root: wt.to_path_buf(),
317            keychain: HashMap::new(),
318            current_state: "test".to_string(),
319            command: None,
320        }
321    }
322
323    fn make_executable(path: &std::path::Path, content: &str) {
324        std::fs::write(path, content).unwrap();
325        #[cfg(unix)]
326        {
327            use std::os::unix::fs::PermissionsExt;
328            std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
329        }
330    }
331
332    // --- resolve_wrapper tests (via wrapper::resolve_wrapper) ---
333
334    #[test]
335    fn resolve_wrapper_custom_shadows_builtin() {
336        let dir = tempfile::tempdir().unwrap();
337        let root = dir.path();
338        let agent_dir = root.join(".apm").join("agents").join("claude");
339        std::fs::create_dir_all(&agent_dir).unwrap();
340        make_executable(&agent_dir.join("wrapper.sh"), "#!/bin/sh\nexit 0\n");
341
342        let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
343        assert!(matches!(result, Some(WrapperKind::Custom { .. })), "expected Custom variant");
344    }
345
346    #[test]
347    fn resolve_wrapper_fallback_to_builtin() {
348        let dir = tempfile::tempdir().unwrap();
349        let root = dir.path();
350        // No .apm/agents/claude/ dir
351
352        let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
353        assert!(matches!(result, Some(WrapperKind::Builtin(ref n)) if n == "claude"),
354            "expected Builtin(claude)");
355    }
356
357    #[test]
358    fn resolve_wrapper_missing_returns_none() {
359        let dir = tempfile::tempdir().unwrap();
360        let root = dir.path();
361        // "bogus-agent" is neither a builtin nor a custom script
362
363        let result = crate::wrapper::resolve_wrapper(root, "bogus-agent").unwrap();
364        assert!(result.is_none(), "expected None");
365    }
366
367    #[test]
368    fn resolve_wrapper_nonexecutable_invisible() {
369        let dir = tempfile::tempdir().unwrap();
370        let root = dir.path();
371        let agent_dir = root.join(".apm").join("agents").join("claude");
372        std::fs::create_dir_all(&agent_dir).unwrap();
373
374        // Write non-executable wrapper.sh
375        let script = agent_dir.join("wrapper.sh");
376        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
377        #[cfg(unix)]
378        {
379            use std::os::unix::fs::PermissionsExt;
380            std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o644)).unwrap();
381        }
382
383        // Non-executable script is invisible; falls through to builtin
384        let result = crate::wrapper::resolve_wrapper(root, "claude").unwrap();
385        assert!(matches!(result, Some(WrapperKind::Builtin(ref n)) if n == "claude"),
386            "non-executable script should be invisible; expected fallback to Builtin(claude)");
387    }
388
389    // --- manifest tests ---
390
391    #[test]
392    fn manifest_parse_valid() {
393        let dir = tempfile::tempdir().unwrap();
394        let root = dir.path();
395        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
396        std::fs::create_dir_all(&agent_dir).unwrap();
397        std::fs::write(agent_dir.join("manifest.toml"),
398            "[wrapper]\nname = \"my-wrapper\"\ncontract_version = 1\nparser = \"canonical\"\n"
399        ).unwrap();
400
401        let m = parse_manifest(root, "my-wrapper").unwrap().unwrap();
402        assert_eq!(m.contract_version, 1);
403        assert_eq!(m.parser, "canonical");
404        assert_eq!(m.name.as_deref(), Some("my-wrapper"));
405        assert!(m.parser_command.is_none());
406    }
407
408    #[test]
409    fn manifest_parse_defaults() {
410        let dir = tempfile::tempdir().unwrap();
411        let root = dir.path();
412        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
413        std::fs::create_dir_all(&agent_dir).unwrap();
414        std::fs::write(agent_dir.join("manifest.toml"), "[wrapper]\n").unwrap();
415
416        let m = parse_manifest(root, "my-wrapper").unwrap().unwrap();
417        assert_eq!(m.contract_version, 1);
418        assert_eq!(m.parser, "canonical");
419        assert!(m.parser_command.is_none());
420    }
421
422    #[test]
423    fn manifest_parse_invalid_toml() {
424        let dir = tempfile::tempdir().unwrap();
425        let root = dir.path();
426        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
427        std::fs::create_dir_all(&agent_dir).unwrap();
428        std::fs::write(agent_dir.join("manifest.toml"), "[[[\nbad toml\n").unwrap();
429
430        assert!(parse_manifest(root, "my-wrapper").is_err(), "expected parse error");
431    }
432
433    #[test]
434    fn manifest_missing() {
435        let dir = tempfile::tempdir().unwrap();
436        let root = dir.path();
437        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
438        std::fs::create_dir_all(&agent_dir).unwrap();
439        // No manifest.toml
440
441        assert!(parse_manifest(root, "my-wrapper").unwrap().is_none());
442    }
443
444    #[test]
445    fn manifest_unknown_keys_detected() {
446        let dir = tempfile::tempdir().unwrap();
447        let root = dir.path();
448        let agent_dir = root.join(".apm").join("agents").join("my-wrapper");
449        std::fs::create_dir_all(&agent_dir).unwrap();
450        std::fs::write(agent_dir.join("manifest.toml"),
451            "[wrapper]\ncontract_version = 1\nunknown_key = \"foo\"\n"
452        ).unwrap();
453
454        let unknown = manifest_unknown_keys(root, "my-wrapper").unwrap();
455        assert!(unknown.contains(&"unknown_key".to_string()),
456            "expected unknown_key in {unknown:?}");
457    }
458
459    // --- check_contract_version unit tests ---
460
461    #[test]
462    fn check_version_equal() {
463        let log_dir = tempfile::tempdir().unwrap();
464        let log_path = log_dir.path().join("worker.log");
465        assert!(check_contract_version(1, 1, &log_path).is_ok());
466        // No log file created for equal versions
467        assert!(!log_path.exists() || std::fs::read_to_string(&log_path).unwrap().is_empty());
468    }
469
470    #[test]
471    fn check_version_older_writes_warning() {
472        let log_dir = tempfile::tempdir().unwrap();
473        let log_path = log_dir.path().join("worker.log");
474        // declared=1 is older than apm_version=2 → warning, Ok
475        let result = check_contract_version(1, 2, &log_path);
476        assert!(result.is_ok(), "expected Ok for older version");
477        let content = std::fs::read_to_string(&log_path).unwrap_or_default();
478        assert!(content.contains("warning"), "log must contain 'warning': {content}");
479        assert!(content.contains('1'), "log must contain declared version 1: {content}");
480        assert!(content.contains('2'), "log must contain apm version 2: {content}");
481    }
482
483    #[test]
484    fn check_version_too_high_returns_err() {
485        let log_dir = tempfile::tempdir().unwrap();
486        let log_path = log_dir.path().join("worker.log");
487        let result = check_contract_version(2, 1, &log_path);
488        assert!(result.is_err(), "expected Err for version > apm");
489        let msg = result.unwrap_err().to_string();
490        assert!(msg.contains("upgrade APM"), "error must mention 'upgrade APM': {msg}");
491        assert!(msg.contains('2'), "error must mention declared version 2: {msg}");
492        assert!(msg.contains('1'), "error must mention apm version 1: {msg}");
493    }
494
495    #[test]
496    fn default_contract_version_tracks_apm_version() {
497        // Ensures that bumping CONTRACT_VERSION also updates the manifest serde
498        // default, so older manifests don't silently parse with a stale version.
499        assert_eq!(default_contract_version(), CONTRACT_VERSION);
500    }
501
502    // --- ParserStrategy tests ---
503
504    #[test]
505    fn parser_strategy_defaults_to_canonical() {
506        assert_eq!(ParserStrategy::from_manifest(None), ParserStrategy::Canonical);
507    }
508
509    #[test]
510    fn parser_strategy_explicit_canonical() {
511        let m = Manifest {
512            name: None,
513            contract_version: 1,
514            parser: "canonical".to_string(),
515            parser_command: None,
516        };
517        assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::Canonical);
518    }
519
520    #[test]
521    fn parser_strategy_external() {
522        let m = Manifest {
523            name: None,
524            contract_version: 1,
525            parser: "external".to_string(),
526            parser_command: Some("my-parser".to_string()),
527        };
528        assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::External);
529    }
530
531    #[test]
532    fn parser_strategy_unknown_falls_back_to_canonical() {
533        let m = Manifest {
534            name: None,
535            contract_version: 1,
536            parser: "foobar".to_string(),
537            parser_command: None,
538        };
539        assert_eq!(ParserStrategy::from_manifest(Some(&m)), ParserStrategy::Canonical);
540    }
541
542    #[test]
543    fn spawn_external_missing_parser_command() {
544        use std::os::unix::fs::PermissionsExt;
545
546        let wt = tempfile::tempdir().unwrap();
547        let log_dir = tempfile::tempdir().unwrap();
548        let log_path = log_dir.path().join("worker.log");
549
550        let script = wt.path().join("wrapper.sh");
551        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
552        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
553
554        let manifest = Manifest {
555            name: None,
556            contract_version: 1,
557            parser: "external".to_string(),
558            parser_command: None,
559        };
560        let wrapper = CustomWrapper {
561            script_path: script,
562            manifest: Some(manifest),
563        };
564
565        let ctx = make_ctx(wt.path(), &log_path);
566        let err = wrapper.spawn(&ctx).unwrap_err();
567        let msg = err.to_string();
568        assert!(msg.contains("parser_command"), "error must mention parser_command: {msg}");
569        assert!(msg.contains("not set"), "error must mention 'not set': {msg}");
570    }
571
572    #[test]
573    fn spawn_external_binary_not_found() {
574        use std::os::unix::fs::PermissionsExt;
575
576        let wt = tempfile::tempdir().unwrap();
577        let log_dir = tempfile::tempdir().unwrap();
578        let log_path = log_dir.path().join("worker.log");
579
580        let script = wt.path().join("wrapper.sh");
581        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
582        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
583
584        let manifest = Manifest {
585            name: None,
586            contract_version: 1,
587            parser: "external".to_string(),
588            parser_command: Some("nonexistent-binary-xyzzy-2803".to_string()),
589        };
590        let wrapper = CustomWrapper {
591            script_path: script,
592            manifest: Some(manifest),
593        };
594
595        let ctx = make_ctx(wt.path(), &log_path);
596        let err = wrapper.spawn(&ctx).unwrap_err();
597        let msg = err.to_string();
598        assert!(
599            msg.contains("nonexistent-binary-xyzzy-2803"),
600            "error must name the missing binary: {msg}"
601        );
602    }
603
604    #[test]
605    fn spawn_rejects_contract_version_gt_1() {
606        use std::os::unix::fs::PermissionsExt;
607
608        let wt = tempfile::tempdir().unwrap();
609        let log_dir = tempfile::tempdir().unwrap();
610
611        // Create a script (won't be reached due to early bail)
612        let script = wt.path().join("wrapper.sh");
613        std::fs::write(&script, "#!/bin/sh\nexit 0\n").unwrap();
614        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
615
616        let manifest = Manifest {
617            name: None,
618            contract_version: 2,
619            parser: "canonical".to_string(),
620            parser_command: None,
621        };
622
623        let wrapper = CustomWrapper {
624            script_path: script,
625            manifest: Some(manifest),
626        };
627
628        let ctx = make_ctx(wt.path(), &log_dir.path().join("worker.log"));
629        let err = wrapper.spawn(&ctx).unwrap_err();
630        let msg = err.to_string();
631        assert!(msg.contains("upgrade APM"),
632            "error message must mention 'upgrade APM': {msg}");
633    }
634}