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 (optional; derived from description if omitted).
185    pub title: Option<String>,
186
187    /// Task description.
188    pub description: Option<String>,
189
190    /// Task phase (type of work: explore, design, implement, etc.).
191    pub phase: Option<String>,
192
193    /// Task priority.
194    pub priority: Option<Priority>,
195
196    /// Story points / complexity estimate.
197    pub points: Option<i32>,
198
199    /// Estimated duration in milliseconds.
200    pub time_estimate_ms: Option<i64>,
201
202    /// Tags that claiming agent must have ALL of (AND logic).
203    pub needed_tags: Option<Vec<String>>,
204
205    /// Tags that claiming agent must have AT LEAST ONE of (OR logic).
206    pub wanted_tags: Option<Vec<String>>,
207
208    /// Categorization/discovery tags for the task.
209    pub tags: Option<Vec<String>>,
210
211    /// Child nodes in the tree.
212    #[serde(default)]
213    pub children: Vec<TaskTreeInput>,
214}
215
216/// A typed dependency between tasks.
217/// The dependency indicates that from_task_id affects to_task_id based on dep_type.
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct Dependency {
220    pub from_task_id: String,
221    pub to_task_id: String,
222    /// Dependency type: "blocks", "follows", "contains", or custom types.
223    pub dep_type: String,
224}
225
226/// An advisory file lock.
227#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct FileLock {
229    pub file_path: String,
230    pub worker_id: String,
231    #[serde(skip_serializing_if = "Option::is_none")]
232    pub reason: Option<String>,
233    pub locked_at: i64,
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub task_id: Option<String>,
236}
237
238/// A claim event for file coordination tracking.
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct ClaimEvent {
241    pub id: i64,
242    pub file_path: String,
243    pub worker_id: String,
244    pub event: ClaimEventType,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub reason: Option<String>,
247    pub timestamp: i64,
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub end_timestamp: Option<i64>,
250    /// For release events: the ID of the corresponding claim event.
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub claim_id: Option<i64>,
253}
254
255/// A unified task sequence event for tracking status and phase changes.
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct TaskSequenceEvent {
258    pub id: i64,
259    pub task_id: String,
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub worker_id: Option<String>,
262    /// Status value (None if phase-only change)
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub status: Option<String>,
265    /// Phase value (None if status-only change)
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub phase: Option<String>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub reason: Option<String>,
270    pub timestamp: i64,
271    #[serde(skip_serializing_if = "Option::is_none")]
272    pub end_timestamp: Option<i64>,
273}
274
275/// Legacy alias for backward compatibility in exports.
276/// A task state transition event for time tracking.
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct TaskStateEvent {
279    pub id: i64,
280    pub task_id: String,
281    pub worker_id: Option<String>,
282    pub event: String,
283    pub reason: Option<String>,
284    pub timestamp: i64,
285    pub end_timestamp: Option<i64>,
286}
287
288/// Type of claim event.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum ClaimEventType {
292    Claimed,
293    Released,
294}
295
296impl ClaimEventType {
297    pub fn as_str(&self) -> &'static str {
298        match self {
299            ClaimEventType::Claimed => "claimed",
300            ClaimEventType::Released => "released",
301        }
302    }
303
304    pub fn parse(s: &str) -> Option<Self> {
305        match s {
306            "claimed" => Some(ClaimEventType::Claimed),
307            "released" => Some(ClaimEventType::Released),
308            _ => None,
309        }
310    }
311}
312
313/// Result of polling claim updates.
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct ClaimUpdates {
316    pub new_claims: Vec<ClaimEvent>,
317    pub dropped_claims: Vec<ClaimEvent>,
318    pub sequence: i64,
319}
320
321/// An attachment on a task.
322/// Primary key is (task_id, attachment_type, sequence).
323/// If file_path is set, content is stored in the referenced file; otherwise content is inline.
324#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct Attachment {
326    pub task_id: String,
327    pub attachment_type: String,
328    pub sequence: i32,
329    pub name: String,
330    pub mime_type: String,
331    pub content: String,
332    /// Path to the file containing the content (relative to media dir or absolute).
333    /// If set, content is read from this file; if None, content is stored inline.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub file_path: Option<String>,
336    pub created_at: i64,
337}
338
339/// Attachment metadata (without content).
340/// Primary key is (task_id, attachment_type, sequence).
341#[derive(Debug, Clone, Serialize, Deserialize)]
342pub struct AttachmentMeta {
343    pub task_id: String,
344    pub attachment_type: String,
345    pub sequence: i32,
346    pub name: String,
347    pub mime_type: String,
348    /// Path to the file containing the content (if stored as file).
349    #[serde(skip_serializing_if = "Option::is_none")]
350    pub file_path: Option<String>,
351    pub created_at: i64,
352}
353
354/// Aggregate statistics.
355#[derive(Debug, Clone, Serialize, Deserialize)]
356pub struct Stats {
357    pub total_tasks: i64,
358    /// Task counts by state (dynamic based on config).
359    pub tasks_by_status: HashMap<String, i64>,
360    #[serde(skip_serializing_if = "is_zero")]
361    pub total_points: i64,
362    #[serde(skip_serializing_if = "is_zero")]
363    pub completed_points: i64,
364    #[serde(skip_serializing_if = "is_zero")]
365    pub total_time_estimate_ms: i64,
366    #[serde(skip_serializing_if = "is_zero")]
367    pub total_time_actual_ms: i64,
368    #[serde(skip_serializing_if = "is_zero")]
369    pub total_cost_usd: f64,
370    /// Aggregated metrics [metric_0..metric_7]
371    #[serde(
372        with = "metrics_serde",
373        skip_serializing_if = "metrics_serde::is_empty",
374        default
375    )]
376    pub total_metrics: [i64; 8],
377}
378
379/// Compact task representation for list views.
380#[derive(Debug, Clone, Serialize, Deserialize)]
381pub struct TaskSummary {
382    pub id: String,
383    pub title: String,
384    pub status: String,
385    #[serde(skip_serializing_if = "is_default_priority")]
386    pub priority: Priority,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub worker_id: Option<String>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub points: Option<i32>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub current_thought: Option<String>,
393}
394
395/// Result of scanning the task graph from a starting task.
396/// Contains tasks organized by traversal direction.
397#[derive(Debug, Clone, Serialize, Deserialize)]
398pub struct ScanResult {
399    /// The task that was scanned from
400    pub root: Task,
401    /// Tasks that block this task (predecessors via blocks/follows)
402    #[serde(skip_serializing_if = "Vec::is_empty")]
403    pub before: Vec<Task>,
404    /// Tasks that this task blocks (successors via blocks/follows)
405    #[serde(skip_serializing_if = "Vec::is_empty")]
406    pub after: Vec<Task>,
407    /// Parent chain (ancestors via contains)
408    #[serde(skip_serializing_if = "Vec::is_empty")]
409    pub above: Vec<Task>,
410    /// Children tree (descendants via contains)
411    #[serde(skip_serializing_if = "Vec::is_empty")]
412    pub below: Vec<Task>,
413}
414
415/// Summary of disconnect operation.
416#[derive(Debug, Clone, Serialize, Deserialize)]
417pub struct DisconnectSummary {
418    /// Number of tasks that were released.
419    pub tasks_released: i32,
420    /// Number of file locks that were released.
421    pub files_released: i32,
422    /// The final status applied to released tasks.
423    pub final_status: String,
424}
425
426/// Summary of stale worker cleanup operation.
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct CleanupSummary {
429    /// Number of stale workers evicted.
430    pub workers_evicted: i32,
431    /// Total number of tasks released across all evicted workers.
432    pub tasks_released: i32,
433    /// Total number of file locks released across all evicted workers.
434    pub files_released: i32,
435    /// The final status applied to released tasks.
436    pub final_status: String,
437    /// IDs of evicted workers.
438    pub evicted_worker_ids: Vec<String>,
439}
440
441/// A task tag row for export/import.
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct TaskTagRow {
444    pub task_id: String,
445    pub tag: String,
446}
447
448/// A task needed tag row for export/import.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct TaskNeededTagRow {
451    pub task_id: String,
452    pub tag: String,
453}
454
455/// A task wanted tag row for export/import.
456#[derive(Debug, Clone, Serialize, Deserialize)]
457pub struct TaskWantedTagRow {
458    pub task_id: String,
459    pub tag: String,
460}
461
462/// Exported tables container for database export.
463#[derive(Debug, Clone, Default, Serialize, Deserialize)]
464pub struct ExportTables {
465    #[serde(skip_serializing_if = "Option::is_none")]
466    pub tasks: Option<Vec<Task>>,
467    #[serde(skip_serializing_if = "Option::is_none")]
468    pub dependencies: Option<Vec<Dependency>>,
469    #[serde(skip_serializing_if = "Option::is_none")]
470    pub attachments: Option<Vec<Attachment>>,
471    #[serde(skip_serializing_if = "Option::is_none")]
472    pub task_tags: Option<Vec<TaskTagRow>>,
473    #[serde(skip_serializing_if = "Option::is_none")]
474    pub task_needed_tags: Option<Vec<TaskNeededTagRow>>,
475    #[serde(skip_serializing_if = "Option::is_none")]
476    pub task_wanted_tags: Option<Vec<TaskWantedTagRow>>,
477    #[serde(skip_serializing_if = "Option::is_none")]
478    pub task_sequence: Option<Vec<TaskSequenceEvent>>,
479}
480
481#[cfg(test)]
482mod tests {
483    // Priority tests removed - Priority is now a type alias for i32
484}