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