Skip to main content

task_graph_mcp/
types.rs

1//! Core types for the Task Graph MCP Server.
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::collections::HashMap;
5
6// Skip-if helpers (serde requires function paths, not closures)
7fn is_zero<T: Default + PartialEq>(v: &T) -> bool {
8    *v == T::default()
9}
10
11fn is_default_priority(p: &Priority) -> bool {
12    *p == PRIORITY_DEFAULT
13}
14
15/// Metrics array - serializes with trailing zeros trimmed, deserializes back to [i64; 8]
16mod metrics_serde {
17    use super::*;
18
19    pub fn serialize<S: Serializer>(metrics: &[i64; 8], s: S) -> Result<S::Ok, S::Error> {
20        // Find last non-zero index
21        let len = metrics
22            .iter()
23            .rposition(|&x| x != 0)
24            .map(|i| i + 1)
25            .unwrap_or(0);
26        s.collect_seq(&metrics[..len])
27    }
28
29    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[i64; 8], D::Error> {
30        let v: Vec<i64> = Vec::deserialize(d)?;
31        let mut arr = [0i64; 8];
32        for (i, val) in v.into_iter().take(8).enumerate() {
33            arr[i] = val;
34        }
35        Ok(arr)
36    }
37
38    pub fn is_empty(metrics: &[i64; 8]) -> bool {
39        metrics.iter().all(|&x| x == 0)
40    }
41}
42
43/// Worker (session-based) - represents a connected worker.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Worker {
46    pub id: String,
47    #[serde(skip_serializing_if = "Vec::is_empty")]
48    pub tags: Vec<String>,
49    pub max_claims: i32,
50    pub registered_at: i64,
51    pub last_heartbeat: i64,
52    /// Last status the worker transitioned to (for prompts/dashboard)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub last_status: Option<String>,
55    /// Last phase the worker transitioned to (for prompts/dashboard)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub last_phase: Option<String>,
58    /// Named workflow this worker is using (e.g., "swarm" for workflow-swarm.yaml)
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub workflow: Option<String>,
61    /// Overlay names applied on top of the workflow (e.g., ["git", "user-request"])
62    #[serde(default, skip_serializing_if = "Vec::is_empty")]
63    pub overlays: Vec<String>,
64}
65
66/// Worker info with additional runtime details for list_workers.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct WorkerInfo {
69    pub id: String,
70    #[serde(skip_serializing_if = "Vec::is_empty")]
71    pub tags: Vec<String>,
72    pub max_claims: i32,
73    #[serde(skip_serializing_if = "is_zero")]
74    pub claim_count: i32,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub current_thought: Option<String>,
77    pub registered_at: i64,
78    pub last_heartbeat: i64,
79    /// Last status the worker transitioned to (for prompts/dashboard)
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub last_status: Option<String>,
82    /// Last phase the worker transitioned to (for prompts/dashboard)
83    #[serde(skip_serializing_if = "Option::is_none")]
84    pub last_phase: Option<String>,
85    /// Named workflow this worker is using (e.g., "swarm" for workflow-swarm.yaml)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub workflow: Option<String>,
88    /// Overlay names applied on top of the workflow (e.g., ["git", "user-request"])
89    #[serde(default, skip_serializing_if = "Vec::is_empty")]
90    pub overlays: Vec<String>,
91}
92
93/// Task priority as an integer (higher = more important).
94/// Range: 0-10, where 10 is highest priority. Default is 5.
95pub type Priority = i32;
96
97/// Default priority (middle of 0-10 range).
98pub const PRIORITY_DEFAULT: Priority = 5;
99
100/// Parse a priority value, clamping to 0-10 range.
101pub fn parse_priority(s: &str) -> Priority {
102    s.parse().unwrap_or(PRIORITY_DEFAULT).clamp(0, 10)
103}
104
105/// Clamp priority to valid range.
106pub fn clamp_priority(p: Priority) -> Priority {
107    p.clamp(0, 10)
108}
109
110/// A task in the task graph.
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct Task {
113    pub id: String,
114    pub title: String,
115    #[serde(skip_serializing_if = "Option::is_none")]
116    pub description: Option<String>,
117    pub status: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub phase: Option<String>,
120    #[serde(skip_serializing_if = "is_default_priority")]
121    pub priority: Priority,
122    #[serde(skip_serializing_if = "Option::is_none")]
123    pub worker_id: Option<String>,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub claimed_at: Option<i64>,
126
127    // Affinity (tag-based claiming requirements)
128    #[serde(skip_serializing_if = "Vec::is_empty")]
129    pub needed_tags: Vec<String>,
130    #[serde(skip_serializing_if = "Vec::is_empty")]
131    pub wanted_tags: Vec<String>,
132
133    // Categorization/discovery tags
134    #[serde(skip_serializing_if = "Vec::is_empty")]
135    pub tags: Vec<String>,
136
137    // Estimation & tracking
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub points: Option<i32>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub time_estimate_ms: Option<i64>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub time_actual_ms: Option<i64>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub started_at: Option<i64>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub completed_at: Option<i64>,
148
149    // Live status
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub current_thought: Option<String>,
152
153    // Cost accounting
154    #[serde(skip_serializing_if = "is_zero")]
155    pub cost_usd: f64,
156    /// Fixed array of 8 integer metrics [metric_0..metric_7], aggregated on update
157    #[serde(
158        with = "metrics_serde",
159        skip_serializing_if = "metrics_serde::is_empty",
160        default
161    )]
162    pub metrics: [i64; 8],
163
164    pub created_at: i64,
165    pub updated_at: i64,
166}
167
168/// A task with its children for tree operations.
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct TaskTree {
171    #[serde(flatten)]
172    pub task: Task,
173    pub children: Vec<TaskTree>,
174}
175
176/// Input for creating a task tree.
177/// Supports all fields from task creation, plus tree-specific fields.
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub struct TaskTreeInput {
180    /// Reference to an existing task ID to include in the tree.
181    /// If set, this node references an existing task rather than creating a new one.
182    /// Other fields are ignored when ref is set.
183    #[serde(rename = "ref")]
184    pub ref_id: Option<String>,
185
186    /// Custom task ID (optional, petname ID generated if not provided).
187    /// Ignored if ref is set.
188    pub id: Option<String>,
189
190    /// Task title (optional; derived from description if omitted).
191    pub title: Option<String>,
192
193    /// Task description.
194    pub description: Option<String>,
195
196    /// Task phase (type of work: explore, design, implement, etc.).
197    pub phase: Option<String>,
198
199    /// Task priority.
200    pub priority: Option<Priority>,
201
202    /// Story points / complexity estimate.
203    pub points: Option<i32>,
204
205    /// Estimated duration in milliseconds.
206    pub time_estimate_ms: Option<i64>,
207
208    /// Tags that claiming agent must have ALL of (AND logic).
209    pub needed_tags: Option<Vec<String>>,
210
211    /// Tags that claiming agent must have AT LEAST ONE of (OR logic).
212    pub wanted_tags: Option<Vec<String>>,
213
214    /// Categorization/discovery tags for the task.
215    pub tags: Option<Vec<String>>,
216
217    /// Child nodes in the tree.
218    #[serde(default)]
219    pub children: Vec<TaskTreeInput>,
220}
221
222/// A typed dependency between tasks.
223/// The dependency indicates that from_task_id affects to_task_id based on dep_type.
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Dependency {
226    pub from_task_id: String,
227    pub to_task_id: String,
228    /// Dependency type: "blocks", "follows", "contains", or custom types.
229    pub dep_type: String,
230}
231
232/// An advisory file lock.
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct FileLock {
235    pub file_path: String,
236    pub worker_id: String,
237    #[serde(skip_serializing_if = "Option::is_none")]
238    pub reason: Option<String>,
239    pub locked_at: i64,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub task_id: Option<String>,
242}
243
244/// A claim event for file coordination tracking.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct ClaimEvent {
247    pub id: i64,
248    pub file_path: String,
249    pub worker_id: String,
250    pub event: ClaimEventType,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub reason: Option<String>,
253    pub timestamp: i64,
254    #[serde(skip_serializing_if = "Option::is_none")]
255    pub end_timestamp: Option<i64>,
256    /// For release events: the ID of the corresponding claim event.
257    #[serde(skip_serializing_if = "Option::is_none")]
258    pub claim_id: Option<i64>,
259}
260
261/// A unified task sequence event for tracking status and phase changes.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct TaskSequenceEvent {
264    pub id: i64,
265    pub task_id: String,
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub worker_id: Option<String>,
268    /// Status value (None if phase-only change)
269    #[serde(skip_serializing_if = "Option::is_none")]
270    pub status: Option<String>,
271    /// Phase value (None if status-only change)
272    #[serde(skip_serializing_if = "Option::is_none")]
273    pub phase: Option<String>,
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub reason: Option<String>,
276    pub timestamp: i64,
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub end_timestamp: Option<i64>,
279}
280
281/// Legacy alias for backward compatibility in exports.
282/// A task state transition event for time tracking.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct TaskStateEvent {
285    pub id: i64,
286    pub task_id: String,
287    pub worker_id: Option<String>,
288    pub event: String,
289    pub reason: Option<String>,
290    pub timestamp: i64,
291    pub end_timestamp: Option<i64>,
292}
293
294/// Type of claim event.
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
296#[serde(rename_all = "snake_case")]
297pub enum ClaimEventType {
298    Claimed,
299    Released,
300}
301
302impl ClaimEventType {
303    pub fn as_str(&self) -> &'static str {
304        match self {
305            ClaimEventType::Claimed => "claimed",
306            ClaimEventType::Released => "released",
307        }
308    }
309
310    pub fn parse(s: &str) -> Option<Self> {
311        match s {
312            "claimed" => Some(ClaimEventType::Claimed),
313            "released" => Some(ClaimEventType::Released),
314            _ => None,
315        }
316    }
317}
318
319/// Result of polling claim updates.
320#[derive(Debug, Clone, Serialize, Deserialize)]
321pub struct ClaimUpdates {
322    pub new_claims: Vec<ClaimEvent>,
323    pub dropped_claims: Vec<ClaimEvent>,
324    pub sequence: i64,
325}
326
327/// An attachment on a task.
328/// Primary key is (task_id, attachment_type, sequence).
329/// If file_path is set, content is stored in the referenced file; otherwise content is inline.
330#[derive(Debug, Clone, Serialize, Deserialize)]
331pub struct Attachment {
332    pub task_id: String,
333    pub attachment_type: String,
334    pub sequence: i32,
335    pub name: String,
336    pub mime_type: String,
337    pub content: String,
338    /// Path to the file containing the content (relative to media dir or absolute).
339    /// If set, content is read from this file; if None, content is stored inline.
340    #[serde(skip_serializing_if = "Option::is_none")]
341    pub file_path: Option<String>,
342    pub created_at: i64,
343}
344
345/// Attachment metadata (without content).
346/// Primary key is (task_id, attachment_type, sequence).
347#[derive(Debug, Clone, Serialize, Deserialize)]
348pub struct AttachmentMeta {
349    pub task_id: String,
350    pub attachment_type: String,
351    pub sequence: i32,
352    pub name: String,
353    pub mime_type: String,
354    /// Path to the file containing the content (if stored as file).
355    #[serde(skip_serializing_if = "Option::is_none")]
356    pub file_path: Option<String>,
357    pub created_at: i64,
358}
359
360/// Aggregate statistics.
361#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct Stats {
363    pub total_tasks: i64,
364    /// Task counts by state (dynamic based on config).
365    pub tasks_by_status: HashMap<String, i64>,
366    #[serde(skip_serializing_if = "is_zero")]
367    pub total_points: i64,
368    #[serde(skip_serializing_if = "is_zero")]
369    pub completed_points: i64,
370    #[serde(skip_serializing_if = "is_zero")]
371    pub total_time_estimate_ms: i64,
372    #[serde(skip_serializing_if = "is_zero")]
373    pub total_time_actual_ms: i64,
374    #[serde(skip_serializing_if = "is_zero")]
375    pub total_cost_usd: f64,
376    /// Aggregated metrics [metric_0..metric_7]
377    #[serde(
378        with = "metrics_serde",
379        skip_serializing_if = "metrics_serde::is_empty",
380        default
381    )]
382    pub total_metrics: [i64; 8],
383}
384
385/// Compact task representation for list views.
386#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct TaskSummary {
388    pub id: String,
389    pub title: String,
390    pub status: String,
391    #[serde(skip_serializing_if = "is_default_priority")]
392    pub priority: Priority,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub worker_id: Option<String>,
395    #[serde(skip_serializing_if = "Option::is_none")]
396    pub points: Option<i32>,
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub current_thought: Option<String>,
399}
400
401/// Result of scanning the task graph from a starting task.
402/// Contains tasks organized by traversal direction.
403#[derive(Debug, Clone, Serialize, Deserialize)]
404pub struct ScanResult {
405    /// The task that was scanned from
406    pub root: Task,
407    /// Tasks that block this task (predecessors via blocks/follows)
408    #[serde(skip_serializing_if = "Vec::is_empty")]
409    pub before: Vec<Task>,
410    /// Tasks that this task blocks (successors via blocks/follows)
411    #[serde(skip_serializing_if = "Vec::is_empty")]
412    pub after: Vec<Task>,
413    /// Parent chain (ancestors via contains)
414    #[serde(skip_serializing_if = "Vec::is_empty")]
415    pub above: Vec<Task>,
416    /// Children tree (descendants via contains)
417    #[serde(skip_serializing_if = "Vec::is_empty")]
418    pub below: Vec<Task>,
419}
420
421/// Summary of disconnect operation.
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct DisconnectSummary {
424    /// Number of tasks that were released.
425    pub tasks_released: i32,
426    /// Number of file locks that were released.
427    pub files_released: i32,
428    /// The final status applied to released tasks.
429    pub final_status: String,
430}
431
432/// Summary of stale worker cleanup operation.
433#[derive(Debug, Clone, Serialize, Deserialize)]
434pub struct CleanupSummary {
435    /// Number of stale workers evicted.
436    pub workers_evicted: i32,
437    /// Total number of tasks released across all evicted workers.
438    pub tasks_released: i32,
439    /// Total number of file locks released across all evicted workers.
440    pub files_released: i32,
441    /// The final status applied to released tasks.
442    pub final_status: String,
443    /// IDs of evicted workers.
444    pub evicted_worker_ids: Vec<String>,
445}
446
447/// A task tag row for export/import.
448#[derive(Debug, Clone, Serialize, Deserialize)]
449pub struct TaskTagRow {
450    pub task_id: String,
451    pub tag: String,
452}
453
454/// A task needed tag row for export/import.
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct TaskNeededTagRow {
457    pub task_id: String,
458    pub tag: String,
459}
460
461/// A task wanted tag row for export/import.
462#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct TaskWantedTagRow {
464    pub task_id: String,
465    pub tag: String,
466}
467
468/// Exported tables container for database export.
469#[derive(Debug, Clone, Default, Serialize, Deserialize)]
470pub struct ExportTables {
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub tasks: Option<Vec<Task>>,
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub dependencies: Option<Vec<Dependency>>,
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub attachments: Option<Vec<Attachment>>,
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub task_tags: Option<Vec<TaskTagRow>>,
479    #[serde(skip_serializing_if = "Option::is_none")]
480    pub task_needed_tags: Option<Vec<TaskNeededTagRow>>,
481    #[serde(skip_serializing_if = "Option::is_none")]
482    pub task_wanted_tags: Option<Vec<TaskWantedTagRow>>,
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub task_sequence: Option<Vec<TaskSequenceEvent>>,
485}
486
487#[cfg(test)]
488mod tests {
489    // Priority tests removed - Priority is now a type alias for i32
490}