Skip to main content

team_core/
render.rs

1//! Render a loaded compose into on-disk artifacts.
2//!
3//! Outputs under `<root>/state/`:
4//! - `envs/<project>-<agent>.env`      — env vars for the agent wrapper.
5//! - `mcp/<project>-<agent>.json`      — MCP stdio config for the runtime.
6//! - `role_prompts/<project>-<agent>.md` (multi-file role_prompt only) —
7//!   the ordered concatenation of every source file declared in the
8//!   role's `role_prompt: [...]` list. Re-materialized on every render
9//!   so any source-file edit lands in the agent's prompt at next boot.
10//!
11//! `systemd` / `launchd` unit rendering lives behind a feature flag when
12//! those back-ends are enabled via `supervisor.type`.
13
14use std::io;
15use std::path::{Path, PathBuf};
16
17use crate::compose::{AgentHandle, Compose, RolePrompt};
18
19/// Separator written between concatenated role-prompt files. Em-dash
20/// framed by blank lines reads cleanly when an operator inspects the
21/// materialized file under `state/role_prompts/`.
22const ROLE_PROMPT_SEPARATOR: &str = "\n\n—\n\n";
23
24/// Absolute path to the rendered env file for a given agent.
25pub fn env_path(root: &Path, project: &str, agent: &str) -> PathBuf {
26    root.join("state/envs")
27        .join(format!("{project}-{agent}.env"))
28}
29
30/// Absolute path to the rendered MCP config for a given agent.
31pub fn mcp_path(root: &Path, project: &str, agent: &str) -> PathBuf {
32    root.join("state/mcp")
33        .join(format!("{project}-{agent}.json"))
34}
35
36/// Absolute path to the materialized concatenation of a multi-file
37/// `role_prompt` list. Only ever written for the list form — single-file
38/// `role_prompt` keeps pointing at its source path directly.
39pub fn role_prompt_concat_path(root: &Path, project: &str, agent: &str) -> PathBuf {
40    root.join("state/role_prompts")
41        .join(format!("{project}-{agent}.md"))
42}
43
44/// Rendered env + MCP content for a single agent.
45pub fn render_agent(
46    compose: &Compose,
47    handle: AgentHandle<'_>,
48    team_mcp_bin: &str,
49) -> (String, String) {
50    let env = render_env(compose, handle);
51    let mcp = render_mcp(compose, handle, team_mcp_bin);
52    (env, mcp)
53}
54
55fn render_env(compose: &Compose, h: AgentHandle<'_>) -> String {
56    let project = compose
57        .projects
58        .iter()
59        .find(|p| p.project.id == h.project)
60        .expect("agent belongs to a loaded project");
61    let mailbox = compose.root.join(&compose.global.broker.path);
62    let mcp = mcp_path(&compose.root, h.project, h.agent);
63    let prompt = system_prompt_path(compose, h)
64        .map(|p| p.display().to_string())
65        .unwrap_or_default();
66
67    let mut s = String::new();
68    s.push_str(&format!("AGENT_ID={}:{}\n", h.project, h.agent));
69    s.push_str(&format!("PROJECT_ID={}\n", h.project));
70    s.push_str(&format!("RUNTIME={}\n", h.spec.runtime));
71    if let Some(m) = &h.spec.model {
72        s.push_str(&format!("MODEL={m}\n"));
73    }
74    if let Some(pm) = &h.spec.permission_mode {
75        s.push_str(&format!("PERMISSION_MODE={pm}\n"));
76    }
77    // T-048: per-agent reasoning effort flows through to the runtime
78    // via the wrapper. Workspace-level `.env` `EFFORT=` still wins for
79    // operators not yet on the YAML form (back-compat).
80    if let Some(effort) = h.spec.effort {
81        s.push_str(&format!("EFFORT={}\n", effort.as_str()));
82    }
83    s.push_str(&format!("TEAMCTL_MAILBOX={}\n", mailbox.display()));
84    s.push_str(&format!("MCP_CONFIG={}\n", mcp.display()));
85    s.push_str(&format!("SYSTEM_PROMPT_PATH={prompt}\n"));
86    s.push_str(&format!(
87        "CLAUDE_PROJECT_DIR={}\n",
88        project.project.cwd.display()
89    ));
90    // Absolute path to the compose root (the directory holding
91    // `team-compose.yaml`). The wrapper passes this to `teamctl --root`
92    // so rl-watch resolves the right tree regardless of where
93    // `cd "$CLAUDE_PROJECT_DIR"` lands the shell. Without this,
94    // wrapper falls back to CLAUDE_PROJECT_DIR (often a relative `..`)
95    // which compounds with the post-cd cwd and points at the wrong
96    // directory.
97    s.push_str(&format!("TEAMCTL_ROOT={}\n", compose.root.display()));
98    s.push_str(&format!(
99        "TMUX_SESSION={}{}-{}\n",
100        compose.global.supervisor.tmux_prefix, h.project, h.agent
101    ));
102    // T-118: claude-code agents resume their conversation across
103    // teamctl down/up + crash recovery via a deterministic UUIDv5
104    // session id. Other runtimes don't recognize `--session-id`, so
105    // emit these env vars only for `claude-code` — the wrapper's
106    // claude-code arm picks them up; other arms ignore them.
107    if h.spec.runtime == "claude-code" {
108        let session_id = crate::session::derive_session_id(h.project, h.agent);
109        let session_name = crate::session::session_name(h.project, h.agent);
110        s.push_str(&format!("CLAUDE_SESSION_ID={session_id}\n"));
111        s.push_str(&format!("CLAUDE_SESSION_NAME={session_name}\n"));
112    }
113    s
114}
115
116/// Resolve the absolute path that `SYSTEM_PROMPT_PATH` will point at.
117///
118/// - `None` role_prompt → `None` (env line renders as blank).
119/// - Single source file → `<root>/<source>` (back-compat, no concat
120///   file is written — the operator's source is the prompt).
121/// - List form → the materialized concat path under
122///   `<root>/state/role_prompts/<project>-<agent>.md`. The file at that
123///   path is produced by [`write_role_prompt_concat`]; this helper is
124///   pure and only computes the destination.
125pub fn system_prompt_path(compose: &Compose, h: AgentHandle<'_>) -> Option<PathBuf> {
126    match h.spec.role_prompt.as_ref()? {
127        RolePrompt::Single(p) => Some(compose.root.join(p)),
128        RolePrompt::Multiple(_) => Some(role_prompt_concat_path(&compose.root, h.project, h.agent)),
129    }
130}
131
132/// Materialize the multi-file `role_prompt` concatenation for one agent.
133///
134/// No-op when `role_prompt` is `None` or `Single` — there is nothing to
135/// concatenate. For the list form, every source file is read in declared
136/// order and joined with [`ROLE_PROMPT_SEPARATOR`]; the result overwrites
137/// `<root>/state/role_prompts/<project>-<agent>.md` so subsequent edits
138/// to any source file flow into the agent's prompt at the next render.
139///
140/// Missing source files surface as the underlying `io::Error` so the
141/// caller can fail the apply rather than silently emit a partial concat.
142pub fn write_role_prompt_concat(compose: &Compose, h: AgentHandle<'_>) -> io::Result<()> {
143    let Some(RolePrompt::Multiple(paths)) = h.spec.role_prompt.as_ref() else {
144        return Ok(());
145    };
146
147    let mut buf = String::new();
148    for (idx, rel) in paths.iter().enumerate() {
149        if idx > 0 {
150            buf.push_str(ROLE_PROMPT_SEPARATOR);
151        }
152        let abs = compose.root.join(rel);
153        let bytes = std::fs::read(&abs).map_err(|e| {
154            io::Error::new(
155                e.kind(),
156                format!("read role_prompt source {}: {e}", abs.display()),
157            )
158        })?;
159        // Source files are expected to be UTF-8 markdown; lossy decode
160        // keeps render diagnostics readable if a stray byte sneaks in.
161        buf.push_str(&String::from_utf8_lossy(&bytes));
162    }
163
164    let dest = role_prompt_concat_path(&compose.root, h.project, h.agent);
165    if let Some(parent) = dest.parent() {
166        std::fs::create_dir_all(parent)?;
167    }
168    std::fs::write(&dest, buf)
169}
170
171fn render_mcp(compose: &Compose, h: AgentHandle<'_>, team_mcp_bin: &str) -> String {
172    let mailbox = compose.root.join(&compose.global.broker.path);
173    let v = serde_json::json!({
174        "mcpServers": {
175            "team": {
176                "command": team_mcp_bin,
177                "args": [
178                    "--agent-id", format!("{}:{}", h.project, h.agent),
179                    "--mailbox", mailbox.display().to_string(),
180                    // T-109: compact_self resolves the caller's tmux pane
181                    // as `<prefix><project>-<agent>`. Pass the configured
182                    // prefix explicitly so teams overriding the default
183                    // (`a-`, `oss-`, …) route the slash command to the
184                    // right session. team-bot gets the same arg threaded
185                    // from `teamctl bot up`; this keeps the two MCP-side
186                    // and bot-side resolvers in sync.
187                    "--tmux-prefix", compose.global.supervisor.tmux_prefix.clone(),
188                    // T-32b: compose root used by `read_attachment`
189                    // for `attachments:` policy + tempfile staging.
190                    // Always passed so the per-agent team-mcp can
191                    // serve attachment reads; the staging dir is
192                    // computed under this root.
193                    "--compose-root", compose.root.display().to_string(),
194                ],
195                "env": {}
196            }
197        }
198    });
199    serde_json::to_string_pretty(&v).expect("json")
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::compose::*;
206    use std::collections::BTreeMap;
207    use std::path::PathBuf;
208
209    fn fixture() -> Compose {
210        let mut managers = BTreeMap::new();
211        managers.insert(
212            "mgr".into(),
213            Agent {
214                runtime: "claude-code".into(),
215                model: Some("claude-opus-4-7".into()),
216                role_prompt: Some(RolePrompt::Single(PathBuf::from("roles/mgr.md"))),
217                permission_mode: Some("auto".into()),
218                autonomy: "low_risk_only".into(),
219                can_dm: vec![],
220                can_broadcast: vec![],
221                reports_to: None,
222                on_rate_limit: None,
223                effort: None,
224                interfaces: None,
225                display_name: None,
226            },
227        );
228        Compose {
229            root: PathBuf::from("/teamctl"),
230            global: Global {
231                version: 2,
232                broker: Broker {
233                    r#type: "sqlite".into(),
234                    path: PathBuf::from("state/mailbox.db"),
235                },
236                supervisor: SupervisorCfg {
237                    r#type: "tmux".into(),
238                    tmux_prefix: "a-".into(),
239                    drain_timeout_secs: 10,
240                },
241                budget: Default::default(),
242                hitl: Default::default(),
243                rate_limits: Default::default(),
244                interfaces: vec![],
245                projects: vec![],
246                attachments: Default::default(),
247            },
248            projects: vec![Project {
249                version: 2,
250                project: ProjectMeta {
251                    id: "hello".into(),
252                    name: "Hello".into(),
253                    cwd: PathBuf::from("/teamctl/examples/hello-team"),
254                },
255                channels: vec![],
256                managers,
257                workers: Default::default(),
258            }],
259        }
260    }
261
262    #[test]
263    fn env_contains_agent_id_and_mailbox() {
264        let c = fixture();
265        let h = c.agents().next().unwrap();
266        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
267        assert!(env.contains("AGENT_ID=hello:mgr"));
268        assert!(env.contains("TEAMCTL_MAILBOX=/teamctl/state/mailbox.db"));
269        assert!(env.contains("TMUX_SESSION=a-hello-mgr"));
270    }
271
272    #[test]
273    fn env_emits_claude_session_id_and_name_for_claude_code_runtime() {
274        // T-118: claude-code agents get deterministic UUIDv5 session
275        // ids in their env so the wrapper can pass `--session-id` +
276        // `-n` and resume the conversation across restarts.
277        let c = fixture();
278        let h = c.agents().next().unwrap();
279        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
280        let expected_id = crate::session::derive_session_id(h.project, h.agent);
281        assert!(
282            env.contains(&format!("CLAUDE_SESSION_ID={expected_id}\n")),
283            "env was: {env}"
284        );
285        assert!(
286            env.contains("CLAUDE_SESSION_NAME=teamctl:hello:mgr\n"),
287            "env was: {env}"
288        );
289    }
290
291    #[test]
292    fn env_omits_claude_session_vars_for_non_claude_runtimes() {
293        // Other runtimes (codex, gemini) don't recognize claude's
294        // `--session-id` flag — their wrapper arms must not see these
295        // vars. Pin the gate so a future render refactor can't leak
296        // them into every runtime.
297        let mut c = fixture();
298        c.projects[0].managers.get_mut("mgr").unwrap().runtime = "codex".into();
299        let h = c.agents().next().unwrap();
300        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
301        assert!(
302            !env.contains("CLAUDE_SESSION_ID="),
303            "non-claude runtime must not get session id: {env}"
304        );
305        assert!(
306            !env.contains("CLAUDE_SESSION_NAME="),
307            "non-claude runtime must not get session name: {env}"
308        );
309    }
310
311    #[test]
312    fn env_pins_teamctl_root_to_compose_root() {
313        // Regression: when project.cwd is a relative path (e.g. `..`),
314        // the wrapper used to fall back to it for `--root`, which
315        // resolves against the post-cd cwd and points at the wrong
316        // directory. Rendering an absolute TEAMCTL_ROOT pins
317        // `teamctl --root` to the compose root regardless of cwd.
318        let c = fixture();
319        let h = c.agents().next().unwrap();
320        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
321        assert!(env.contains("TEAMCTL_ROOT=/teamctl\n"), "env was: {env}");
322    }
323
324    #[test]
325    fn env_omits_effort_when_unset() {
326        let c = fixture();
327        let h = c.agents().next().unwrap();
328        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
329        assert!(!env.contains("EFFORT="), "env was: {env}");
330    }
331
332    #[test]
333    fn env_emits_effort_when_set() {
334        let mut c = fixture();
335        c.projects[0].managers.get_mut("mgr").unwrap().effort = Some(EffortLevel::Max);
336        let h = c.agents().next().unwrap();
337        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
338        assert!(env.contains("EFFORT=max\n"), "env was: {env}");
339    }
340
341    #[test]
342    fn mcp_json_parses_back() {
343        let c = fixture();
344        let h = c.agents().next().unwrap();
345        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
346        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
347        assert_eq!(
348            v["mcpServers"]["team"]["command"],
349            "/usr/local/bin/team-mcp"
350        );
351        assert_eq!(
352            v["mcpServers"]["team"]["args"][1].as_str().unwrap(),
353            "hello:mgr"
354        );
355    }
356
357    #[test]
358    fn mcp_json_threads_tmux_prefix_from_compose() {
359        // T-109: compact_self routes its tmux send-keys to
360        // `<prefix><project>-<agent>` and reads the prefix from a CLI arg
361        // (default `t-` only fits a stock team). Render must surface the
362        // configured prefix so teams overriding it (e.g. `a-` here) get
363        // their pane resolved correctly.
364        let c = fixture();
365        let h = c.agents().next().unwrap();
366        let (_, mcp) = render_agent(&c, h, "/usr/local/bin/team-mcp");
367        let v: serde_json::Value = serde_json::from_str(&mcp).unwrap();
368        let args: Vec<&str> = v["mcpServers"]["team"]["args"]
369            .as_array()
370            .unwrap()
371            .iter()
372            .map(|a| a.as_str().unwrap())
373            .collect();
374        let i = args.iter().position(|a| *a == "--tmux-prefix").expect(
375            "render_mcp must emit --tmux-prefix so compact_self resolves the caller's pane",
376        );
377        assert_eq!(
378            args[i + 1],
379            "a-",
380            "prefix must come from compose, not the default"
381        );
382    }
383
384    #[test]
385    fn env_points_at_source_for_single_role_prompt() {
386        let c = fixture();
387        let h = c.agents().next().unwrap();
388        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
389        assert!(
390            env.contains("SYSTEM_PROMPT_PATH=/teamctl/roles/mgr.md\n"),
391            "env was: {env}"
392        );
393    }
394
395    #[test]
396    fn env_points_at_concat_path_for_multi_role_prompt() {
397        let mut c = fixture();
398        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
399            Some(RolePrompt::Multiple(vec![
400                PathBuf::from("roles/_base.md"),
401                PathBuf::from("roles/mgr.md"),
402            ]));
403        let h = c.agents().next().unwrap();
404        let (env, _) = render_agent(&c, h, "/usr/local/bin/team-mcp");
405        assert!(
406            env.contains("SYSTEM_PROMPT_PATH=/teamctl/state/role_prompts/hello-mgr.md\n"),
407            "env was: {env}"
408        );
409    }
410
411    #[test]
412    fn write_role_prompt_concat_is_noop_for_single() {
413        let dir = tempfile::tempdir().unwrap();
414        let mut c = fixture();
415        c.root = dir.path().to_path_buf();
416        let h = c.agents().next().unwrap();
417        write_role_prompt_concat(&c, h).unwrap();
418        assert!(
419            !role_prompt_concat_path(&c.root, h.project, h.agent).exists(),
420            "single-form role_prompt should not produce a concat file"
421        );
422    }
423
424    #[test]
425    fn write_role_prompt_concat_joins_in_declared_order() {
426        let dir = tempfile::tempdir().unwrap();
427        let root = dir.path();
428        std::fs::create_dir_all(root.join("roles")).unwrap();
429        std::fs::write(root.join("roles/_base.md"), "BASE").unwrap();
430        std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
431
432        let mut c = fixture();
433        c.root = root.to_path_buf();
434        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
435            Some(RolePrompt::Multiple(vec![
436                PathBuf::from("roles/_base.md"),
437                PathBuf::from("roles/mgr.md"),
438            ]));
439        let h = c.agents().next().unwrap();
440        write_role_prompt_concat(&c, h).unwrap();
441
442        let dest = role_prompt_concat_path(root, h.project, h.agent);
443        let got = std::fs::read_to_string(&dest).unwrap();
444        assert_eq!(got, "BASE\n\n—\n\nMGR");
445    }
446
447    #[test]
448    fn write_role_prompt_concat_reflects_source_edits() {
449        // Owner-flagged: editing a source file must show up at the next
450        // render. We re-write unconditionally rather than caching.
451        let dir = tempfile::tempdir().unwrap();
452        let root = dir.path();
453        std::fs::create_dir_all(root.join("roles")).unwrap();
454        std::fs::write(root.join("roles/_base.md"), "v1").unwrap();
455        std::fs::write(root.join("roles/mgr.md"), "MGR").unwrap();
456
457        let mut c = fixture();
458        c.root = root.to_path_buf();
459        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt =
460            Some(RolePrompt::Multiple(vec![
461                PathBuf::from("roles/_base.md"),
462                PathBuf::from("roles/mgr.md"),
463            ]));
464        let h = c.agents().next().unwrap();
465        write_role_prompt_concat(&c, h).unwrap();
466
467        std::fs::write(root.join("roles/_base.md"), "v2").unwrap();
468        let h = c.agents().next().unwrap();
469        write_role_prompt_concat(&c, h).unwrap();
470
471        let dest = role_prompt_concat_path(root, h.project, h.agent);
472        let got = std::fs::read_to_string(&dest).unwrap();
473        assert_eq!(got, "v2\n\n—\n\nMGR");
474    }
475
476    #[test]
477    fn write_role_prompt_concat_errors_on_missing_source() {
478        let dir = tempfile::tempdir().unwrap();
479        let mut c = fixture();
480        c.root = dir.path().to_path_buf();
481        c.projects[0].managers.get_mut("mgr").unwrap().role_prompt = Some(RolePrompt::Multiple(
482            vec![PathBuf::from("roles/missing.md")],
483        ));
484        let h = c.agents().next().unwrap();
485        let err = write_role_prompt_concat(&c, h).unwrap_err();
486        assert!(err.to_string().contains("missing.md"), "err was: {err}");
487    }
488}