Skip to main content

task_graph_mcp/
types.rs

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