diff --git a/docs/wiki/Architecture.md b/docs/wiki/Architecture.md index ce6089b7..7f93c79f 100644 --- a/docs/wiki/Architecture.md +++ b/docs/wiki/Architecture.md @@ -50,7 +50,7 @@ All command handlers receive `State<'_, AppState>` as a Tauri-injected parameter | `commands/integrations.rs` | Confluence / ServiceNow / ADO — v0.2 stubs | | `ai/provider.rs` | `Provider` trait + `create_provider()` factory | | `pii/detector.rs` | Multi-pattern PII scanner with overlap resolution | -| `db/migrations.rs` | Versioned schema (12 migrations in `_migrations` table) | +| `db/migrations.rs` | Versioned schema (17 migrations in `_migrations` table) | | `db/models.rs` | All DB types — see `IssueDetail` note below | | `docs/rca.rs` + `docs/postmortem.rs` | Markdown template builders | | `audit/log.rs` | `write_audit_event()` — called before every external send | @@ -176,6 +176,55 @@ pub struct IssueDetail { Use `detail.issue.title`, **not** `detail.title`. +## Incident Response Methodology + +The application integrates a comprehensive incident response framework via system prompt injection. The `INCIDENT_RESPONSE_FRAMEWORK` constant in `src/lib/domainPrompts.ts` is appended to all 17 domain-specific system prompts (Linux, Windows, Network, Kubernetes, Databases, Virtualization, Hardware, Observability, and others). + +**5-Phase Framework:** + +1. **Detection & Evidence Gathering** — Initial issue assessment, log collection, PII redaction +2. **Diagnosis & Hypothesis Testing** — AI-assisted analysis, pattern matching against known incidents +3. **Root Cause Analysis with 5-Whys** — Iterative questioning to identify underlying cause (steps 1–5) +4. **Resolution & Prevention** — Remediation planning and implementation +5. **Post-Incident Review** — Timeline-based blameless post-mortem and lessons learned + +**System Prompt Injection:** + +The `chat_message` command accepts an optional `system_prompt` parameter. If provided, it prepends domain expertise before the conversation history. If omitted, the framework selects the appropriate domain prompt based on the issue category. This allows: + +- **Specialized expertise**: Different frameworks for Linux vs. Kubernetes vs. Network incidents +- **Flexible override**: Users can inject custom system prompts for cross-domain problems +- **Consistent methodology**: All 17 domain prompts follow the same 5-phase incident response structure + +**Timeline Event Recording:** + +Timeline events are recorded non-blockingly at key triage moments: + +``` +Issue Creation → triage_started + ↓ +Log Upload → log_uploaded (metadata: file_name, file_size) + ↓ +Why-Level Progression → why_level_advanced (metadata: from_level → to_level) + ↓ +Root Cause Identified → root_cause_identified (metadata: root_cause, confidence) + ↓ +RCA Generated → rca_generated (metadata: doc_id, section_count) + ↓ +Postmortem Generated → postmortem_generated (metadata: doc_id, timeline_events_count) + ↓ +Document Exported → document_exported (metadata: format, file_path) +``` + +**Document Generation:** + +RCA and Postmortem generators now use real timeline event data instead of placeholders: + +- **RCA**: Incorporates timeline to show detection-to-root-cause progression +- **Postmortem**: Uses full timeline to demonstrate the complete incident lifecycle and response effectiveness + +Timeline events are stored in the `timeline_events` table (indexed by issue_id and created_at for fast retrieval) and dual-written to `audit_log` for security/compliance purposes. + ## Application Startup Sequence ``` diff --git a/docs/wiki/Database.md b/docs/wiki/Database.md index adcd0c21..452395ff 100644 --- a/docs/wiki/Database.md +++ b/docs/wiki/Database.md @@ -2,7 +2,7 @@ ## Overview -TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 12 versioned migrations are tracked in the `_migrations` table. +TFTSR uses **SQLite** via `rusqlite` with the `bundled-sqlcipher` feature for AES-256 encryption in production. 17 versioned migrations are tracked in the `_migrations` table. **DB file location:** `{app_data_dir}/tftsr.db` @@ -38,7 +38,7 @@ pub fn init_db(data_dir: &Path) -> anyhow::Result { --- -## Schema (11 Migrations) +## Schema (17 Migrations) ### 001 — issues @@ -245,6 +245,51 @@ CREATE TABLE image_attachments ( - Basic auth (ServiceNow): Store encrypted password - One credential per service (enforced by UNIQUE constraint) +### 017 — timeline_events (Incident Response Timeline) + +```sql +CREATE TABLE timeline_events ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + description TEXT NOT NULL, + metadata TEXT, -- JSON object with event-specific data + created_at TEXT NOT NULL +); + +CREATE INDEX idx_timeline_events_issue ON timeline_events(issue_id); +CREATE INDEX idx_timeline_events_time ON timeline_events(created_at); +``` + +**Event Types:** +- `triage_started` — Incident response begins, initial issue properties recorded +- `log_uploaded` — Log file uploaded and analyzed +- `why_level_advanced` — 5-Whys entry completed, progression to next level +- `root_cause_identified` — Root cause determined from analysis +- `rca_generated` — Root Cause Analysis document created +- `postmortem_generated` — Post-mortem document created +- `document_exported` — Document exported to file (MD or PDF) + +**Metadata Structure (JSON):** +```json +{ + "triage_started": {"severity": "high", "category": "network"}, + "log_uploaded": {"file_name": "app.log", "file_size": 2048576}, + "why_level_advanced": {"from_level": 2, "to_level": 3, "question": "Why did the service timeout?"}, + "root_cause_identified": {"root_cause": "DNS resolution failure", "confidence": 0.95}, + "rca_generated": {"doc_id": "doc_abc123", "section_count": 7}, + "postmortem_generated": {"doc_id": "doc_def456", "timeline_events_count": 12}, + "document_exported": {"format": "pdf", "file_path": "/home/user/docs/rca.pdf"} +} +``` + +**Design Notes:** +- Timeline events are **queryable** (indexed by issue_id and created_at) for document generation +- Dual-write: Events recorded to both `timeline_events` and `audit_log` — timeline for chronological reporting, audit_log for security/compliance +- `created_at`: TEXT UTC timestamp (`YYYY-MM-DD HH:MM:SS`) +- Non-blocking writes: Timeline events recorded asynchronously at key triage moments +- Cascade delete from issues ensures cleanup + --- ## Key Design Notes @@ -289,4 +334,13 @@ pub struct AuditEntry { pub user_id: String, pub details: Option, } + +pub struct TimelineEvent { + pub id: String, + pub issue_id: String, + pub event_type: String, + pub description: String, + pub metadata: Option, // JSON + pub created_at: String, +} ``` diff --git a/docs/wiki/IPC-Commands.md b/docs/wiki/IPC-Commands.md index ad931460..6e7081e8 100644 --- a/docs/wiki/IPC-Commands.md +++ b/docs/wiki/IPC-Commands.md @@ -62,11 +62,27 @@ updateFiveWhyCmd(entryId: string, answer: string) → void ``` Sets or updates the answer for an existing 5-Whys entry. +### `get_timeline_events` +```typescript +getTimelineEventsCmd(issueId: string) → TimelineEvent[] +``` +Retrieves all timeline events for an issue, ordered by created_at ascending. +```typescript +interface TimelineEvent { + id: string; + issue_id: string; + event_type: string; // One of: triage_started, log_uploaded, why_level_advanced, etc. + description: string; + metadata?: Record; // Event-specific JSON data + created_at: string; // UTC timestamp +} +``` + ### `add_timeline_event` ```typescript -addTimelineEventCmd(issueId: string, eventType: string, description: string) → TimelineEvent +addTimelineEventCmd(issueId: string, eventType: string, description: string, metadata?: Record) → TimelineEvent ``` -Records a timestamped event in the issue timeline. +Records a timestamped event in the issue timeline. Dual-writes to both `timeline_events` (for document generation) and `audit_log` (for security audit trail). --- @@ -137,9 +153,9 @@ Sends selected (redacted) log files to the AI provider with an analysis prompt. ### `chat_message` ```typescript -chatMessageCmd(issueId: string, message: string, providerConfig: ProviderConfig) → ChatResponse +chatMessageCmd(issueId: string, message: string, providerConfig: ProviderConfig, systemPrompt?: string) → ChatResponse ``` -Sends a message in the ongoing triage conversation. Domain system prompt is injected automatically on first message. AI response is parsed for why-level indicators (1–5). +Sends a message in the ongoing triage conversation. Optional `systemPrompt` parameter allows prepending domain expertise before conversation history. If not provided, the domain-specific system prompt for the issue category is injected automatically on first message. AI response is parsed for why-level indicators (1–5). ### `list_providers` ```typescript @@ -155,13 +171,13 @@ Returns the list of supported providers with their available models and configur ```typescript generateRcaCmd(issueId: string) → Document ``` -Builds an RCA Markdown document from the issue data, 5-Whys answers, and timeline. +Builds an RCA Markdown document from the issue data, 5-Whys answers, and timeline events. Uses real incident response timeline (log uploads, why-level progression, root cause identification) instead of placeholders. ### `generate_postmortem` ```typescript generatePostmortemCmd(issueId: string) → Document ``` -Builds a blameless post-mortem Markdown document. +Builds a blameless post-mortem Markdown document. Incorporates timeline events to show the full incident lifecycle: detection, diagnosis, resolution, and post-incident review phases. ### `update_document` ```typescript diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs index 9becd0eb..fe17d210 100644 --- a/src-tauri/src/commands/ai.rs +++ b/src-tauri/src/commands/ai.rs @@ -165,6 +165,7 @@ pub async fn chat_message( issue_id: String, message: String, provider_config: ProviderConfig, + system_prompt: Option, app_handle: tauri::AppHandle, state: State<'_, AppState>, ) -> Result { @@ -232,7 +233,21 @@ pub async fn chat_message( // Search integration sources for relevant context let integration_context = search_integration_sources(&message, &app_handle, &state).await; - let mut messages = history; + let mut messages = Vec::new(); + + // Inject domain system prompt if provided + if let Some(ref prompt) = system_prompt { + if !prompt.is_empty() { + messages.push(Message { + role: "system".into(), + content: prompt.clone(), + tool_call_id: None, + tool_calls: None, + }); + } + } + + messages.extend(history); // If we found integration content, add it to the conversation context if !integration_context.is_empty() { diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index 4419b222..73550f94 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -2,7 +2,7 @@ use tauri::State; use crate::db::models::{ AiConversation, AiMessage, ImageAttachment, Issue, IssueDetail, IssueFilter, IssueSummary, - IssueUpdate, LogFile, ResolutionStep, + IssueUpdate, LogFile, ResolutionStep, TimelineEvent, }; use crate::state::AppState; @@ -171,12 +171,35 @@ pub async fn get_issue( .filter_map(|r| r.ok()) .collect(); + // Load timeline events + let mut te_stmt = db + .prepare( + "SELECT id, issue_id, event_type, description, metadata, created_at \ + FROM timeline_events WHERE issue_id = ?1 ORDER BY created_at ASC", + ) + .map_err(|e| e.to_string())?; + let timeline_events: Vec = te_stmt + .query_map([&issue_id], |row| { + Ok(TimelineEvent { + id: row.get(0)?, + issue_id: row.get(1)?, + event_type: row.get(2)?, + description: row.get(3)?, + metadata: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + Ok(IssueDetail { issue, log_files, image_attachments, resolution_steps, conversations, + timeline_events, }) } @@ -302,6 +325,11 @@ pub async fn delete_issue(issue_id: String, state: State<'_, AppState>) -> Resul [&issue_id], ) .map_err(|e| e.to_string())?; + db.execute( + "DELETE FROM timeline_events WHERE issue_id = ?1", + [&issue_id], + ) + .map_err(|e| e.to_string())?; db.execute("DELETE FROM issues WHERE id = ?1", [&issue_id]) .map_err(|e| e.to_string())?; @@ -505,37 +533,105 @@ pub async fn update_five_why( Ok(()) } +const VALID_EVENT_TYPES: &[&str] = &[ + "triage_started", + "log_uploaded", + "why_level_advanced", + "root_cause_identified", + "rca_generated", + "postmortem_generated", + "document_exported", +]; + #[tauri::command] pub async fn add_timeline_event( issue_id: String, event_type: String, description: String, + metadata: Option, state: State<'_, AppState>, -) -> Result<(), String> { - // Use audit_log for timeline tracking - let db = state.db.lock().map_err(|e| e.to_string())?; - let entry = crate::db::models::AuditEntry::new( - event_type, - "issue".to_string(), +) -> Result { + if !VALID_EVENT_TYPES.contains(&event_type.as_str()) { + return Err(format!("Invalid event_type: {event_type}")); + } + + let meta = metadata.unwrap_or_else(|| "{}".to_string()); + if meta.len() > 10240 { + return Err("metadata exceeds maximum size of 10KB".to_string()); + } + serde_json::from_str::(&meta) + .map_err(|_| "metadata must be valid JSON".to_string())?; + + let event = TimelineEvent::new( issue_id.clone(), - serde_json::json!({ "description": description }).to_string(), + event_type.clone(), + description.clone(), + meta, ); + + let mut db = state.db.lock().map_err(|e| e.to_string())?; + let tx = db.transaction().map_err(|e| e.to_string())?; + + tx.execute( + "INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + event.id, + event.issue_id, + event.event_type, + event.description, + event.metadata, + event.created_at, + ], + ) + .map_err(|e| e.to_string())?; + crate::audit::log::write_audit_event( - &db, - &entry.action, - &entry.entity_type, - &entry.entity_id, - &entry.details, + &tx, + &event_type, + "issue", + &issue_id, + &serde_json::json!({ "description": description, "metadata": event.metadata }).to_string(), ) .map_err(|_| "Failed to write security audit entry".to_string())?; - // Update issue timestamp let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); - db.execute( + tx.execute( "UPDATE issues SET updated_at = ?1 WHERE id = ?2", rusqlite::params![now, issue_id], ) .map_err(|e| e.to_string())?; - Ok(()) + tx.commit().map_err(|e| e.to_string())?; + + Ok(event) +} + +#[tauri::command] +pub async fn get_timeline_events( + issue_id: String, + state: State<'_, AppState>, +) -> Result, String> { + let db = state.db.lock().map_err(|e| e.to_string())?; + let mut stmt = db + .prepare( + "SELECT id, issue_id, event_type, description, metadata, created_at \ + FROM timeline_events WHERE issue_id = ?1 ORDER BY created_at ASC", + ) + .map_err(|e| e.to_string())?; + let events = stmt + .query_map([&issue_id], |row| { + Ok(TimelineEvent { + id: row.get(0)?, + issue_id: row.get(1)?, + event_type: row.get(2)?, + description: row.get(3)?, + metadata: row.get(4)?, + created_at: row.get(5)?, + }) + }) + .map_err(|e| e.to_string())? + .filter_map(|r| r.ok()) + .collect(); + Ok(events) } diff --git a/src-tauri/src/db/migrations.rs b/src-tauri/src/db/migrations.rs index 12056f10..9259ce11 100644 --- a/src-tauri/src/db/migrations.rs +++ b/src-tauri/src/db/migrations.rs @@ -199,6 +199,20 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> { "016_add_created_at", "ALTER TABLE ai_providers ADD COLUMN created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S', 'now'))", ), + ( + "017_create_timeline_events", + "CREATE TABLE IF NOT EXISTS timeline_events ( + id TEXT PRIMARY KEY, + issue_id TEXT NOT NULL, + event_type TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE + ); + CREATE INDEX idx_timeline_events_issue ON timeline_events(issue_id); + CREATE INDEX idx_timeline_events_time ON timeline_events(created_at);", + ), ]; for (name, sql) in migrations { @@ -698,4 +712,82 @@ mod tests { // Should not fail even though columns already exist run_migrations(&conn).unwrap(); } + + #[test] + fn test_timeline_events_table_exists() { + let conn = setup_test_db(); + let count: i64 = conn + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='timeline_events'", + [], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(count, 1); + + let mut stmt = conn.prepare("PRAGMA table_info(timeline_events)").unwrap(); + let columns: Vec = stmt + .query_map([], |row| row.get::<_, String>(1)) + .unwrap() + .collect::, _>>() + .unwrap(); + + assert!(columns.contains(&"id".to_string())); + assert!(columns.contains(&"issue_id".to_string())); + assert!(columns.contains(&"event_type".to_string())); + assert!(columns.contains(&"description".to_string())); + assert!(columns.contains(&"metadata".to_string())); + assert!(columns.contains(&"created_at".to_string())); + } + + #[test] + fn test_timeline_events_cascade_delete() { + let conn = setup_test_db(); + conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); + + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + conn.execute( + "INSERT INTO issues (id, title, created_at, updated_at) VALUES (?1, ?2, ?3, ?4)", + rusqlite::params!["issue-1", "Test Issue", now, now], + ) + .unwrap(); + + conn.execute( + "INSERT INTO timeline_events (id, issue_id, event_type, description, metadata, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params!["te-1", "issue-1", "triage_started", "Started triage", "{}", "2025-01-15 10:00:00 UTC"], + ) + .unwrap(); + + // Verify event exists + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM timeline_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + + // Delete issue — cascade should remove timeline event + conn.execute("DELETE FROM issues WHERE id = 'issue-1'", []) + .unwrap(); + + let count: i64 = conn + .query_row("SELECT COUNT(*) FROM timeline_events", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 0); + } + + #[test] + fn test_timeline_events_indexes() { + let conn = setup_test_db(); + let mut stmt = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='timeline_events'", + ) + .unwrap(); + let indexes: Vec = stmt + .query_map([], |row| row.get(0)) + .unwrap() + .filter_map(|r| r.ok()) + .collect(); + assert!(indexes.contains(&"idx_timeline_events_issue".to_string())); + assert!(indexes.contains(&"idx_timeline_events_time".to_string())); + } } diff --git a/src-tauri/src/db/models.rs b/src-tauri/src/db/models.rs index 1524c587..e7b1b2f6 100644 --- a/src-tauri/src/db/models.rs +++ b/src-tauri/src/db/models.rs @@ -47,6 +47,7 @@ pub struct IssueDetail { pub image_attachments: Vec, pub resolution_steps: Vec, pub conversations: Vec, + pub timeline_events: Vec, } /// Lightweight row returned by list/search commands. @@ -121,9 +122,31 @@ pub struct FiveWhyEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimelineEvent { pub id: String, + pub issue_id: String, pub event_type: String, pub description: String, - pub created_at: i64, + pub metadata: String, + pub created_at: String, +} + +impl TimelineEvent { + pub fn new( + issue_id: String, + event_type: String, + description: String, + metadata: String, + ) -> Self { + TimelineEvent { + id: Uuid::now_v7().to_string(), + issue_id, + event_type, + description, + metadata, + created_at: chrono::Utc::now() + .format("%Y-%m-%d %H:%M:%S UTC") + .to_string(), + } + } } // ─── Log File ─────────────────────────────────────────────────────────────── diff --git a/src-tauri/src/docs/postmortem.rs b/src-tauri/src/docs/postmortem.rs index abddaaf2..8b919b26 100644 --- a/src-tauri/src/docs/postmortem.rs +++ b/src-tauri/src/docs/postmortem.rs @@ -1,4 +1,5 @@ use crate::db::models::IssueDetail; +use crate::docs::rca::{calculate_duration, format_event_type}; pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { let issue = &detail.issue; @@ -51,7 +52,16 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { // Impact md.push_str("## Impact\n\n"); - md.push_str("- **Duration:** _[How long did the incident last?]_\n"); + if detail.timeline_events.len() >= 2 { + let first = &detail.timeline_events[0].created_at; + let last = &detail.timeline_events[detail.timeline_events.len() - 1].created_at; + md.push_str(&format!( + "- **Duration:** {}\n", + calculate_duration(first, last) + )); + } else { + md.push_str("- **Duration:** _[How long did the incident last?]_\n"); + } md.push_str("- **Users Affected:** _[Number/percentage of affected users]_\n"); md.push_str("- **Revenue Impact:** _[Financial impact, if applicable]_\n"); md.push_str("- **SLA Impact:** _[Were any SLAs breached?]_\n\n"); @@ -67,7 +77,19 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { if let Some(ref resolved) = issue.resolved_at { md.push_str(&format!("| {resolved} | Issue resolved |\n")); } - md.push_str("| _HH:MM_ | _[Add additional timeline events]_ |\n\n"); + if detail.timeline_events.is_empty() { + md.push_str("| _HH:MM_ | _[Add additional timeline events]_ |\n"); + } else { + for event in &detail.timeline_events { + md.push_str(&format!( + "| {} | {} - {} |\n", + event.created_at, + format_event_type(&event.event_type), + event.description + )); + } + } + md.push('\n'); // Root Cause Analysis md.push_str("## Root Cause Analysis\n\n"); @@ -114,6 +136,19 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { // What Went Well md.push_str("## What Went Well\n\n"); + if !detail.resolution_steps.is_empty() { + md.push_str(&format!( + "- Systematic 5-whys analysis conducted ({} steps completed)\n", + detail.resolution_steps.len() + )); + } + if detail + .timeline_events + .iter() + .any(|e| e.event_type == "root_cause_identified") + { + md.push_str("- Root cause was identified during triage\n"); + } md.push_str("- _[e.g., Quick detection through existing alerts]_\n"); md.push_str("- _[e.g., Effective cross-team collaboration]_\n"); md.push_str("- _[e.g., Smooth communication with stakeholders]_\n\n"); @@ -158,7 +193,7 @@ pub fn generate_postmortem_markdown(detail: &IssueDetail) -> String { #[cfg(test)] mod tests { use super::*; - use crate::db::models::{Issue, IssueDetail, ResolutionStep}; + use crate::db::models::{Issue, IssueDetail, ResolutionStep, TimelineEvent}; fn make_test_detail() -> IssueDetail { IssueDetail { @@ -188,6 +223,7 @@ mod tests { created_at: "2025-02-10 09:00:00".to_string(), }], conversations: vec![], + timeline_events: vec![], } } @@ -246,4 +282,76 @@ mod tests { assert!(md.contains("| Priority | Action | Owner | Due Date | Status |")); assert!(md.contains("| P0 |")); } + + #[test] + fn test_postmortem_timeline_with_real_events() { + let mut detail = make_test_detail(); + detail.timeline_events = vec![ + TimelineEvent { + id: "te-1".to_string(), + issue_id: "pm-456".to_string(), + event_type: "triage_started".to_string(), + description: "Triage initiated".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 08:05:00 UTC".to_string(), + }, + TimelineEvent { + id: "te-2".to_string(), + issue_id: "pm-456".to_string(), + event_type: "root_cause_identified".to_string(), + description: "Certificate expiry confirmed".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 10:30:00 UTC".to_string(), + }, + ]; + let md = generate_postmortem_markdown(&detail); + assert!(md.contains("## Timeline")); + assert!(md.contains("| 2025-02-10 08:05:00 UTC | Triage Started - Triage initiated |")); + assert!(md.contains( + "| 2025-02-10 10:30:00 UTC | Root Cause Identified - Certificate expiry confirmed |" + )); + assert!(!md.contains("_[Add additional timeline events]_")); + } + + #[test] + fn test_postmortem_impact_with_duration() { + let mut detail = make_test_detail(); + detail.timeline_events = vec![ + TimelineEvent { + id: "te-1".to_string(), + issue_id: "pm-456".to_string(), + event_type: "triage_started".to_string(), + description: "Triage initiated".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 08:00:00 UTC".to_string(), + }, + TimelineEvent { + id: "te-2".to_string(), + issue_id: "pm-456".to_string(), + event_type: "root_cause_identified".to_string(), + description: "Found it".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 10:30:00 UTC".to_string(), + }, + ]; + let md = generate_postmortem_markdown(&detail); + assert!(md.contains("**Duration:** 2h 30m")); + assert!(!md.contains("_[How long did the incident last?]_")); + } + + #[test] + fn test_postmortem_what_went_well_with_steps() { + let mut detail = make_test_detail(); + detail.timeline_events = vec![TimelineEvent { + id: "te-1".to_string(), + issue_id: "pm-456".to_string(), + event_type: "root_cause_identified".to_string(), + description: "Root cause found".to_string(), + metadata: "{}".to_string(), + created_at: "2025-02-10 10:00:00 UTC".to_string(), + }]; + let md = generate_postmortem_markdown(&detail); + assert!(md.contains("Systematic 5-whys analysis conducted (1 steps completed)")); + assert!(md.contains("Root cause was identified during triage")); + } } diff --git a/src-tauri/src/docs/rca.rs b/src-tauri/src/docs/rca.rs index f6d508bc..c3d2bcd7 100644 --- a/src-tauri/src/docs/rca.rs +++ b/src-tauri/src/docs/rca.rs @@ -1,5 +1,48 @@ use crate::db::models::IssueDetail; +pub fn format_event_type(event_type: &str) -> &str { + match event_type { + "triage_started" => "Triage Started", + "log_uploaded" => "Log File Uploaded", + "why_level_advanced" => "Why Level Advanced", + "root_cause_identified" => "Root Cause Identified", + "rca_generated" => "RCA Document Generated", + "postmortem_generated" => "Post-Mortem Generated", + "document_exported" => "Document Exported", + other => other, + } +} + +pub fn calculate_duration(start: &str, end: &str) -> String { + let fmt = "%Y-%m-%d %H:%M:%S UTC"; + let start_dt = match chrono::NaiveDateTime::parse_from_str(start, fmt) { + Ok(dt) => dt, + Err(_) => return "N/A".to_string(), + }; + let end_dt = match chrono::NaiveDateTime::parse_from_str(end, fmt) { + Ok(dt) => dt, + Err(_) => return "N/A".to_string(), + }; + + let duration = end_dt.signed_duration_since(start_dt); + let total_minutes = duration.num_minutes(); + if total_minutes < 0 { + return "N/A".to_string(); + } + + let days = total_minutes / (24 * 60); + let hours = (total_minutes % (24 * 60)) / 60; + let minutes = total_minutes % 60; + + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {minutes}m") + } else { + format!("{minutes}m") + } +} + pub fn generate_rca_markdown(detail: &IssueDetail) -> String { let issue = &detail.issue; @@ -57,6 +100,52 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String { md.push_str("\n\n"); } + // Incident Timeline + md.push_str("## Incident Timeline\n\n"); + if detail.timeline_events.is_empty() { + md.push_str("_No timeline events recorded._\n\n"); + } else { + md.push_str("| Time (UTC) | Event | Description |\n"); + md.push_str("|------------|-------|-------------|\n"); + for event in &detail.timeline_events { + md.push_str(&format!( + "| {} | {} | {} |\n", + event.created_at, + format_event_type(&event.event_type), + event.description + )); + } + md.push('\n'); + } + + // Incident Metrics + md.push_str("## Incident Metrics\n\n"); + md.push_str(&format!( + "- **Total Events:** {}\n", + detail.timeline_events.len() + )); + if detail.timeline_events.len() >= 2 { + let first = &detail.timeline_events[0].created_at; + let last = &detail.timeline_events[detail.timeline_events.len() - 1].created_at; + md.push_str(&format!( + "- **Incident Duration:** {}\n", + calculate_duration(first, last) + )); + } else { + md.push_str("- **Incident Duration:** N/A\n"); + } + let root_cause_event = detail + .timeline_events + .iter() + .find(|e| e.event_type == "root_cause_identified"); + if let (Some(first), Some(rc)) = (detail.timeline_events.first(), root_cause_event) { + md.push_str(&format!( + "- **Time to Root Cause:** {}\n", + calculate_duration(&first.created_at, &rc.created_at) + )); + } + md.push('\n'); + // 5 Whys Analysis md.push_str("## 5 Whys Analysis\n\n"); if detail.resolution_steps.is_empty() { @@ -143,7 +232,7 @@ pub fn generate_rca_markdown(detail: &IssueDetail) -> String { #[cfg(test)] mod tests { use super::*; - use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep}; + use crate::db::models::{Issue, IssueDetail, LogFile, ResolutionStep, TimelineEvent}; fn make_test_detail() -> IssueDetail { IssueDetail { @@ -194,6 +283,7 @@ mod tests { }, ], conversations: vec![], + timeline_events: vec![], } } @@ -247,4 +337,135 @@ mod tests { let md = generate_rca_markdown(&detail); assert!(md.contains("Unassigned")); } + + #[test] + fn test_rca_timeline_section_with_events() { + let mut detail = make_test_detail(); + detail.timeline_events = vec![ + TimelineEvent { + id: "te-1".to_string(), + issue_id: "test-123".to_string(), + event_type: "triage_started".to_string(), + description: "Triage initiated by oncall".to_string(), + metadata: "{}".to_string(), + created_at: "2025-01-15 10:00:00 UTC".to_string(), + }, + TimelineEvent { + id: "te-2".to_string(), + issue_id: "test-123".to_string(), + event_type: "log_uploaded".to_string(), + description: "app.log uploaded".to_string(), + metadata: "{}".to_string(), + created_at: "2025-01-15 10:30:00 UTC".to_string(), + }, + TimelineEvent { + id: "te-3".to_string(), + issue_id: "test-123".to_string(), + event_type: "root_cause_identified".to_string(), + description: "Connection pool leak found".to_string(), + metadata: "{}".to_string(), + created_at: "2025-01-15 12:15:00 UTC".to_string(), + }, + ]; + let md = generate_rca_markdown(&detail); + assert!(md.contains("## Incident Timeline")); + assert!(md.contains("| Time (UTC) | Event | Description |")); + assert!(md + .contains("| 2025-01-15 10:00:00 UTC | Triage Started | Triage initiated by oncall |")); + assert!(md.contains("| 2025-01-15 10:30:00 UTC | Log File Uploaded | app.log uploaded |")); + assert!(md.contains( + "| 2025-01-15 12:15:00 UTC | Root Cause Identified | Connection pool leak found |" + )); + } + + #[test] + fn test_rca_timeline_section_empty() { + let detail = make_test_detail(); + let md = generate_rca_markdown(&detail); + assert!(md.contains("## Incident Timeline")); + assert!(md.contains("_No timeline events recorded._")); + } + + #[test] + fn test_rca_metrics_section() { + let mut detail = make_test_detail(); + detail.timeline_events = vec![ + TimelineEvent { + id: "te-1".to_string(), + issue_id: "test-123".to_string(), + event_type: "triage_started".to_string(), + description: "Triage started".to_string(), + metadata: "{}".to_string(), + created_at: "2025-01-15 10:00:00 UTC".to_string(), + }, + TimelineEvent { + id: "te-2".to_string(), + issue_id: "test-123".to_string(), + event_type: "root_cause_identified".to_string(), + description: "Root cause found".to_string(), + metadata: "{}".to_string(), + created_at: "2025-01-15 12:15:00 UTC".to_string(), + }, + ]; + let md = generate_rca_markdown(&detail); + assert!(md.contains("## Incident Metrics")); + assert!(md.contains("**Total Events:** 2")); + assert!(md.contains("**Incident Duration:** 2h 15m")); + assert!(md.contains("**Time to Root Cause:** 2h 15m")); + } + + #[test] + fn test_calculate_duration_hours_minutes() { + assert_eq!( + calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-15 12:15:00 UTC"), + "2h 15m" + ); + } + + #[test] + fn test_calculate_duration_days() { + assert_eq!( + calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-18 11:00:00 UTC"), + "3d 1h" + ); + } + + #[test] + fn test_calculate_duration_minutes_only() { + assert_eq!( + calculate_duration("2025-01-15 10:00:00 UTC", "2025-01-15 10:45:00 UTC"), + "45m" + ); + } + + #[test] + fn test_calculate_duration_invalid() { + assert_eq!(calculate_duration("bad-date", "also-bad"), "N/A"); + } + + #[test] + fn test_format_event_type_known() { + assert_eq!(format_event_type("triage_started"), "Triage Started"); + assert_eq!(format_event_type("log_uploaded"), "Log File Uploaded"); + assert_eq!( + format_event_type("why_level_advanced"), + "Why Level Advanced" + ); + assert_eq!( + format_event_type("root_cause_identified"), + "Root Cause Identified" + ); + assert_eq!(format_event_type("rca_generated"), "RCA Document Generated"); + assert_eq!( + format_event_type("postmortem_generated"), + "Post-Mortem Generated" + ); + assert_eq!(format_event_type("document_exported"), "Document Exported"); + } + + #[test] + fn test_format_event_type_unknown() { + assert_eq!(format_event_type("custom_event"), "custom_event"); + assert_eq!(format_event_type(""), ""); + } } diff --git a/src-tauri/src/integrations/auth.rs b/src-tauri/src/integrations/auth.rs index c91048b4..4a2e9d6e 100644 --- a/src-tauri/src/integrations/auth.rs +++ b/src-tauri/src/integrations/auth.rs @@ -629,11 +629,10 @@ mod tests { #[test] fn test_derive_aes_key_is_stable_for_same_input() { - std::env::set_var("TFTSR_ENCRYPTION_KEY", "stable-test-key"); - let k1 = derive_aes_key().unwrap(); - let k2 = derive_aes_key().unwrap(); + // Use deterministic helper to avoid env var race conditions in parallel tests + let k1 = derive_aes_key_from_str("stable-test-key").unwrap(); + let k2 = derive_aes_key_from_str("stable-test-key").unwrap(); assert_eq!(k1, k2); - std::env::remove_var("TFTSR_ENCRYPTION_KEY"); } // Test helper functions that accept key directly (bypass env var) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cdf319ba..5ee2269e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -69,6 +69,7 @@ pub fn run() { commands::db::add_five_why, commands::db::update_five_why, commands::db::add_timeline_event, + commands::db::get_timeline_events, // Analysis / PII commands::analysis::upload_log_file, commands::analysis::upload_log_file_by_content, diff --git a/src/lib/domainPrompts.ts b/src/lib/domainPrompts.ts index b57170ab..535df6c3 100644 --- a/src/lib/domainPrompts.ts +++ b/src/lib/domainPrompts.ts @@ -331,6 +331,58 @@ When analyzing identity and access issues, focus on these key areas: Always ask about the Keycloak version, realm configuration (external IdP vs local users vs LDAP), SSSD version and configured domains, and whether this is a first-time setup or a regression.`, }; +export const INCIDENT_RESPONSE_FRAMEWORK = ` + +--- + +## INCIDENT RESPONSE METHODOLOGY + +Follow this structured framework for every triage conversation. Each phase must be completed with evidence before advancing. + +### Phase 1: Detection & Evidence Gathering +- **Do NOT propose fixes** until the problem is fully understood +- Gather: error messages, timestamps, affected systems, scope of impact, recent changes +- Ask: "What changed? When did it start? Who/what is affected? What has been tried?" +- Record all evidence with UTC timestamps +- Establish a clear problem statement before proceeding + +### Phase 2: Diagnosis & Hypothesis Testing +- Apply the scientific method: form hypotheses, test them with evidence +- **The 3-Fix Rule**: If you cannot confidently identify the root cause after 3 hypotheses, STOP and reassess your assumptions — you may be looking at the wrong system or the wrong layer +- Check the most common causes first (Occam's Razor): DNS, certificates, disk space, permissions, recent deployments +- Differentiate between symptoms and causes — treat causes, not symptoms +- Use binary search to narrow scope: which component, which layer, which change + +### Phase 3: Root Cause Analysis with 5-Whys +- Each "Why" must be backed by evidence, not speculation +- If you cannot provide evidence for a "Why", state what investigation is needed to confirm +- Look for systemic issues, not just proximate causes +- The root cause should explain ALL observed symptoms, not just some +- Common root cause categories: configuration drift, capacity exhaustion, dependency failure, race condition, human error in process + +### Phase 4: Resolution & Prevention +- **Immediate fix**: What stops the bleeding right now? (rollback, restart, failover) +- **Permanent fix**: What prevents recurrence? (code fix, config change, automation) +- **Runbook update**: Document the fix for future oncall engineers +- Verify the fix resolves ALL symptoms, not just the primary one +- Monitor for regression after applying the fix + +### Phase 5: Post-Incident Review +- Calculate incident metrics: MTTD (detect), MTTA (acknowledge), MTTR (resolve) +- Conduct blameless post-mortem focused on systems and processes +- Identify action items with owners and due dates +- Categories: monitoring gaps, process improvements, technical debt, training needs +- Ask: "What would have prevented this? What would have detected it faster? What would have resolved it faster?" + +### Communication Practices +- State your current phase explicitly (e.g., "We are in Phase 2: Diagnosis") +- Summarize findings at each phase transition +- Flag assumptions clearly: "ASSUMPTION: ..." vs "CONFIRMED: ..." +- When advancing the Why level, explicitly state the evidence chain +`; + export function getDomainPrompt(domainId: string): string { - return domainPrompts[domainId] ?? ""; + const domainSpecific = domainPrompts[domainId] ?? ""; + if (!domainSpecific) return ""; + return domainSpecific + INCIDENT_RESPONSE_FRAMEWORK; } diff --git a/src/lib/tauriCommands.ts b/src/lib/tauriCommands.ts index 78ae9962..c78ede46 100644 --- a/src/lib/tauriCommands.ts +++ b/src/lib/tauriCommands.ts @@ -74,9 +74,11 @@ export interface FiveWhyEntry { export interface TimelineEvent { id: string; + issue_id: string; event_type: string; description: string; - created_at: number; + metadata: string; + created_at: string; } export interface AiConversation { @@ -104,6 +106,7 @@ export interface IssueDetail { image_attachments: ImageAttachment[]; resolution_steps: ResolutionStep[]; conversations: AiConversation[]; + timeline_events: TimelineEvent[]; } export interface IssueSummary { @@ -268,8 +271,8 @@ export interface TriageMessage { export const analyzeLogsCmd = (issueId: string, logFileIds: string[], providerConfig: ProviderConfig) => invoke("analyze_logs", { issueId, logFileIds, providerConfig }); -export const chatMessageCmd = (issueId: string, message: string, providerConfig: ProviderConfig) => - invoke("chat_message", { issueId, message, providerConfig }); +export const chatMessageCmd = (issueId: string, message: string, providerConfig: ProviderConfig, systemPrompt?: string) => + invoke("chat_message", { issueId, message, providerConfig, systemPrompt: systemPrompt ?? null }); export const listProvidersCmd = () => invoke("list_providers"); @@ -361,8 +364,11 @@ export const addFiveWhyCmd = ( export const updateFiveWhyCmd = (entryId: string, answer: string) => invoke("update_five_why", { entryId, answer }); -export const addTimelineEventCmd = (issueId: string, eventType: string, description: string) => - invoke("add_timeline_event", { issueId, eventType, description }); +export const addTimelineEventCmd = (issueId: string, eventType: string, description: string, metadata?: string) => + invoke("add_timeline_event", { issueId, eventType, description, metadata: metadata ?? null }); + +export const getTimelineEventsCmd = (issueId: string) => + invoke("get_timeline_events", { issueId }); // ─── Document commands ──────────────────────────────────────────────────────── diff --git a/src/pages/Postmortem/index.tsx b/src/pages/Postmortem/index.tsx index 82e423d7..e3788206 100644 --- a/src/pages/Postmortem/index.tsx +++ b/src/pages/Postmortem/index.tsx @@ -5,7 +5,7 @@ import { DocEditor } from "@/components/DocEditor"; import { useSettingsStore } from "@/stores/settingsStore"; import { generatePostmortemCmd, - + addTimelineEventCmd, updateDocumentCmd, exportDocumentCmd, type Document_, @@ -28,6 +28,7 @@ export default function Postmortem() { const generated = await generatePostmortemCmd(id); setDoc(generated); setContent(generated.content_md); + addTimelineEventCmd(id, "postmortem_generated", "Post-mortem document generated").catch(() => {}); } catch (err) { setError(String(err)); } finally { @@ -54,6 +55,7 @@ export default function Postmortem() { try { const path = await exportDocumentCmd(doc.id, doc.title, content, format, ""); setError(`Document exported to: ${path}`); + addTimelineEventCmd(id!, "document_exported", `Post-mortem exported as ${format}`).catch(() => {}); setTimeout(() => setError(null), 5000); } catch (err) { setError(`Export failed: ${String(err)}`); diff --git a/src/pages/RCA/index.tsx b/src/pages/RCA/index.tsx index 0273816a..46d2389f 100644 --- a/src/pages/RCA/index.tsx +++ b/src/pages/RCA/index.tsx @@ -8,6 +8,7 @@ import { generateRcaCmd, updateDocumentCmd, exportDocumentCmd, + addTimelineEventCmd, type Document_, } from "@/lib/tauriCommands"; @@ -29,6 +30,7 @@ export default function RCA() { const generated = await generateRcaCmd(id); setDoc(generated); setContent(generated.content_md); + addTimelineEventCmd(id, "rca_generated", "RCA document generated").catch(() => {}); } catch (err) { setError(String(err)); } finally { @@ -55,6 +57,7 @@ export default function RCA() { try { const path = await exportDocumentCmd(doc.id, doc.title, content, format, ""); setError(`Document exported to: ${path}`); + addTimelineEventCmd(id!, "document_exported", `RCA exported as ${format}`).catch(() => {}); setTimeout(() => setError(null), 5000); } catch (err) { setError(`Export failed: ${String(err)}`); diff --git a/src/pages/Triage/index.tsx b/src/pages/Triage/index.tsx index c34211ff..ab4aa9c8 100644 --- a/src/pages/Triage/index.tsx +++ b/src/pages/Triage/index.tsx @@ -15,6 +15,7 @@ import { updateIssueCmd, addFiveWhyCmd, } from "@/lib/tauriCommands"; +import { getDomainPrompt } from "@/lib/domainPrompts"; import type { TriageMessage } from "@/lib/tauriCommands"; const CLOSE_PATTERNS = [ @@ -167,7 +168,8 @@ export default function Triage() { setPendingFiles([]); try { - const response = await chatMessageCmd(id, aiMessage, provider); + const systemPrompt = currentIssue ? getDomainPrompt(currentIssue.category) : undefined; + const response = await chatMessageCmd(id, aiMessage, provider, systemPrompt); const assistantMsg: TriageMessage = { id: `asst-${Date.now()}`, issue_id: id, diff --git a/tests/unit/domainPrompts.test.ts b/tests/unit/domainPrompts.test.ts new file mode 100644 index 00000000..8a29e7ed --- /dev/null +++ b/tests/unit/domainPrompts.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "vitest"; +import { getDomainPrompt, DOMAINS, INCIDENT_RESPONSE_FRAMEWORK } from "@/lib/domainPrompts"; + +describe("Domain Prompts with Incident Response Framework", () => { + it("exports INCIDENT_RESPONSE_FRAMEWORK constant", () => { + expect(INCIDENT_RESPONSE_FRAMEWORK).toBeDefined(); + expect(typeof INCIDENT_RESPONSE_FRAMEWORK).toBe("string"); + expect(INCIDENT_RESPONSE_FRAMEWORK.length).toBeGreaterThan(100); + }); + + it("framework contains all 5 phases", () => { + expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 1: Detection & Evidence Gathering"); + expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 2: Diagnosis & Hypothesis Testing"); + expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 3: Root Cause Analysis with 5-Whys"); + expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 4: Resolution & Prevention"); + expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Phase 5: Post-Incident Review"); + }); + + it("framework contains the 3-Fix Rule", () => { + expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("3-Fix Rule"); + }); + + it("framework contains communication practices", () => { + expect(INCIDENT_RESPONSE_FRAMEWORK).toContain("Communication Practices"); + }); + + it("all defined domains include incident response methodology", () => { + for (const domain of DOMAINS) { + const prompt = getDomainPrompt(domain.id); + if (prompt) { + expect(prompt).toContain("INCIDENT RESPONSE METHODOLOGY"); + expect(prompt).toContain("Phase 1:"); + expect(prompt).toContain("Phase 5:"); + } + } + }); + + it("returns empty string for unknown domain", () => { + expect(getDomainPrompt("nonexistent_domain")).toBe(""); + expect(getDomainPrompt("")).toBe(""); + }); + + it("preserves existing Linux domain content", () => { + const prompt = getDomainPrompt("linux"); + expect(prompt).toContain("senior Linux systems engineer"); + expect(prompt).toContain("RHEL"); + expect(prompt).toContain("INCIDENT RESPONSE METHODOLOGY"); + }); + + it("preserves existing Kubernetes domain content", () => { + const prompt = getDomainPrompt("kubernetes"); + expect(prompt).toContain("Kubernetes platform engineer"); + expect(prompt).toContain("k3s"); + expect(prompt).toContain("INCIDENT RESPONSE METHODOLOGY"); + }); + + it("preserves existing Network domain content", () => { + const prompt = getDomainPrompt("network"); + expect(prompt).toContain("network engineer"); + expect(prompt).toContain("Fortigate"); + expect(prompt).toContain("INCIDENT RESPONSE METHODOLOGY"); + }); +}); diff --git a/tests/unit/resolution.test.tsx b/tests/unit/resolution.test.tsx index c2429853..b19938b8 100644 --- a/tests/unit/resolution.test.tsx +++ b/tests/unit/resolution.test.tsx @@ -35,6 +35,7 @@ const mockIssueDetail = { }, ], conversations: [], + timeline_events: [], }; describe("Resolution Page", () => { diff --git a/tests/unit/timelineEvents.test.ts b/tests/unit/timelineEvents.test.ts new file mode 100644 index 00000000..c23f5928 --- /dev/null +++ b/tests/unit/timelineEvents.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; + +const mockInvoke = vi.mocked(invoke); + +describe("Timeline Event Commands", () => { + beforeEach(() => { + mockInvoke.mockReset(); + }); + + it("addTimelineEventCmd calls invoke with correct params", async () => { + const mockEvent = { + id: "te-1", + issue_id: "issue-1", + event_type: "triage_started", + description: "Started", + metadata: "{}", + created_at: "2025-01-15 10:00:00 UTC", + }; + mockInvoke.mockResolvedValueOnce(mockEvent as never); + + const { addTimelineEventCmd } = await import("@/lib/tauriCommands"); + const result = await addTimelineEventCmd("issue-1", "triage_started", "Started"); + expect(mockInvoke).toHaveBeenCalledWith("add_timeline_event", { + issueId: "issue-1", + eventType: "triage_started", + description: "Started", + metadata: null, + }); + expect(result).toEqual(mockEvent); + }); + + it("addTimelineEventCmd passes metadata when provided", async () => { + mockInvoke.mockResolvedValueOnce({} as never); + + const { addTimelineEventCmd } = await import("@/lib/tauriCommands"); + await addTimelineEventCmd("issue-1", "log_uploaded", "File uploaded", '{"file":"app.log"}'); + expect(mockInvoke).toHaveBeenCalledWith("add_timeline_event", { + issueId: "issue-1", + eventType: "log_uploaded", + description: "File uploaded", + metadata: '{"file":"app.log"}', + }); + }); + + it("getTimelineEventsCmd calls invoke with correct params", async () => { + mockInvoke.mockResolvedValueOnce([] as never); + + const { getTimelineEventsCmd } = await import("@/lib/tauriCommands"); + const result = await getTimelineEventsCmd("issue-1"); + expect(mockInvoke).toHaveBeenCalledWith("get_timeline_events", { issueId: "issue-1" }); + expect(result).toEqual([]); + }); +});