Skip to main content

track/services/
agent_context.rs

1use crate::models::{
2    build_git_context, build_jj_context, build_workflow_context, oldest_pending_todo,
3    workspace_lifecycle, AgentGuardrails, GitAgentContext, JjAgentContext, Task, TaskRepo, Todo,
4    TodoAction, TodoAgentView, TodoStatus, VcsMode, WorkflowContext, WorkspaceAgentView, Worktree,
5};
6use crate::services::WorktreeService;
7use serde::Serialize;
8
9/// Agent-oriented fields shared by `track status --json` and `/api/status`.
10#[derive(Debug, Clone, Serialize)]
11pub struct AgentStatusExtensions {
12    pub vcs_mode: VcsMode,
13    pub workflow: WorkflowContext,
14    #[serde(skip_serializing_if = "Option::is_none")]
15    pub jj: Option<JjAgentContext>,
16    #[serde(skip_serializing_if = "Option::is_none")]
17    pub git: Option<GitAgentContext>,
18    pub todos_agent: Vec<TodoAgentView>,
19    pub guardrails: AgentGuardrails,
20}
21
22/// Builds agent-oriented JSON extensions for status endpoints.
23pub fn build_agent_extensions(
24    vcs_mode: VcsMode,
25    task: &Task,
26    todos: &[Todo],
27    worktrees: &[Worktree],
28    repos: &[TaskRepo],
29    worktree_service: &WorktreeService<'_>,
30) -> AgentStatusExtensions {
31    let workflow = build_workflow_context(vcs_mode, task, todos, worktrees, repos);
32    let next_todo_id = oldest_pending_todo(todos).map(|todo| todo.id);
33    let legacy_merge_required = vcs_mode == VcsMode::Jj
34        && todos
35            .iter()
36            .any(|todo| todo.status == TodoStatus::Pending && todo.worktree_requested);
37
38    let git_ctx = build_git_context(task, repos);
39    let git_worktree_path = git_ctx.workspace_path.clone();
40
41    let todos_agent: Vec<TodoAgentView> = todos
42        .iter()
43        .map(|todo| {
44            let lifecycle = workspace_lifecycle(todo, worktrees);
45            let bookmark = if todo.worktree_requested {
46                worktree_service
47                    .get_todo_branch_name(task.id, task.ticket_id.as_deref(), todo.task_index)
48                    .ok()
49            } else {
50                None
51            };
52            let path = worktrees
53                .iter()
54                .find(|wt| wt.todo_id == Some(todo.id))
55                .map(|wt| wt.path.clone())
56                .or_else(|| {
57                    if vcs_mode == VcsMode::Git && todo.status == TodoStatus::Pending {
58                        git_worktree_path.clone()
59                    } else {
60                        None
61                    }
62                });
63
64            TodoAgentView {
65                todo_id: todo.task_index,
66                content: todo.content.clone(),
67                status: todo.status,
68                is_next: next_todo_id == Some(todo.id),
69                allowed_actions: TodoAction::allowed_for(todo)
70                    .into_iter()
71                    .map(|action| action.as_str().to_string())
72                    .chain(if todo.status == TodoStatus::Pending {
73                        vec!["delete".to_string()]
74                    } else {
75                        Vec::new()
76                    })
77                    .collect(),
78                workspace: WorkspaceAgentView {
79                    lifecycle,
80                    path,
81                    bookmark,
82                },
83            }
84        })
85        .collect();
86
87    let guardrails = AgentGuardrails::for_mode(vcs_mode, legacy_merge_required);
88
89    let (jj, git) = match vcs_mode {
90        VcsMode::Jj => (Some(build_jj_context(task, repos)), None),
91        VcsMode::Git => (None, Some(git_ctx)),
92    };
93
94    AgentStatusExtensions {
95        vcs_mode,
96        workflow,
97        jj,
98        git,
99        todos_agent,
100        guardrails,
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107    use crate::db::Database;
108    use crate::models::WorkflowPhase;
109    use crate::services::{TaskService, TodoService};
110
111    #[test]
112    fn agent_extensions_include_workflow_and_jj() {
113        let db = Database::new_in_memory().unwrap();
114        let task = TaskService::new(&db)
115            .create_task("Task", None, None, None)
116            .unwrap();
117        let todo_service = TodoService::new(&db);
118        todo_service.add_todo(task.id, "Work", false).unwrap();
119
120        let todos = todo_service.list_todos(task.id).unwrap();
121        let worktree_service = WorktreeService::new(&db);
122        let extensions =
123            build_agent_extensions(VcsMode::Jj, &task, &todos, &[], &[], &worktree_service);
124
125        assert_eq!(extensions.workflow.phase, WorkflowPhase::Setup);
126        assert_eq!(extensions.vcs_mode, VcsMode::Jj);
127        assert_eq!(extensions.jj.as_ref().unwrap().skill, "jj");
128        assert!(extensions.git.is_none());
129        assert!(extensions.guardrails.must_use_jj_skill);
130        assert!(extensions.guardrails.reopen_forbidden);
131    }
132
133    #[test]
134    fn agent_extensions_use_git_context_in_git_mode() {
135        let db = Database::new_in_memory().unwrap();
136        db.set_vcs_mode(VcsMode::Git).unwrap();
137        let task = TaskService::new(&db)
138            .create_task("Task", None, None, None)
139            .unwrap();
140        let todo_service = TodoService::new(&db);
141        todo_service.add_todo(task.id, "Work", false).unwrap();
142
143        let todos = todo_service.list_todos(task.id).unwrap();
144        let worktree_service = WorktreeService::new(&db);
145        let extensions = build_agent_extensions(
146            db.get_vcs_mode().unwrap(),
147            &task,
148            &todos,
149            &[],
150            &[],
151            &worktree_service,
152        );
153
154        assert_eq!(extensions.vcs_mode, VcsMode::Git);
155        assert!(extensions.jj.is_none());
156        assert_eq!(extensions.git.as_ref().unwrap().branch, "track/task-1");
157        assert!(!extensions.guardrails.must_use_jj_skill);
158    }
159}