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#[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#[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#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
28pub struct WorkflowContext {
29 pub phase: WorkflowPhase,
30 pub next_action: NextAction,
31}
32
33#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
89pub struct AgentGuardrails {
90 pub must_use_jj_skill: bool,
92 pub jj_skill_name: &'static str,
93 pub reopen_forbidden: bool,
94 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
162pub 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
196pub 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}