orchai/docs/superpowers/plans/2026-04-17-graylog-auto-resolve-implementation.md

65 KiB

Graylog Auto-Resolve Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Add a project-scoped Graylog module that detects recurring error/warning patterns, scores them deterministically, and automatically routes high-score subjects through the existing analyst -> developer worktree pipeline.

Architecture: Keep one unified processing queue (processed_tickets) and extend it to support multiple sources (tuleap, graylog). Add dedicated Graylog storage (graylog_credentials, graylog_subjects, graylog_detections), a Graylog polling service, and a queue bridge that inserts Graylog tickets only when score exceeds threshold and dedup allows it. Frontend gets a Graylog project page, module toggle visibility, and live activity hooks.

Tech Stack: Rust (Tauri, rusqlite, reqwest, tokio), SQLite migrations, React + TypeScript.


Scope Check

The spec is one cohesive subsystem (Graylog detection + queue bridge + existing pipeline reuse). It can be implemented as a single plan without splitting into sub-plans.

File Structure

src-tauri/
  migrations/
    009_graylog_auto_resolve.sql                 # create: Graylog tables + processed_tickets multi-source migration
  src/
    db.rs                                        # modify: register migration 009, update schema tests
    lib.rs                                       # modify: start graylog poller, register graylog commands
    models/
      mod.rs                                     # modify: export graylog model
      module.rs                                  # modify: add Graylog module default
      ticket.rs                                  # modify: multi-source ticket fields/query/insert helpers
      graylog.rs                                 # create: credentials/subjects/detections model methods
    services/
      mod.rs                                     # modify: export graylog services
      graylog_client.rs                          # create: Graylog HTTP API client + parsing helpers
      graylog_scoring.rs                         # create: normalization/grouping/scoring logic
      graylog_poller.rs                          # create: periodic poller + dedup + queue insertion
      orchestrator.rs                            # modify: source-aware agent resolution + prompt context
    commands/
      mod.rs                                     # modify: export graylog commands
      graylog.rs                                 # create: set/get/delete/test/manual poll/list subjects/list detections
      orchestrator.rs                            # modify: retry path uses ticket project_id fallback-safe

src/
  lib/
    types.ts                                     # modify: Graylog types + ProcessedTicket new fields
    api.ts                                       # modify: Graylog API wrappers
  components/
    projects/
      ProjectDashboard.tsx                       # modify: Graylog activity events + Graylog entry card
      ProjectModules.tsx                         # modify: show module config link for Graylog card
      ProjectGraylog.tsx                         # create: Graylog config + subjects + detections UI
    tickets/
      TicketList.tsx                             # modify: source badge + filters remain stable
      TicketDetail.tsx                           # modify: source/project info rendering for Graylog tickets
  App.tsx                                        # modify: add /projects/:projectId/graylog route

Task 1: Add migration 009 and bump DB schema version

Files:

  • Create: src-tauri/migrations/009_graylog_auto_resolve.sql

  • Modify: src-tauri/src/db.rs

  • Step 1: Add failing DB tests for new version and new tables

In src-tauri/src/db.rs, update expected table list and user_version assertion:

        assert_eq!(
            tables,
            vec![
                "agents",
                "graylog_credentials",
                "graylog_detections",
                "graylog_subjects",
                "notifications",
                "processed_tickets",
                "project_agent_tasks",
                "project_live_messages",
                "project_live_sessions",
                "project_modules",
                "projects",
                "tuleap_credentials",
                "watched_trackers",
                "worktrees",
            ]
        );
        assert_eq!(version, 9);
  • Step 2: Run DB tests to verify failure before migration wiring

Run:

cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib db::tests -- --nocapture

Expected: FAIL because migration 009 is not registered yet.

  • Step 3: Create migration 009 with processed_tickets multi-source migration and Graylog tables

Create src-tauri/migrations/009_graylog_auto_resolve.sql:

BEGIN;

PRAGMA foreign_keys = OFF;

DROP INDEX IF EXISTS idx_processed_tickets_tracker_artifact_unique;

CREATE TABLE processed_tickets_new (
    id TEXT PRIMARY KEY,
    tracker_id TEXT REFERENCES watched_trackers(id) ON DELETE CASCADE,
    project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
    source TEXT NOT NULL DEFAULT 'tuleap',
    source_ref TEXT,
    artifact_id INTEGER NOT NULL,
    artifact_title TEXT NOT NULL,
    artifact_data TEXT NOT NULL,
    status TEXT NOT NULL DEFAULT 'Pending',
    analyst_report TEXT,
    developer_report TEXT,
    worktree_path TEXT,
    branch_name TEXT,
    detected_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    processed_at TEXT
);

INSERT INTO processed_tickets_new (
    id,
    tracker_id,
    project_id,
    source,
    source_ref,
    artifact_id,
    artifact_title,
    artifact_data,
    status,
    analyst_report,
    developer_report,
    worktree_path,
    branch_name,
    detected_at,
    processed_at
)
SELECT
    pt.id,
    pt.tracker_id,
    wt.project_id,
    'tuleap',
    NULL,
    pt.artifact_id,
    pt.artifact_title,
    pt.artifact_data,
    pt.status,
    pt.analyst_report,
    pt.developer_report,
    pt.worktree_path,
    pt.branch_name,
    pt.detected_at,
    pt.processed_at
FROM processed_tickets pt
LEFT JOIN watched_trackers wt ON wt.id = pt.tracker_id;

DROP TABLE processed_tickets;
ALTER TABLE processed_tickets_new RENAME TO processed_tickets;

CREATE UNIQUE INDEX idx_processed_tickets_tracker_artifact_unique
ON processed_tickets(tracker_id, artifact_id)
WHERE tracker_id IS NOT NULL;

CREATE INDEX idx_processed_tickets_project_detected
ON processed_tickets(project_id, detected_at DESC);

CREATE INDEX idx_processed_tickets_source_ref
ON processed_tickets(source, source_ref);

CREATE TABLE graylog_credentials (
    id TEXT PRIMARY KEY,
    project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
    base_url TEXT NOT NULL,
    api_token_encrypted TEXT NOT NULL,
    analyst_agent_id TEXT NOT NULL REFERENCES agents(id),
    developer_agent_id TEXT NOT NULL REFERENCES agents(id),
    stream_id TEXT,
    query_filter TEXT NOT NULL DEFAULT '',
    polling_interval_minutes INTEGER NOT NULL DEFAULT 10,
    lookback_minutes INTEGER NOT NULL DEFAULT 30,
    score_threshold INTEGER NOT NULL DEFAULT 70,
    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

CREATE TABLE graylog_subjects (
    id TEXT PRIMARY KEY,
    project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    subject_key TEXT NOT NULL,
    source TEXT NOT NULL,
    normalized_message TEXT NOT NULL,
    first_seen_at TEXT NOT NULL,
    last_seen_at TEXT NOT NULL,
    last_score INTEGER NOT NULL DEFAULT 0,
    active_ticket_id TEXT REFERENCES processed_tickets(id),
    status TEXT NOT NULL DEFAULT 'idle',
    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
    UNIQUE(project_id, subject_key)
);

CREATE TABLE graylog_detections (
    id TEXT PRIMARY KEY,
    subject_id TEXT NOT NULL REFERENCES graylog_subjects(id) ON DELETE CASCADE,
    window_start TEXT NOT NULL,
    window_end TEXT NOT NULL,
    critical_count INTEGER NOT NULL DEFAULT 0,
    error_count INTEGER NOT NULL DEFAULT 0,
    warning_count INTEGER NOT NULL DEFAULT 0,
    total_count INTEGER NOT NULL DEFAULT 0,
    last_seen_at TEXT NOT NULL,
    score INTEGER NOT NULL,
    triggered INTEGER NOT NULL DEFAULT 0,
    triggered_ticket_id TEXT REFERENCES processed_tickets(id),
    created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);

CREATE INDEX idx_graylog_detections_subject_created
ON graylog_detections(subject_id, created_at DESC);

PRAGMA foreign_keys = ON;

COMMIT;
  • Step 4: Register migration 009 in db.rs

Add constants and version bump:

const MIGRATION_009: &str = include_str!("../migrations/009_graylog_auto_resolve.sql");
    if version < 9 {
        conn.execute_batch(MIGRATION_009)?;
        conn.pragma_update(None, "user_version", 9)?;
    }
  • Step 5: Run DB tests and full backend tests

Run:

cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib db::tests -- --nocapture
cargo test --lib -- --nocapture

Expected:

  • DB tests PASS with version 9.

  • No migration regression in existing model tests.

  • Step 6: Commit

cd /home/leclere/Projets/IA/orchai
git add src-tauri/migrations/009_graylog_auto_resolve.sql src-tauri/src/db.rs
git commit -m "feat(db): add graylog schema and processed_tickets multi-source migration"

Task 2: Extend ProcessedTicket for multi-source queue

Files:

  • Modify: src-tauri/src/models/ticket.rs

  • Step 1: Add failing tests for new ticket shape and project-scoped queries

Add tests in ticket.rs:

    #[test]
    fn test_insert_if_new_sets_project_and_source_for_tuleap() {
        let (conn, tracker_id) = setup();
        let project_id = project_id_for_tracker(&conn, &tracker_id);

        let ticket = ProcessedTicket::insert_if_new(
            &conn,
            &project_id,
            &tracker_id,
            777,
            "Tracker issue",
            "{}",
        )
        .unwrap()
        .unwrap();

        assert_eq!(ticket.project_id, project_id);
        assert_eq!(ticket.source, "tuleap");
        assert_eq!(ticket.source_ref, None);
        assert_eq!(ticket.tracker_id.as_deref(), Some(tracker_id.as_str()));
    }

    #[test]
    fn test_insert_external_graylog_ticket() {
        let (conn, tracker_id) = setup();
        let project_id = project_id_for_tracker(&conn, &tracker_id);

        let ticket = ProcessedTicket::insert_external(
            &conn,
            &project_id,
            "graylog",
            Some("subject-1"),
            -42,
            "[Graylog] api - timeout",
            "{\"score\":83}",
        )
        .unwrap();

        assert_eq!(ticket.project_id, project_id);
        assert_eq!(ticket.source, "graylog");
        assert_eq!(ticket.source_ref.as_deref(), Some("subject-1"));
        assert!(ticket.tracker_id.is_none());
    }
  • Step 2: Run ticket model tests and confirm compilation fails

Run:

cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib models::ticket::tests -- --nocapture

Expected: FAIL because project_id/source/source_ref fields and new methods do not exist yet.

  • Step 3: Implement multi-source fields and insert/query helpers

Update ProcessedTicket and SQL mapping:

pub struct ProcessedTicket {
    pub id: String,
    pub tracker_id: Option<String>,
    pub project_id: String,
    pub source: String,
    pub source_ref: Option<String>,
    pub artifact_id: i32,
    pub artifact_title: String,
    pub artifact_data: String,
    pub status: String,
    pub analyst_report: Option<String>,
    pub developer_report: Option<String>,
    pub worktree_path: Option<String>,
    pub branch_name: Option<String>,
    pub detected_at: String,
    pub processed_at: Option<String>,
}
const SELECT_ALL_COLS: &str = "SELECT id, tracker_id, project_id, source, source_ref, \
    artifact_id, artifact_title, artifact_data, status, analyst_report, developer_report, \
    worktree_path, branch_name, detected_at, processed_at FROM processed_tickets";
pub fn insert_if_new(
    conn: &Connection,
    project_id: &str,
    tracker_id: &str,
    artifact_id: i32,
    artifact_title: &str,
    artifact_data: &str,
) -> Result<Option<ProcessedTicket>> {
    let id = Uuid::new_v4().to_string();
    let now = chrono::Utc::now().to_rfc3339();

    let inserted_rows = conn.execute(
        "INSERT OR IGNORE INTO processed_tickets \
         (id, tracker_id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, detected_at) \
         VALUES (?1, ?2, ?3, 'tuleap', NULL, ?4, ?5, ?6, 'Pending', ?7)",
        params![id, tracker_id, project_id, artifact_id, artifact_title, artifact_data, now],
    )?;

    if inserted_rows == 0 {
        return Ok(None);
    }

    Ok(Some(ProcessedTicket {
        id,
        tracker_id: Some(tracker_id.to_string()),
        project_id: project_id.to_string(),
        source: "tuleap".to_string(),
        source_ref: None,
        artifact_id,
        artifact_title: artifact_title.to_string(),
        artifact_data: artifact_data.to_string(),
        status: "Pending".to_string(),
        analyst_report: None,
        developer_report: None,
        worktree_path: None,
        branch_name: None,
        detected_at: now,
        processed_at: None,
    }))
}
pub fn insert_external(
    conn: &Connection,
    project_id: &str,
    source: &str,
    source_ref: Option<&str>,
    artifact_id: i32,
    artifact_title: &str,
    artifact_data: &str,
) -> Result<ProcessedTicket> {
    let id = Uuid::new_v4().to_string();
    let now = chrono::Utc::now().to_rfc3339();
    conn.execute(
        "INSERT INTO processed_tickets \
         (id, tracker_id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, status, detected_at) \
         VALUES (?1, NULL, ?2, ?3, ?4, ?5, ?6, ?7, 'Pending', ?8)",
        params![id, project_id, source, source_ref, artifact_id, artifact_title, artifact_data, now],
    )?;
    Ok(ProcessedTicket {
        id,
        tracker_id: None,
        project_id: project_id.to_string(),
        source: source.to_string(),
        source_ref: source_ref.map(str::to_string),
        artifact_id,
        artifact_title: artifact_title.to_string(),
        artifact_data: artifact_data.to_string(),
        status: "Pending".to_string(),
        analyst_report: None,
        developer_report: None,
        worktree_path: None,
        branch_name: None,
        detected_at: now,
        processed_at: None,
    })
}
pub fn list_by_project(conn: &Connection, project_id: &str) -> Result<Vec<ProcessedTicket>> {
    let sql = format!(
        "{} WHERE project_id = ?1 ORDER BY detected_at DESC",
        SELECT_ALL_COLS
    );
    let mut stmt = conn.prepare(&sql)?;
    let rows = stmt.query_map(params![project_id], from_row)?;
    rows.collect()
}
pub fn get_project_throughput_stats(
    conn: &Connection,
    project_id: &str,
) -> Result<ProjectThroughputStats> {
    let window_start = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339();
    conn.query_row(
        "SELECT
            COALESCE(SUM(CASE WHEN status NOT IN ('Done','Error','Cancelled') THEN 1 ELSE 0 END), 0),
            COALESCE(SUM(CASE WHEN status = 'Done' AND processed_at IS NOT NULL AND julianday(processed_at) >= julianday(?2) THEN 1 ELSE 0 END), 0),
            COALESCE(SUM(CASE WHEN status = 'Error' AND processed_at IS NOT NULL AND julianday(processed_at) >= julianday(?2) THEN 1 ELSE 0 END), 0),
            AVG(CASE WHEN status IN ('Done','Error') AND processed_at IS NOT NULL AND julianday(processed_at) >= julianday(?2)
                THEN (julianday(processed_at) - julianday(detected_at)) * 86400.0
                ELSE NULL END)
         FROM processed_tickets
         WHERE project_id = ?1",
        params![project_id, window_start],
        |row| {
            Ok(ProjectThroughputStats {
                backlog_count: row.get(0)?,
                done_last_24h: row.get(1)?,
                error_last_24h: row.get(2)?,
                avg_lead_time_seconds: row.get(3)?,
            })
        },
    )
}
  • Step 4: Update all call sites using insert_if_new

Adjust:

  • src-tauri/src/services/poller.rs
  • src-tauri/src/commands/poller.rs

Snippet to apply at both call sites:

if let Some(ticket) = ProcessedTicket::insert_if_new(
    &db,
    &tracker.project_id,
    &tracker.id,
    artifact_id,
    &artifact_title,
    &artifact_data,
)? {
    // existing flow
}
  • Step 5: Run ticket tests and backend regression tests

Run:

cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib models::ticket::tests -- --nocapture
cargo test --lib commands::poller -- --nocapture

Expected: PASS.

  • Step 6: Commit
cd /home/leclere/Projets/IA/orchai
git add src-tauri/src/models/ticket.rs src-tauri/src/services/poller.rs src-tauri/src/commands/poller.rs
git commit -m "feat(queue): support multi-source processed tickets"

Task 3: Add Graylog model layer and module defaults

Files:

  • Create: src-tauri/src/models/graylog.rs

  • Modify: src-tauri/src/models/mod.rs

  • Modify: src-tauri/src/models/module.rs

  • Step 1: Add failing tests for Graylog credentials and subjects

In new graylog.rs test module, add:

#[test]
fn test_upsert_graylog_credentials_for_project() {
    let conn = db::init_in_memory().unwrap();
    let project = Project::insert(&conn, "Gray", "/tmp/gray", None, "main").unwrap();
    let analyst = Agent::insert(&conn, "A", AgentRole::Analyst, AgentTool::Codex, "").unwrap();
    let developer = Agent::insert(&conn, "D", AgentRole::Developer, AgentTool::ClaudeCode, "").unwrap();

    let creds = GraylogCredentials::upsert_for_project(
        &conn,
        &project.id,
        "https://graylog.local",
        "enc-token",
        &analyst.id,
        &developer.id,
        Some("stream-1"),
        "level:(critical OR error)",
        10,
        30,
        70,
    ).unwrap();

    assert_eq!(creds.project_id, project.id);
    assert_eq!(creds.base_url, "https://graylog.local");
}
  • Step 2: Implement graylog.rs with credentials, subjects, detections

Create src-tauri/src/models/graylog.rs with these core structs and methods:

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraylogCredentials {
    pub id: String,
    pub project_id: String,
    pub base_url: String,
    pub api_token_encrypted: String,
    pub analyst_agent_id: String,
    pub developer_agent_id: String,
    pub stream_id: Option<String>,
    pub query_filter: String,
    pub polling_interval_minutes: i32,
    pub lookback_minutes: i32,
    pub score_threshold: i32,
    pub created_at: String,
    pub updated_at: String,
}
pub fn upsert_for_project(
    conn: &Connection,
    project_id: &str,
    base_url: &str,
    api_token_encrypted: &str,
    analyst_agent_id: &str,
    developer_agent_id: &str,
    stream_id: Option<&str>,
    query_filter: &str,
    polling_interval_minutes: i32,
    lookback_minutes: i32,
    score_threshold: i32,
) -> Result<GraylogCredentials> {
    let now = chrono::Utc::now().to_rfc3339();
    conn.execute(
        "INSERT INTO graylog_credentials (
            id, project_id, base_url, api_token_encrypted, analyst_agent_id, developer_agent_id,
            stream_id, query_filter, polling_interval_minutes, lookback_minutes, score_threshold,
            created_at, updated_at
         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
         ON CONFLICT(project_id) DO UPDATE SET
            base_url = excluded.base_url,
            api_token_encrypted = excluded.api_token_encrypted,
            analyst_agent_id = excluded.analyst_agent_id,
            developer_agent_id = excluded.developer_agent_id,
            stream_id = excluded.stream_id,
            query_filter = excluded.query_filter,
            polling_interval_minutes = excluded.polling_interval_minutes,
            lookback_minutes = excluded.lookback_minutes,
            score_threshold = excluded.score_threshold,
            updated_at = excluded.updated_at",
        params![
            Uuid::new_v4().to_string(),
            project_id,
            base_url,
            api_token_encrypted,
            analyst_agent_id,
            developer_agent_id,
            stream_id,
            query_filter,
            polling_interval_minutes,
            lookback_minutes,
            score_threshold,
            now,
            now
        ],
    )?;
    GraylogCredentials::get_by_project(conn, project_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)
}
pub fn upsert_subject(
    conn: &Connection,
    project_id: &str,
    subject_key: &str,
    source: &str,
    normalized_message: &str,
    last_seen_at: &str,
    last_score: i32,
) -> Result<GraylogSubject> {
    let now = chrono::Utc::now().to_rfc3339();
    conn.execute(
        "INSERT INTO graylog_subjects (
            id, project_id, subject_key, source, normalized_message, first_seen_at, last_seen_at,
            last_score, status, created_at, updated_at
         ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?7, 'idle', ?8, ?8)
         ON CONFLICT(project_id, subject_key) DO UPDATE SET
            source = excluded.source,
            normalized_message = excluded.normalized_message,
            last_seen_at = excluded.last_seen_at,
            last_score = excluded.last_score,
            updated_at = excluded.updated_at",
        params![Uuid::new_v4().to_string(), project_id, subject_key, source, normalized_message, last_seen_at, last_score, now],
    )?;
    GraylogSubject::get_by_project_and_key(conn, project_id, subject_key)?
        .ok_or(rusqlite::Error::QueryReturnedNoRows)
}
  • Step 3: Wire model exports and module default

In src-tauri/src/models/mod.rs:

pub mod graylog;

In src-tauri/src/models/module.rs:

pub const MODULE_GRAYLOG_AUTO_RESOLVE: &str = "graylog_polling_auto_resolve";

And add default insertion:

        insert_default(
            conn,
            project_id,
            MODULE_GRAYLOG_AUTO_RESOLVE,
            "Polling Graylog + auto-resolve",
            "Surveille Graylog, score les sujets, et déclenche le pipeline analyste/developpeur.",
        )?;
  • Step 4: Run focused model tests

Run:

cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib models::graylog -- --nocapture
cargo test --lib models::module -- --nocapture

Expected: PASS.

  • Step 5: Commit
cd /home/leclere/Projets/IA/orchai
git add src-tauri/src/models/graylog.rs src-tauri/src/models/mod.rs src-tauri/src/models/module.rs
git commit -m "feat(graylog): add model layer and project module default"

Task 4: Implement Graylog scoring engine (normalize/group/score)

Files:

  • Create: src-tauri/src/services/graylog_scoring.rs

  • Modify: src-tauri/src/services/mod.rs

  • Modify: src-tauri/Cargo.toml

  • Step 1: Add failing tests for normalization and score

In graylog_scoring.rs tests:

#[test]
fn test_normalize_message_replaces_dynamic_values() {
    let message = "User 42 failed from 10.0.0.1 at 2026-04-17T10:00:00Z request=9f8b-12ab";
    let normalized = normalize_message(message);
    assert!(normalized.contains("<num>"));
    assert!(normalized.contains("<ip>"));
    assert!(normalized.contains("<ts>"));
}

#[test]
fn test_compute_score_critical_recent_is_high() {
    let score = compute_score(SeverityCounts { critical: 1, error: 0, warning: 0 }, 2, 30);
    assert!(score >= 70);
}
  • Step 2: Implement scoring primitives and grouping

Create graylog_scoring.rs:

use regex::Regex;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraylogEvent {
    pub timestamp: String,
    pub source: String,
    pub service: Option<String>,
    pub level: String,
    pub message: String,
    pub raw: serde_json::Value,
}

#[derive(Debug, Clone, Copy, Default)]
pub struct SeverityCounts {
    pub critical: i32,
    pub error: i32,
    pub warning: i32,
}

#[derive(Debug, Clone)]
pub struct SubjectAggregate {
    pub subject_key: String,
    pub source: String,
    pub normalized_message: String,
    pub counts: SeverityCounts,
    pub total_count: i32,
    pub last_seen_age_minutes: i64,
    pub last_seen_at: String,
    pub sample_events: Vec<serde_json::Value>,
    pub score: i32,
}

pub fn normalize_message(input: &str) -> String {
    let mut value = input.to_lowercase();
    let uuid_re = Regex::new(r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b").unwrap();
    let ip_re = Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").unwrap();
    let ts_re = Regex::new(r"\b\d{4}-\d{2}-\d{2}t\d{2}:\d{2}:\d{2}(?:\.\d+)?z\b").unwrap();
    let hash_re = Regex::new(r"\b[0-9a-f]{12,}\b").unwrap();
    let num_re = Regex::new(r"\b\d+\b").unwrap();
    let ws_re = Regex::new(r"\s+").unwrap();

    value = uuid_re.replace_all(&value, "<uuid>").to_string();
    value = ip_re.replace_all(&value, "<ip>").to_string();
    value = ts_re.replace_all(&value, "<ts>").to_string();
    value = hash_re.replace_all(&value, "<hash>").to_string();
    value = num_re.replace_all(&value, "<num>").to_string();
    ws_re.replace_all(&value, " ").trim().to_string()
}

pub fn compute_score(counts: SeverityCounts, total_count: i32, last_seen_age_minutes: i64) -> i32 {
    let severity_score = if counts.critical > 0 {
        50
    } else if counts.error > 0 {
        35
    } else if counts.warning > 0 {
        20
    } else {
        0
    };

    let frequency_score = match total_count {
        0 => 0,
        1 => 5,
        2..=3 => 12,
        4..=7 => 22,
        8..=15 => 30,
        _ => 35,
    };

    let recency_score = if last_seen_age_minutes <= 2 {
        15
    } else if last_seen_age_minutes <= 10 {
        12
    } else if last_seen_age_minutes <= 30 {
        8
    } else if last_seen_age_minutes <= 120 {
        4
    } else {
        0
    };

    severity_score + frequency_score + recency_score
}

pub fn group_subjects(events: &[GraylogEvent], now: chrono::DateTime<chrono::Utc>) -> Vec<SubjectAggregate> {
    let mut map: HashMap<String, SubjectAggregate> = HashMap::new();
    for event in events {
        let source = event.service.clone().unwrap_or_else(|| event.source.clone());
        let normalized_message = normalize_message(&event.message);
        let subject_key = format!("{source}|{normalized_message}");
        let event_time = chrono::DateTime::parse_from_rfc3339(&event.timestamp)
            .map(|dt| dt.with_timezone(&chrono::Utc))
            .unwrap_or(now);
        let age_minutes = now.signed_duration_since(event_time).num_minutes().max(0);

        let entry = map.entry(subject_key.clone()).or_insert_with(|| SubjectAggregate {
            subject_key: subject_key.clone(),
            source: source.clone(),
            normalized_message: normalized_message.clone(),
            counts: SeverityCounts::default(),
            total_count: 0,
            last_seen_age_minutes: age_minutes,
            last_seen_at: event.timestamp.clone(),
            sample_events: Vec::new(),
            score: 0,
        });

        entry.total_count += 1;
        let level = event.level.to_lowercase();
        if level.contains("critical") {
            entry.counts.critical += 1;
        } else if level.contains("error") {
            entry.counts.error += 1;
        } else if level.contains("warn") {
            entry.counts.warning += 1;
        }

        if age_minutes < entry.last_seen_age_minutes {
            entry.last_seen_age_minutes = age_minutes;
            entry.last_seen_at = event.timestamp.clone();
        }
        if entry.sample_events.len() < 5 {
            entry.sample_events.push(event.raw.clone());
        }
    }

    let mut out: Vec<SubjectAggregate> = map
        .into_values()
        .map(|mut aggregate| {
            aggregate.score = compute_score(
                aggregate.counts,
                aggregate.total_count,
                aggregate.last_seen_age_minutes,
            );
            aggregate
        })
        .collect();

    out.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| b.total_count.cmp(&a.total_count)));
    out
}
  • Step 3: Add Rust dependencies required by scoring/client utilities

In src-tauri/Cargo.toml under [dependencies]:

regex = "1"
urlencoding = "2"
  • Step 4: Export service module

In src-tauri/src/services/mod.rs:

pub mod graylog_scoring;
  • Step 5: Run scoring tests

Run:

cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib services::graylog_scoring -- --nocapture

Expected: PASS.

  • Step 6: Commit
cd /home/leclere/Projets/IA/orchai
git add src-tauri/src/services/graylog_scoring.rs src-tauri/src/services/mod.rs src-tauri/Cargo.toml
git commit -m "feat(graylog): add deterministic scoring and subject grouping"

Task 5: Implement Graylog API client and backend commands

Files:

  • Create: src-tauri/src/services/graylog_client.rs

  • Create: src-tauri/src/commands/graylog.rs

  • Modify: src-tauri/src/commands/mod.rs

  • Modify: src-tauri/src/lib.rs

  • Step 1: Add failing tests for Graylog payload parsing

In graylog_client.rs tests:

#[test]
fn test_parse_search_response_extracts_events() {
    let payload = serde_json::json!({
        "messages": [
            { "message": { "timestamp": "2026-04-17T10:00:00.000Z", "source": "api-1", "level": "error", "message": "timeout id=42", "service": "api" } }
        ]
    });
    let events = parse_search_response(&payload);
    assert_eq!(events.len(), 1);
    assert_eq!(events[0].source, "api-1");
    assert_eq!(events[0].service.as_deref(), Some("api"));
}
  • Step 2: Implement Graylog client

Create src-tauri/src/services/graylog_client.rs:

use crate::services::graylog_scoring::GraylogEvent;
use serde_json::Value;
use std::time::Instant;
use tokio::time::{sleep, Duration};

pub struct GraylogClient {
    http: reqwest::Client,
    base_url: String,
    token: String,
}

impl GraylogClient {
    pub fn new(http: &reqwest::Client, base_url: &str, token: &str) -> Self {
        Self {
            http: http.clone(),
            base_url: base_url.trim_end_matches('/').to_string(),
            token: token.to_string(),
        }
    }

    async fn send_get(&self, url: &str) -> Result<reqwest::Response, String> {
        const MAX_ATTEMPTS: u32 = 3;
        const BASE_DELAY_MS: u64 = 500;
        for attempt in 1..=MAX_ATTEMPTS {
            let started_at = Instant::now();
            let response = self
                .http
                .get(url)
                .header("Authorization", format!("Bearer {}", self.token))
                .header("X-Requested-By", "orchai")
                .send()
                .await;
            match response {
                Ok(resp) => {
                    let status = resp.status();
                    if (status == reqwest::StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) && attempt < MAX_ATTEMPTS {
                        let delay_ms = BASE_DELAY_MS * 2u64.pow(attempt - 1);
                        eprintln!("[graylog] retry GET {} after {}ms (status={})", url, delay_ms, status);
                        sleep(Duration::from_millis(delay_ms)).await;
                        continue;
                    }
                    eprintln!("[graylog] GET {} status={} {}ms", url, status, started_at.elapsed().as_millis());
                    return Ok(resp);
                }
                Err(err) => {
                    if attempt < MAX_ATTEMPTS {
                        let delay_ms = BASE_DELAY_MS * 2u64.pow(attempt - 1);
                        eprintln!("[graylog] retry GET {} after {}ms ({})", url, delay_ms, err);
                        sleep(Duration::from_millis(delay_ms)).await;
                        continue;
                    }
                    return Err(format!("graylog request failed: {}", err));
                }
            }
        }
        Err("graylog request failed after retries".to_string())
    }

    pub async fn test_connection(&self) -> Result<(), String> {
        let url = format!("{}/api/system", self.base_url);
        let resp = self.send_get(&url).await?;
        if resp.status().is_success() {
            Ok(())
        } else {
            Err(format!("graylog connection test failed: HTTP {}", resp.status()))
        }
    }

    pub async fn search_relative(
        &self,
        query: &str,
        stream_id: Option<&str>,
        range_seconds: i32,
    ) -> Result<Vec<GraylogEvent>, String> {
        let mut url = format!(
            "{}/api/search/universal/relative?query={}&range={}&limit=500",
            self.base_url,
            urlencoding::encode(query),
            range_seconds
        );
        if let Some(stream_id) = stream_id {
            url.push_str(&format!("&streams={}", urlencoding::encode(stream_id)));
        }
        let resp = self.send_get(&url).await?;
        if !resp.status().is_success() {
            return Err(format!("graylog search failed: HTTP {}", resp.status()));
        }
        let body: Value = resp.json().await.map_err(|e| format!("invalid graylog JSON: {}", e))?;
        Ok(parse_search_response(&body))
    }
}

pub fn parse_search_response(body: &Value) -> Vec<GraylogEvent> {
    let rows = body.get("messages").and_then(|v| v.as_array()).cloned().unwrap_or_default();
    rows.into_iter()
        .filter_map(|row| {
            let message = row.get("message")?;
            let timestamp = message.get("timestamp").and_then(|v| v.as_str()).unwrap_or("").to_string();
            let source = message.get("source").and_then(|v| v.as_str()).unwrap_or("").to_string();
            let level = message.get("level")
                .and_then(|v| v.as_str())
                .map(str::to_string)
                .unwrap_or_else(|| message.get("level").map(|v| v.to_string()).unwrap_or_default());
            let msg = message.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string();
            if timestamp.is_empty() || source.is_empty() || msg.is_empty() {
                return None;
            }
            let service = message.get("service").and_then(|v| v.as_str()).map(str::to_string);
            Some(GraylogEvent {
                timestamp,
                source,
                service,
                level,
                message: msg,
                raw: message.clone(),
            })
        })
        .collect()
}
  • Step 3: Implement Graylog commands skeleton with credential CRUD and test/manual operations

Create src-tauri/src/commands/graylog.rs with:

#[tauri::command]
pub fn set_graylog_credentials(
    state: State<'_, AppState>,
    project_id: String,
    base_url: String,
    api_token: String,
    analyst_agent_id: String,
    developer_agent_id: String,
    stream_id: Option<String>,
    query_filter: String,
    polling_interval_minutes: i32,
    lookback_minutes: i32,
    score_threshold: i32,
) -> Result<GraylogCredentialsSafe, AppError> {
    let token_encrypted = crypto::encrypt(&state.encryption_key, api_token.trim())
        .map_err(AppError::from)?;
    let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
    let creds = GraylogCredentials::upsert_for_project(
        &db,
        &project_id,
        base_url.trim(),
        &token_encrypted,
        analyst_agent_id.trim(),
        developer_agent_id.trim(),
        stream_id.as_deref(),
        query_filter.trim(),
        polling_interval_minutes,
        lookback_minutes,
        score_threshold,
    )?;
    Ok(creds.to_safe())
}

#[tauri::command]
pub async fn test_graylog_connection(
    state: State<'_, AppState>,
    project_id: String,
) -> Result<String, AppError> {
    let (base_url, token) = {
        let db = state.db.lock().map_err(|e| AppError::from(format!("Database lock failed: {}", e)))?;
        let creds = GraylogCredentials::get_by_project(&db, &project_id)?
            .ok_or_else(|| AppError::from("No Graylog credentials configured".to_string()))?;
        let token = crypto::decrypt(&state.encryption_key, &creds.api_token_encrypted)
            .map_err(AppError::from)?;
        (creds.base_url, token)
    };
    let client = GraylogClient::new(&state.http_client, &base_url, &token);
    client.test_connection().await.map_err(AppError::from)?;
    Ok("Connection successful".to_string())
}

#[tauri::command]
pub async fn manual_graylog_poll(
    state: State<'_, AppState>,
    app_handle: tauri::AppHandle,
    project_id: String,
) -> Result<i32, AppError> {
    let count = crate::services::graylog_poller::poll_project_once(
        &state.db,
        &state.encryption_key,
        &state.http_client,
        &app_handle,
        &project_id,
    )
    .await
    .map_err(AppError::from)?;
    Ok(count)
}
  • Step 4: Register commands and service exports

In src-tauri/src/commands/mod.rs:

pub mod graylog;

In src-tauri/src/services/mod.rs:

pub mod graylog_client;

In src-tauri/src/lib.rs invoke handler:

commands::graylog::set_graylog_credentials,
commands::graylog::get_graylog_credentials,
commands::graylog::delete_graylog_credentials,
commands::graylog::test_graylog_connection,
commands::graylog::manual_graylog_poll,
commands::graylog::list_graylog_subjects,
commands::graylog::list_graylog_detections,
  • Step 5: Run command and client tests

Run:

cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib services::graylog_client -- --nocapture
cargo test --lib commands::graylog -- --nocapture

Expected: PASS.

  • Step 6: Commit
cd /home/leclere/Projets/IA/orchai
git add src-tauri/src/services/graylog_client.rs src-tauri/src/commands/graylog.rs src-tauri/src/commands/mod.rs src-tauri/src/lib.rs src-tauri/src/services/mod.rs
git commit -m "feat(graylog): add client and tauri commands"

Task 6: Implement Graylog poller and orchestrator source-aware behavior

Files:

  • Create: src-tauri/src/services/graylog_poller.rs

  • Modify: src-tauri/src/services/orchestrator.rs

  • Modify: src-tauri/src/services/mod.rs

  • Modify: src-tauri/src/commands/orchestrator.rs

  • Modify: src-tauri/src/lib.rs

  • Step 1: Add failing tests for dedup strictness

In graylog_poller.rs tests:

#[test]
fn test_should_trigger_subject_respects_active_ticket() {
    assert!(should_trigger_subject(82, 70, false));
    assert!(!should_trigger_subject(82, 70, true));
    assert!(!should_trigger_subject(60, 70, false));
}

With helper:

fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) -> bool {
    score >= threshold && !has_active_ticket
}
  • Step 2: Implement graylog_poller.rs service

Create src-tauri/src/services/graylog_poller.rs:

use crate::models::graylog::{GraylogCredentials, GraylogDetection, GraylogSubject};
use crate::models::module::{ProjectModule, MODULE_GRAYLOG_AUTO_RESOLVE};
use crate::models::ticket::ProcessedTicket;
use crate::services::graylog_client::GraylogClient;
use crate::services::graylog_scoring::{group_subjects, SubjectAggregate};
use crate::services::crypto;
use rusqlite::Connection;
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter};
use tokio::time::{interval, Duration};

fn should_trigger_subject(score: i32, threshold: i32, has_active_ticket: bool) -> bool {
    score >= threshold && !has_active_ticket
}

fn synthetic_artifact_id(subject_key: &str) -> i32 {
    use std::hash::{Hash, Hasher};
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    subject_key.hash(&mut hasher);
    let raw = (hasher.finish() & 0x3fff_ffff) as i32;
    -raw.max(1)
}

fn subject_payload(aggregate: &SubjectAggregate) -> String {
    serde_json::json!({
        "source": aggregate.source,
        "normalized_message": aggregate.normalized_message,
        "counts": {
            "critical": aggregate.counts.critical,
            "error": aggregate.counts.error,
            "warning": aggregate.counts.warning,
            "total": aggregate.total_count
        },
        "last_seen_at": aggregate.last_seen_at,
        "score": aggregate.score,
        "samples": aggregate.sample_events
    }).to_string()
}

And core poll entrypoint:

pub async fn poll_project_once(
    db: &Arc<Mutex<Connection>>,
    encryption_key: &[u8; 32],
    http_client: &reqwest::Client,
    app_handle: &AppHandle,
    project_id: &str,
) -> Result<i32, String> {
    let creds = {
        let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
        let enabled = ProjectModule::is_enabled(&conn, project_id, MODULE_GRAYLOG_AUTO_RESOLVE)
            .map_err(|e| format!("module lookup failed: {}", e))?;
        if !enabled {
            return Ok(0);
        }
        GraylogCredentials::get_by_project(&conn, project_id)
            .map_err(|e| format!("credentials lookup failed: {}", e))?
            .ok_or_else(|| "No Graylog credentials configured".to_string())?
    };

    let token = crypto::decrypt(encryption_key, &creds.api_token_encrypted)
        .map_err(|e| format!("token decrypt failed: {}", e))?;
    let client = GraylogClient::new(http_client, &creds.base_url, &token);
    let query = if creds.query_filter.trim().is_empty() {
        "level:(critical OR error OR warning)".to_string()
    } else {
        creds.query_filter.clone()
    };
    let events = client
        .search_relative(&query, creds.stream_id.as_deref(), creds.lookback_minutes * 60)
        .await?;

    let now = chrono::Utc::now();
    let aggregates = group_subjects(&events, now);
    let mut triggered_count = 0i32;

    let _ = app_handle.emit("graylog-polling-started", serde_json::json!({ "project_id": project_id }));

    for aggregate in aggregates {
        let (subject, active_in_progress) = {
            let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
            let subject = GraylogSubject::upsert_subject(
                &conn,
                project_id,
                &aggregate.subject_key,
                &aggregate.source,
                &aggregate.normalized_message,
                &aggregate.last_seen_at,
                aggregate.score,
            ).map_err(|e| format!("upsert subject failed: {}", e))?;

            let active = match &subject.active_ticket_id {
                Some(ticket_id) => ProcessedTicket::get_by_id(&conn, ticket_id)
                    .map(|t| matches!(t.status.as_str(), "Pending" | "Analyzing" | "Developing"))
                    .unwrap_or(false),
                None => false,
            };
            (subject, active)
        };

        let should_trigger = should_trigger_subject(
            aggregate.score,
            creds.score_threshold,
            active_in_progress,
        );

        let triggered_ticket_id = if should_trigger {
            let mut conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
            let ticket = ProcessedTicket::insert_external(
                &conn,
                project_id,
                "graylog",
                Some(&subject.id),
                synthetic_artifact_id(&aggregate.subject_key),
                &format!("[Graylog] {} - {}", aggregate.source, aggregate.normalized_message),
                &subject_payload(&aggregate),
            ).map_err(|e| format!("insert graylog ticket failed: {}", e))?;
            GraylogSubject::set_active_ticket(&conn, &subject.id, Some(&ticket.id), "queued")
                .map_err(|e| format!("set active ticket failed: {}", e))?;
            triggered_count += 1;
            let _ = app_handle.emit("graylog-subject-triggered", serde_json::json!({
                "project_id": project_id,
                "subject_id": subject.id,
                "ticket_id": ticket.id,
                "score": aggregate.score
            }));
            Some(ticket.id)
        } else {
            None
        };

        {
            let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
            GraylogDetection::insert(
                &conn,
                &subject.id,
                &(now - chrono::Duration::minutes(creds.lookback_minutes as i64)).to_rfc3339(),
                &now.to_rfc3339(),
                aggregate.counts.critical,
                aggregate.counts.error,
                aggregate.counts.warning,
                aggregate.total_count,
                &aggregate.last_seen_at,
                aggregate.score,
                should_trigger,
                triggered_ticket_id.as_deref(),
            ).map_err(|e| format!("insert detection failed: {}", e))?;
        }
    }

    let _ = app_handle.emit("graylog-polling-finished", serde_json::json!({
        "project_id": project_id,
        "triggered_count": triggered_count
    }));

    Ok(triggered_count)
}
  • Step 3: Start background poller and export module

In src-tauri/src/services/mod.rs:

pub mod graylog_poller;

In src-tauri/src/lib.rs setup:

services::graylog_poller::start(
    db_arc.clone(),
    encryption_key,
    http_client.clone(),
    app.handle().clone(),
);
  • Step 4: Make orchestrator source-aware

In src-tauri/src/services/orchestrator.rs, use ticket source to resolve project/agents:

let (project, analyst_agent, developer_agent) = {
    let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
    let project = crate::models::project::Project::get_by_id(&conn, &ticket.project_id)
        .map_err(|e| format!("project lookup failed: {}", e))?;

    if ticket.source == "graylog" {
        let cfg = crate::models::graylog::GraylogCredentials::get_by_project(&conn, &project.id)
            .map_err(|e| format!("graylog credentials lookup failed: {}", e))?
            .ok_or_else(|| "Graylog credentials are missing".to_string())?;
        let analyst_agent = Agent::get_by_id(&conn, &cfg.analyst_agent_id)
            .map_err(|_| "Configured graylog analyst not found".to_string())?;
        let developer_agent = Agent::get_by_id(&conn, &cfg.developer_agent_id)
            .map_err(|_| "Configured graylog developer not found".to_string())?;
        (project, analyst_agent, developer_agent)
    } else {
        let tracker_id = ticket.tracker_id.as_deref().ok_or_else(|| "Missing tracker_id for tuleap ticket".to_string())?;
        let tracker = WatchedTracker::get_by_id(&conn, tracker_id)
            .map_err(|e| format!("get tracker failed: {}", e))?;
        let analyst_id = tracker.analyst_agent_id.as_deref().ok_or_else(|| "Tracker has no analyst".to_string())?;
        let developer_id = tracker.developer_agent_id.as_deref().ok_or_else(|| "Tracker has no developer".to_string())?;
        let analyst_agent = Agent::get_by_id(&conn, analyst_id).map_err(|_| "Configured analyst not found".to_string())?;
        let developer_agent = Agent::get_by_id(&conn, developer_id).map_err(|_| "Configured developer not found".to_string())?;
        (project, analyst_agent, developer_agent)
    }
};

Also update commands/orchestrator.rs retry cleanup project resolution:

let project = crate::models::project::Project::get_by_id(&conn, &ticket.project_id)?;
  • Step 5: Run service and orchestrator tests

Run:

cd /home/leclere/Projets/IA/orchai/src-tauri
cargo test --lib services::graylog_poller -- --nocapture
cargo test --lib services::orchestrator -- --nocapture
cargo test --lib commands::orchestrator -- --nocapture

Expected: PASS with dedup behavior covered.

  • Step 6: Commit
cd /home/leclere/Projets/IA/orchai
git add src-tauri/src/services/graylog_poller.rs src-tauri/src/services/orchestrator.rs src-tauri/src/commands/orchestrator.rs src-tauri/src/services/mod.rs src-tauri/src/lib.rs
git commit -m "feat(graylog): add poller bridge and source-aware orchestrator flow"

Task 7: Frontend API/types wiring for Graylog

Files:

  • Modify: src/lib/types.ts

  • Modify: src/lib/api.ts

  • Step 1: Add TS types

In src/lib/types.ts:

export interface GraylogCredentialsSafe {
  id: string;
  project_id: string;
  base_url: string;
  analyst_agent_id: string;
  developer_agent_id: string;
  stream_id: string | null;
  query_filter: string;
  polling_interval_minutes: number;
  lookback_minutes: number;
  score_threshold: number;
}

export interface GraylogSubject {
  id: string;
  project_id: string;
  subject_key: string;
  source: string;
  normalized_message: string;
  first_seen_at: string;
  last_seen_at: string;
  last_score: number;
  active_ticket_id: string | null;
  status: string;
}

export interface GraylogDetection {
  id: string;
  subject_id: string;
  window_start: string;
  window_end: string;
  critical_count: number;
  error_count: number;
  warning_count: number;
  total_count: number;
  last_seen_at: string;
  score: number;
  triggered: boolean;
  triggered_ticket_id: string | null;
  created_at: string;
}

Also extend ProcessedTicket:

  tracker_id: string | null;
  project_id: string;
  source: string;
  source_ref: string | null;
  • Step 2: Add Graylog API wrappers

In src/lib/api.ts:

export async function getGraylogCredentials(projectId: string): Promise<GraylogCredentialsSafe | null> {
  return invoke("get_graylog_credentials", { projectId });
}

export async function setGraylogCredentials(
  projectId: string,
  baseUrl: string,
  apiToken: string,
  analystAgentId: string,
  developerAgentId: string,
  streamId: string | null,
  queryFilter: string,
  pollingIntervalMinutes: number,
  lookbackMinutes: number,
  scoreThreshold: number
): Promise<GraylogCredentialsSafe> {
  return invoke("set_graylog_credentials", {
    projectId,
    baseUrl,
    apiToken,
    analystAgentId,
    developerAgentId,
    streamId,
    queryFilter,
    pollingIntervalMinutes,
    lookbackMinutes,
    scoreThreshold,
  });
}

export async function listGraylogSubjects(projectId: string): Promise<GraylogSubject[]> {
  return invoke("list_graylog_subjects", { projectId });
}

export async function listGraylogDetections(projectId: string, subjectId?: string): Promise<GraylogDetection[]> {
  return invoke("list_graylog_detections", { projectId, subjectId });
}

export async function testGraylogConnection(projectId: string): Promise<string> {
  return invoke("test_graylog_connection", { projectId });
}

export async function manualGraylogPoll(projectId: string): Promise<number> {
  return invoke("manual_graylog_poll", { projectId });
}
  • Step 3: Run frontend typecheck

Run:

cd /home/leclere/Projets/IA/orchai
npm run qa:frontend

Expected: PASS.

  • Step 4: Commit
cd /home/leclere/Projets/IA/orchai
git add src/lib/types.ts src/lib/api.ts
git commit -m "feat(frontend): add graylog types and API bindings"

Task 8: Build Graylog project UI and live activity hooks

Files:

  • Create: src/components/projects/ProjectGraylog.tsx

  • Modify: src/components/projects/ProjectDashboard.tsx

  • Modify: src/components/projects/ProjectModules.tsx

  • Modify: src/components/tickets/TicketList.tsx

  • Modify: src/components/tickets/TicketDetail.tsx

  • Modify: src/App.tsx

  • Step 1: Add route and dashboard entry

In src/App.tsx add:

import ProjectGraylog from "./components/projects/ProjectGraylog";
<Route path="/projects/:projectId/graylog" element={<ProjectGraylog />} />

In ProjectDashboard.tsx add Graylog card:

<Link
  to={`/projects/${project.id}/graylog`}
  className="rounded-lg border border-gray-200 bg-white p-4 hover:border-gray-300"
>
  <div className="text-sm font-semibold text-gray-900">Graylog</div>
  <div className="mt-1 text-xs text-gray-500">
    Configure le polling Graylog et surveille les sujets scorés.
  </div>
</Link>
  • Step 2: Add Graylog live events on dashboard

In ProjectDashboard.tsx add listeners:

listen("graylog-polling-started", (event) => {
  const payload = event.payload as { project_id: string };
  if (payload.project_id !== projectId) return;
  appendActivity("info", "Polling Graylog lancé.");
});

listen("graylog-subject-triggered", (event) => {
  const payload = event.payload as { project_id: string; score: number; subject_id: string };
  if (payload.project_id !== projectId) return;
  appendActivity("success", `Sujet Graylog déclenché (score ${payload.score}).`);
  void loadData();
});

listen("graylog-polling-error", (event) => {
  const payload = event.payload as { project_id: string; error: string };
  if (payload.project_id !== projectId) return;
  appendActivity("error", `Erreur Graylog: ${payload.error}`);
});
  • Step 3: Create ProjectGraylog.tsx page

Create src/components/projects/ProjectGraylog.tsx with:

import { FormEvent, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import {
  getGraylogCredentials,
  listAgents,
  listGraylogDetections,
  listGraylogSubjects,
  manualGraylogPoll,
  setGraylogCredentials,
  testGraylogConnection,
} from "../../lib/api";
import type { Agent, GraylogCredentialsSafe, GraylogDetection, GraylogSubject } from "../../lib/types";
import { getErrorMessage } from "../../lib/errors";

export default function ProjectGraylog() {
  const { projectId } = useParams<{ projectId: string }>();
  const [agents, setAgents] = useState<Agent[]>([]);
  const [credentials, setCredentials] = useState<GraylogCredentialsSafe | null>(null);
  const [subjects, setSubjects] = useState<GraylogSubject[]>([]);
  const [detections, setDetections] = useState<GraylogDetection[]>([]);
  const [baseUrl, setBaseUrl] = useState("");
  const [apiToken, setApiToken] = useState("");
  const [analystAgentId, setAnalystAgentId] = useState("");
  const [developerAgentId, setDeveloperAgentId] = useState("");
  const [streamId, setStreamId] = useState("");
  const [queryFilter, setQueryFilter] = useState("level:(critical OR error OR warning)");
  const [pollingIntervalMinutes, setPollingIntervalMinutes] = useState(10);
  const [lookbackMinutes, setLookbackMinutes] = useState(30);
  const [scoreThreshold, setScoreThreshold] = useState(70);
  const [loading, setLoading] = useState(false);
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [success, setSuccess] = useState<string | null>(null);

  async function refresh() {
    if (!projectId) return;
    setLoading(true);
    setError(null);
    try {
      const [agentList, creds, subjectList, detectionList] = await Promise.all([
        listAgents(),
        getGraylogCredentials(projectId),
        listGraylogSubjects(projectId),
        listGraylogDetections(projectId),
      ]);
      setAgents(agentList);
      setCredentials(creds);
      setSubjects(subjectList);
      setDetections(detectionList);
      if (creds) {
        setBaseUrl(creds.base_url);
        setAnalystAgentId(creds.analyst_agent_id);
        setDeveloperAgentId(creds.developer_agent_id);
        setStreamId(creds.stream_id ?? "");
        setQueryFilter(creds.query_filter);
        setPollingIntervalMinutes(creds.polling_interval_minutes);
        setLookbackMinutes(creds.lookback_minutes);
        setScoreThreshold(creds.score_threshold);
      }
    } catch (err: unknown) {
      setError(getErrorMessage(err));
    } finally {
      setLoading(false);
    }
  }

  useEffect(() => {
    void refresh();
  }, [projectId]);

  async function handleSave(event: FormEvent) {
    event.preventDefault();
    if (!projectId) return;
    setSaving(true);
    setError(null);
    setSuccess(null);
    try {
      const saved = await setGraylogCredentials(
        projectId,
        baseUrl,
        apiToken,
        analystAgentId,
        developerAgentId,
        streamId.trim() || null,
        queryFilter,
        pollingIntervalMinutes,
        lookbackMinutes,
        scoreThreshold
      );
      setCredentials(saved);
      setApiToken("");
      setSuccess("Configuration Graylog sauvegardée.");
      await refresh();
    } catch (err: unknown) {
      setError(getErrorMessage(err));
    } finally {
      setSaving(false);
    }
  }

  async function handleTest() {
    if (!projectId) return;
    setError(null);
    setSuccess(null);
    try {
      const message = await testGraylogConnection(projectId);
      setSuccess(message);
    } catch (err: unknown) {
      setError(getErrorMessage(err));
    }
  }

  async function handleManualPoll() {
    if (!projectId) return;
    setError(null);
    setSuccess(null);
    try {
      const triggered = await manualGraylogPoll(projectId);
      setSuccess(`Polling Graylog manuel terminé: ${triggered} sujet(s) déclenché(s).`);
      await refresh();
    } catch (err: unknown) {
      setError(getErrorMessage(err));
    }
  }

  const analysts = agents.filter((a) => a.role === "analyst");
  const developers = agents.filter((a) => a.role === "developer");

  return (
    <div className="space-y-6 p-8">
      <div className="flex items-center justify-between">
        <h2 className="text-xl font-bold">Graylog</h2>
        {projectId && <Link to={`/projects/${projectId}`} className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-700 hover:bg-gray-300">Retour</Link>}
      </div>

      {error && <div className="rounded border border-red-200 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
      {success && <div className="rounded border border-green-200 bg-green-50 p-3 text-sm text-green-700">{success}</div>}

      <form onSubmit={handleSave} className="rounded-lg border border-gray-200 bg-white p-4 space-y-3">
        <h3 className="text-sm font-semibold text-gray-900">Configuration</h3>
        <input className="w-full rounded border border-gray-300 px-3 py-2 text-sm" value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://graylog.example.com" required />
        <input className="w-full rounded border border-gray-300 px-3 py-2 text-sm" value={apiToken} onChange={(e) => setApiToken(e.target.value)} placeholder={credentials ? "Laisser vide pour conserver le token actuel" : "Token API Graylog"} required={!credentials} />
        <div className="grid gap-3 md:grid-cols-2">
          <select className="rounded border border-gray-300 px-3 py-2 text-sm" value={analystAgentId} onChange={(e) => setAnalystAgentId(e.target.value)} required>
            <option value="">Analyst agent</option>
            {analysts.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
          </select>
          <select className="rounded border border-gray-300 px-3 py-2 text-sm" value={developerAgentId} onChange={(e) => setDeveloperAgentId(e.target.value)} required>
            <option value="">Developer agent</option>
            {developers.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
          </select>
        </div>
        <div className="grid gap-3 md:grid-cols-4">
          <input className="rounded border border-gray-300 px-3 py-2 text-sm" value={streamId} onChange={(e) => setStreamId(e.target.value)} placeholder="stream_id (optionnel)" />
          <input className="rounded border border-gray-300 px-3 py-2 text-sm md:col-span-3" value={queryFilter} onChange={(e) => setQueryFilter(e.target.value)} />
        </div>
        <div className="grid gap-3 md:grid-cols-3">
          <input type="number" className="rounded border border-gray-300 px-3 py-2 text-sm" value={pollingIntervalMinutes} onChange={(e) => setPollingIntervalMinutes(Number(e.target.value))} min={1} />
          <input type="number" className="rounded border border-gray-300 px-3 py-2 text-sm" value={lookbackMinutes} onChange={(e) => setLookbackMinutes(Number(e.target.value))} min={1} />
          <input type="number" className="rounded border border-gray-300 px-3 py-2 text-sm" value={scoreThreshold} onChange={(e) => setScoreThreshold(Number(e.target.value))} min={1} max={100} />
        </div>
        <div className="flex gap-2">
          <button type="submit" disabled={saving} className="rounded bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50">{saving ? "Sauvegarde..." : "Sauvegarder"}</button>
          <button type="button" onClick={() => void handleTest()} className="rounded bg-gray-200 px-4 py-2 text-sm text-gray-800 hover:bg-gray-300">Tester la connexion</button>
          <button type="button" onClick={() => void handleManualPoll()} className="rounded bg-gray-900 px-4 py-2 text-sm text-white hover:bg-black">Poll manuel</button>
        </div>
      </form>

      <div className="rounded-lg border border-gray-200 bg-white p-4">
        <h3 className="mb-3 text-sm font-semibold text-gray-900">Sujets détectés</h3>
        {loading ? <div className="text-sm text-gray-500">Chargement...</div> : (
          <div className="space-y-2">
            {subjects.map((subject) => (
              <div key={subject.id} className="rounded border border-gray-100 p-3">
                <div className="text-sm font-medium text-gray-900">{subject.source}</div>
                <div className="text-xs text-gray-600">{subject.normalized_message}</div>
                <div className="mt-1 text-xs text-gray-500">Score: {subject.last_score} | Statut: {subject.status} | Last seen: {new Date(subject.last_seen_at).toLocaleString()}</div>
              </div>
            ))}
            {subjects.length === 0 && <div className="text-sm text-gray-400">Aucun sujet détecté.</div>}
          </div>
        )}
      </div>

      <div className="rounded-lg border border-gray-200 bg-white p-4">
        <h3 className="mb-3 text-sm font-semibold text-gray-900">Dernières détections</h3>
        <div className="space-y-2">
          {detections.slice(0, 20).map((detection) => (
            <div key={detection.id} className="rounded border border-gray-100 p-3 text-xs text-gray-700">
              score={detection.score} total={detection.total_count} triggered={String(detection.triggered)} at={new Date(detection.created_at).toLocaleString()}
            </div>
          ))}
          {detections.length === 0 && <div className="text-sm text-gray-400">Aucune détection enregistrée.</div>}
        </div>
      </div>
    </div>
  );
}
  • Step 4: Add Graylog shortcut in module cards

In src/components/projects/ProjectModules.tsx, inside each module card:

{projectId && mod.module_key === "graylog_polling_auto_resolve" && (
  <Link
    to={`/projects/${projectId}/graylog`}
    className="mt-2 inline-flex rounded bg-gray-100 px-2 py-1 text-xs text-gray-700 hover:bg-gray-200"
  >
    Configurer
  </Link>
)}
  • Step 5: Add source awareness in ticket views

In TicketList.tsx ticket row header:

<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">
  {ticket.source}
</span>

In TicketDetail.tsx info tab:

<div>
  <span className="text-sm text-gray-500">Source:</span>
  <span className="ml-2 text-sm uppercase">{ticket.source}</span>
</div>
{ticket.source_ref && (
  <div>
    <span className="text-sm text-gray-500">Source ref:</span>
    <span className="ml-2 font-mono text-sm">{ticket.source_ref}</span>
  </div>
)}
  • Step 6: Run frontend QA and production build

Run:

cd /home/leclere/Projets/IA/orchai
npm run qa:frontend
npm run build

Expected: PASS (typecheck + production bundle build).

  • Step 7: Commit
cd /home/leclere/Projets/IA/orchai
git add src/components/projects/ProjectGraylog.tsx src/components/projects/ProjectDashboard.tsx src/components/projects/ProjectModules.tsx src/components/tickets/TicketList.tsx src/components/tickets/TicketDetail.tsx src/App.tsx
git commit -m "feat(ui): add graylog project page and live subject visibility"

Task 9: End-to-end verification and cleanup

Files:

  • Modify: touched files containing QA fixes discovered in this task

  • Step 1: Run full backend QA

Run:

cd /home/leclere/Projets/IA/orchai
npm run qa:backend:check
npm run qa:backend:test

Expected: PASS.

  • Step 2: Run full project QA

Run:

cd /home/leclere/Projets/IA/orchai
npm run qa

Expected: PASS with no clippy warnings.

  • Step 3: Manual validation checklist

Run app:

cd /home/leclere/Projets/IA/orchai
npm run tauri dev

Validate:

  1. Module Polling Graylog + auto-resolve appears in project modules.
  2. Graylog config page saves credentials and tests connection.
  3. Manual Graylog poll creates subject rows and detection history.
  4. High-score subject creates one queue item only while active (strict dedup).
  5. Ticket detail shows source=graylog and still supports retry/cancel path.
  • Step 4: Final commit for follow-up fixes
cd /home/leclere/Projets/IA/orchai
git status --short
# If non-empty, continue:
git add -A
git commit -m "fix(graylog): complete integration polish and qa fixes"

Spec Coverage Checklist (self-review)

  1. Project-scoped credentials: covered by Tasks 1, 3, 5, 8.
  2. Subject grouping (source + normalized_message): covered by Task 4 and consumed in Task 6.
  3. Deterministic scoring (severity + frequency + recency): covered by Task 4.
  4. Strict dedup (single active branch per subject): covered by Task 6 tests and logic.
  5. Reuse existing analyst -> developer pipeline: covered by Task 2 + Task 6 orchestrator source-aware updates.
  6. UI module/config/visibility: covered by Tasks 7 and 8.
  7. Observability events (graylog-*): covered by Tasks 6 and 8.
  8. Rollout safety via full QA: covered by Task 9.