Skip to main content

track/models/
workflow.rs

1use crate::models::jj::jj_slug;
2use crate::models::{Task, TaskRepo, TaskStatus, Todo, TodoStatus, VcsMode, Worktree};
3use crate::services::{git_worktree, jj_task};
4use serde::Serialize;
5
6/// High-level workflow phase for agents and humans.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
8#[serde(rename_all = "snake_case")]
9pub enum WorkflowPhase {
10    Setup,
11    SyncRequired,
12    Execute,
13    TaskComplete,
14    Archived,
15}
16
17/// Suggested next step for an agent.
18#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
19pub struct NextAction {
20    pub kind: &'static str,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub command: Option<String>,
23    pub reason: String,
24}
25
26/// Derived workflow context (not persisted).
27#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
28pub struct WorkflowContext {
29    pub phase: WorkflowPhase,
30    pub next_action: NextAction,
31}
32
33/// Lifecycle of a TODO's JJ workspace.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum WorkspaceLifecycle {
37    NotRequested,
38    Requested,
39    Ready,
40    Merged,
41}
42
43/// Agent-oriented view of a TODO item.
44#[derive(Debug, Clone, Serialize)]
45pub struct TodoAgentView {
46    pub todo_id: i64,
47    pub content: String,
48    pub status: TodoStatus,
49    pub is_next: bool,
50    pub allowed_actions: Vec<String>,
51    pub workspace: WorkspaceAgentView,
52}
53
54#[derive(Debug, Clone, Serialize)]
55pub struct WorkspaceAgentView {
56    pub lifecycle: WorkspaceLifecycle,
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub path: Option<String>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub bookmark: Option<String>,
61}
62
63/// jj-task / agent-skill-jj context for the current task.
64#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
65pub struct JjAgentContext {
66    pub slug: String,
67    pub skill: &'static str,
68    pub workspace_registered: bool,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub workspace_path: Option<String>,
71    pub start_command: String,
72    pub path_command: String,
73    pub repo_init_command: &'static str,
74}
75
76/// Git worktree context for the current task.
77#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
78pub struct GitAgentContext {
79    pub slug: String,
80    pub branch: String,
81    pub workspace_ready: bool,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub workspace_path: Option<String>,
84    pub sync_command: String,
85}
86
87/// Guardrails exposed to agents via JSON status output.
88#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
89pub struct AgentGuardrails {
90    /// Commits, squash, push, and PR workflow belong to the `$jj` skill (jj mode only).
91    pub must_use_jj_skill: bool,
92    pub jj_skill_name: &'static str,
93    pub reopen_forbidden: bool,
94    /// True only for legacy per-TODO `--worktree` items managed by track.
95    pub complete_requires_jj_merge: bool,
96}
97
98impl AgentGuardrails {
99    pub fn for_mode(vcs_mode: VcsMode, complete_requires_jj_merge: bool) -> Self {
100        match vcs_mode {
101            VcsMode::Jj => Self {
102                must_use_jj_skill: true,
103                jj_skill_name: "jj",
104                reopen_forbidden: true,
105                complete_requires_jj_merge,
106            },
107            VcsMode::Git => Self {
108                must_use_jj_skill: false,
109                jj_skill_name: "jj",
110                reopen_forbidden: true,
111                complete_requires_jj_merge: false,
112            },
113        }
114    }
115}
116
117impl Default for AgentGuardrails {
118    fn default() -> Self {
119        Self::for_mode(VcsMode::Jj, false)
120    }
121}
122
123fn repo_paths(repos: &[TaskRepo]) -> Vec<String> {
124    repos.iter().map(|repo| repo.repo_path.clone()).collect()
125}
126
127fn legacy_worktree_sync_needed(todos: &[Todo], worktrees: &[Worktree]) -> bool {
128    todos.iter().any(|todo| {
129        todo.status == TodoStatus::Pending
130            && todo.worktree_requested
131            && !worktrees.iter().any(|wt| wt.todo_id == Some(todo.id))
132    })
133}
134
135fn jj_workspace_needed(task: &Task, todos: &[Todo], repos: &[TaskRepo]) -> bool {
136    if repos.is_empty() {
137        return false;
138    }
139    let has_pending = todos.iter().any(|todo| todo.status == TodoStatus::Pending);
140    if !has_pending {
141        return false;
142    }
143    let slug = jj_slug(task);
144    !jj_task::slug_registered(&slug, &repo_paths(repos))
145}
146
147fn git_workspace_needed(task: &Task, todos: &[Todo], repos: &[TaskRepo]) -> bool {
148    if repos.is_empty() {
149        return false;
150    }
151    let has_pending = todos.iter().any(|todo| todo.status == TodoStatus::Pending);
152    if !has_pending {
153        return false;
154    }
155    let slug = jj_slug(task);
156    !repos.iter().any(|repo| {
157        let path = git_worktree::git_worktree_path(&repo.repo_path, &slug);
158        git_worktree::git_worktree_exists(&path)
159    })
160}
161
162/// Computes the workflow phase from current task state.
163pub fn compute_workflow_phase(
164    vcs_mode: VcsMode,
165    task: &Task,
166    todos: &[Todo],
167    worktrees: &[Worktree],
168    repos: &[TaskRepo],
169) -> WorkflowPhase {
170    if task.status == TaskStatus::Archived {
171        return WorkflowPhase::Archived;
172    }
173
174    if repos.is_empty() {
175        return WorkflowPhase::Setup;
176    }
177
178    let sync_needed = match vcs_mode {
179        VcsMode::Jj => {
180            legacy_worktree_sync_needed(todos, worktrees) || jj_workspace_needed(task, todos, repos)
181        }
182        VcsMode::Git => git_workspace_needed(task, todos, repos),
183    };
184
185    if sync_needed {
186        return WorkflowPhase::SyncRequired;
187    }
188
189    if todos.iter().any(|todo| todo.status == TodoStatus::Pending) {
190        return WorkflowPhase::Execute;
191    }
192
193    WorkflowPhase::TaskComplete
194}
195
196/// Builds the suggested next action for the current workflow phase.
197pub fn build_next_action(
198    vcs_mode: VcsMode,
199    phase: WorkflowPhase,
200    task: &Task,
201    todos: &[Todo],
202    worktrees: &[Worktree],
203    repos: &[TaskRepo],
204) -> NextAction {
205    let slug = jj_slug(task);
206    let paths = repo_paths(repos);
207
208    match phase {
209        WorkflowPhase::Setup => NextAction {
210            kind: "run_command",
211            command: Some("track repo add [path]".to_string()),
212            reason: match vcs_mode {
213                VcsMode::Jj => {
214                    "Register at least one repository, then run jj-task repo init from the main workspace".to_string()
215                }
216                VcsMode::Git => {
217                    "Register at least one git repository, then run track sync to create a worktree".to_string()
218                }
219            },
220        },
221        WorkflowPhase::SyncRequired => match vcs_mode {
222            VcsMode::Jj => {
223                if legacy_worktree_sync_needed(todos, worktrees) {
224                    NextAction {
225                        kind: "run_command",
226                        command: Some("track sync".to_string()),
227                        reason: "Legacy per-TODO --worktree workspaces are pending (prefer jj-task for new tasks)".to_string(),
228                    }
229                } else {
230                    NextAction {
231                        kind: "run_command",
232                        command: Some(format!("jj-task start {slug}")),
233                        reason: format!(
234                            "Start a jj-task workspace at .worktrees/{slug}. Run jj-task repo init once from the main repo if needed. Load the $jj skill for commits and PR."
235                        ),
236                    }
237                }
238            }
239            VcsMode::Git => NextAction {
240                kind: "run_command",
241                command: Some("track sync".to_string()),
242                reason: format!(
243                    "Create git worktree at .worktrees/{slug} on branch track/{slug}"
244                ),
245            },
246        },
247        WorkflowPhase::Execute => {
248            let next_todo = oldest_pending_todo(todos);
249            if let Some(todo) = next_todo {
250                match vcs_mode {
251                    VcsMode::Jj => {
252                        if jj_task::slug_registered(&slug, &paths) {
253                            return NextAction {
254                                kind: "run_command",
255                                command: Some(format!("cd \"$(jj-task path {slug})\"")),
256                                reason: format!(
257                                    "Work on TODO #{} in jj-task workspace. Use $jj skill for jj commit/squash/push — not jj describe alone.",
258                                    todo.task_index
259                                ),
260                            };
261                        }
262
263                        let has_workspace = worktrees.iter().any(|wt| wt.todo_id == Some(todo.id));
264                        if todo.worktree_requested && has_workspace {
265                            return NextAction {
266                                kind: "run_command",
267                                command: Some(format!("track todo workspace {}", todo.task_index)),
268                                reason: format!(
269                                    "Legacy TODO workspace for #{}: {}",
270                                    todo.task_index, todo.content
271                                ),
272                            };
273                        }
274                    }
275                    VcsMode::Git => {
276                        if let Some(repo) = repos.first() {
277                            let worktree_path =
278                                git_worktree::git_worktree_path(&repo.repo_path, &slug);
279                            if git_worktree::git_worktree_exists(&worktree_path) {
280                                return NextAction {
281                                    kind: "run_command",
282                                    command: Some(format!("cd \"{worktree_path}\"")),
283                                    reason: format!(
284                                        "Work on TODO #{} in git worktree. Commit and push with standard git commands.",
285                                        todo.task_index
286                                    ),
287                                };
288                            }
289                        }
290                    }
291                }
292
293                NextAction {
294                    kind: "execute_todo",
295                    command: Some(format!("track todo done {}", todo.task_index)),
296                    reason: format!("Continue with TODO #{}: {}", todo.task_index, todo.content),
297                }
298            } else {
299                NextAction {
300                    kind: "wait_human",
301                    command: None,
302                    reason: "No pending TODOs found".to_string(),
303                }
304            }
305        }
306        WorkflowPhase::TaskComplete => match vcs_mode {
307            VcsMode::Jj => NextAction {
308                kind: "run_command",
309                command: Some(format!("jj-task done {slug}; track archive")),
310                reason: "All TODOs done — use $jj skill to push/merge PR, then jj-task done and track archive".to_string(),
311            },
312            VcsMode::Git => NextAction {
313                kind: "run_command",
314                command: Some("track archive".to_string()),
315                reason: "All TODOs done — push/merge your PR with git, then archive the task".to_string(),
316            },
317        },
318        WorkflowPhase::Archived => NextAction {
319            kind: "wait_human",
320            command: None,
321            reason: "Task is archived".to_string(),
322        },
323    }
324}
325
326pub fn build_jj_context(task: &Task, repos: &[TaskRepo]) -> JjAgentContext {
327    let slug = jj_slug(task);
328    let paths = repo_paths(repos);
329    let workspace_registered = jj_task::slug_registered(&slug, &paths);
330    let workspace_path = if workspace_registered {
331        paths
332            .iter()
333            .find_map(|repo_path| jj_task::workspace_path(repo_path, &slug))
334    } else {
335        paths
336            .first()
337            .map(|first_repo| jj_task::expected_workspace_path(first_repo, &slug))
338    };
339
340    JjAgentContext {
341        slug: slug.clone(),
342        skill: "jj",
343        workspace_registered,
344        workspace_path,
345        start_command: format!("jj-task start {slug}"),
346        path_command: format!("jj-task path {slug}"),
347        repo_init_command: "jj-task repo init",
348    }
349}
350
351pub fn build_git_context(task: &Task, repos: &[TaskRepo]) -> GitAgentContext {
352    let slug = jj_slug(task);
353    let branch = git_worktree::git_branch_name(&slug);
354    let workspace_path = repos
355        .first()
356        .map(|repo| git_worktree::git_worktree_path(&repo.repo_path, &slug));
357    let workspace_ready = workspace_path
358        .as_deref()
359        .is_some_and(git_worktree::git_worktree_exists);
360
361    GitAgentContext {
362        slug,
363        branch,
364        workspace_ready,
365        workspace_path,
366        sync_command: "track sync".to_string(),
367    }
368}
369
370pub fn oldest_pending_todo(todos: &[Todo]) -> Option<&Todo> {
371    todos
372        .iter()
373        .filter(|todo| todo.status == TodoStatus::Pending)
374        .min_by_key(|todo| todo.task_index)
375}
376
377pub fn workspace_lifecycle(todo: &Todo, worktrees: &[Worktree]) -> WorkspaceLifecycle {
378    if !todo.worktree_requested {
379        return WorkspaceLifecycle::NotRequested;
380    }
381
382    if todo.status == TodoStatus::Done {
383        return WorkspaceLifecycle::Merged;
384    }
385
386    if worktrees.iter().any(|wt| wt.todo_id == Some(todo.id)) {
387        WorkspaceLifecycle::Ready
388    } else {
389        WorkspaceLifecycle::Requested
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use chrono::Utc;
397
398    fn sample_task(status: TaskStatus) -> Task {
399        Task {
400            id: 1,
401            name: "Task".to_string(),
402            description: None,
403            status,
404            ticket_id: Some("PROJ-1".to_string()),
405            ticket_url: None,
406            alias: None,
407            is_today_task: false,
408            created_at: Utc::now(),
409        }
410    }
411
412    fn sample_todo(index: i64, worktree_requested: bool) -> Todo {
413        Todo {
414            id: index,
415            task_id: 1,
416            task_index: index,
417            content: format!("Todo {}", index),
418            status: TodoStatus::Pending,
419            worktree_requested,
420            created_at: Utc::now(),
421            completed_at: None,
422        }
423    }
424
425    fn sample_repo() -> TaskRepo {
426        TaskRepo {
427            id: 1,
428            task_id: 1,
429            task_index: 1,
430            repo_path: "/repo".to_string(),
431            base_branch: None,
432            base_commit_hash: None,
433            created_at: Utc::now(),
434        }
435    }
436
437    #[test]
438    fn workflow_phase_detects_legacy_sync_required() {
439        let task = sample_task(TaskStatus::Active);
440        let todos = vec![sample_todo(1, true)];
441        let repos = vec![sample_repo()];
442
443        assert_eq!(
444            compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
445            WorkflowPhase::SyncRequired
446        );
447    }
448
449    #[test]
450    fn workflow_phase_jj_needs_workspace_without_map() {
451        let task = sample_task(TaskStatus::Active);
452        let todos = vec![sample_todo(1, false)];
453        let repos = vec![sample_repo()];
454
455        assert_eq!(
456            compute_workflow_phase(VcsMode::Jj, &task, &todos, &[], &repos),
457            WorkflowPhase::SyncRequired
458        );
459    }
460
461    #[test]
462    fn workflow_phase_git_needs_sync_without_worktree() {
463        let task = sample_task(TaskStatus::Active);
464        let todos = vec![sample_todo(1, false)];
465        let repos = vec![sample_repo()];
466
467        assert_eq!(
468            compute_workflow_phase(VcsMode::Git, &task, &todos, &[], &repos),
469            WorkflowPhase::SyncRequired
470        );
471    }
472
473    #[test]
474    fn sync_required_action_prefers_jj_task_start() {
475        let task = sample_task(TaskStatus::Active);
476        let todos = vec![sample_todo(1, false)];
477        let repos = vec![sample_repo()];
478
479        let action = build_next_action(
480            VcsMode::Jj,
481            WorkflowPhase::SyncRequired,
482            &task,
483            &todos,
484            &[],
485            &repos,
486        );
487        assert_eq!(action.command.as_deref(), Some("jj-task start proj-1"));
488    }
489
490    #[test]
491    fn sync_required_action_uses_track_sync_in_git_mode() {
492        let task = sample_task(TaskStatus::Active);
493        let todos = vec![sample_todo(1, false)];
494        let repos = vec![sample_repo()];
495
496        let action = build_next_action(
497            VcsMode::Git,
498            WorkflowPhase::SyncRequired,
499            &task,
500            &todos,
501            &[],
502            &repos,
503        );
504        assert_eq!(action.command.as_deref(), Some("track sync"));
505    }
506
507    #[test]
508    fn build_jj_context_includes_slug_and_commands() {
509        let task = sample_task(TaskStatus::Active);
510        let ctx = build_jj_context(&task, &[]);
511        assert_eq!(ctx.slug, "proj-1");
512        assert_eq!(ctx.skill, "jj");
513        assert_eq!(ctx.start_command, "jj-task start proj-1");
514    }
515
516    #[test]
517    fn build_git_context_includes_branch_and_sync() {
518        let task = sample_task(TaskStatus::Active);
519        let repos = vec![sample_repo()];
520        let ctx = build_git_context(&task, &repos);
521        assert_eq!(ctx.slug, "proj-1");
522        assert_eq!(ctx.branch, "track/proj-1");
523        assert_eq!(ctx.sync_command, "track sync");
524        assert!(!ctx.workspace_ready);
525    }
526
527    #[test]
528    fn guardrails_disable_jj_skill_in_git_mode() {
529        let guardrails = AgentGuardrails::for_mode(VcsMode::Git, false);
530        assert!(!guardrails.must_use_jj_skill);
531        assert!(guardrails.reopen_forbidden);
532    }
533}